fix anwser token and database issus
This commit is contained in:
@@ -27,10 +27,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Session key prefix for storing math captcha answers
|
||||
sessionKeyPrefix = "math_captcha_"
|
||||
// TTL for each captcha in session
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaTTL = 5 * time.Minute
|
||||
// maxCaptchaAttempts 同一条验证码被尝试(含模态框预校验)的最大次数,
|
||||
// 超过即销毁该验证码,迫使刷新,防止对答案做无限轮询爆破。
|
||||
maxCaptchaAttempts = 5
|
||||
@@ -52,7 +50,6 @@ type StoredAnswer struct {
|
||||
|
||||
// GenerateMath creates a new arithmetic captcha problem and stores the answer in session.
|
||||
func GenerateMath(s session.Session) (*MathProblem, error) {
|
||||
// Generate random operands
|
||||
op1, err := rand.Int(rand.Reader, big.NewInt(90))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "generate operand 1")
|
||||
@@ -64,7 +61,6 @@ func GenerateMath(s session.Session) (*MathProblem, error) {
|
||||
a := int(op1.Int64()) + 1 // 1-90
|
||||
b := int(op2.Int64()) + 1 // 1-90
|
||||
|
||||
// Pick a random operator (+, -, *)
|
||||
opIdx, err := rand.Int(rand.Reader, big.NewInt(3))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "generate operator")
|
||||
@@ -90,18 +86,16 @@ func GenerateMath(s session.Session) (*MathProblem, error) {
|
||||
answer = a * b
|
||||
}
|
||||
|
||||
// 生成唯一 ID
|
||||
idBytes := make([]byte, 8)
|
||||
if _, err := rand.Read(idBytes); err != nil {
|
||||
return nil, errors.Wrap(err, "generate captcha id")
|
||||
}
|
||||
id := hex.EncodeToString(idBytes)
|
||||
|
||||
// 清理策略说明:flamego session 接口不支持遍历,无法主动清理过期条目。
|
||||
// 泄漏可控:验证码一次性消费(VerifyMath 校验后立即删除),
|
||||
// 且每个用户同一时刻只生成 1 个新 key;session 本身过期后存储层整体清理。
|
||||
// 清理策略:flamego session 不支持遍历,无法主动清理过期条目。但验证码一次性消费
|
||||
// (校验后立即删除)、每用户同时刻只生成 1 个新 key,session 过期后存储层整体清理,
|
||||
// 泄漏可控。
|
||||
|
||||
// Store answer in session
|
||||
key := sessionKeyPrefix + id
|
||||
s.Set(key, StoredAnswer{
|
||||
Answer: answer,
|
||||
@@ -136,14 +130,12 @@ func VerifyMath(s session.Session, token string, lang string) (bool, error) {
|
||||
return false, errors.New(i18n.T(lang, "captcha.errAnswerFormat"))
|
||||
}
|
||||
|
||||
// Retrieve stored answer
|
||||
key := sessionKeyPrefix + id
|
||||
stored, ok := s.Get(key).(StoredAnswer)
|
||||
if !ok {
|
||||
return false, errors.New(i18n.T(lang, "captcha.errExpired"))
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if time.Since(stored.CreatedAt) > captchaTTL {
|
||||
s.Delete(key)
|
||||
return false, errors.New(i18n.T(lang, "captcha.errExpired"))
|
||||
@@ -186,8 +178,8 @@ func VerifyOnly(s session.Session, id string, answer int) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(strconv.Itoa(answer)), []byte(strconv.Itoa(stored.Answer))) == 1
|
||||
}
|
||||
|
||||
// sessionLang 从会话读取匿名用户显式选择的语言(与 Contexter 一致,语言偏好现托管于
|
||||
// session 而非独立 lang cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。
|
||||
// sessionLang 从会话读取匿名用户显式选择的语言(语言偏好托管于 session 而非独立 lang
|
||||
// cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。
|
||||
func sessionLang(s session.Session) string {
|
||||
if v := s.Get("lang"); v != nil {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
|
||||
+8
-13
@@ -12,7 +12,7 @@ import (
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// File is the configuration object.
|
||||
// File 是解析后的配置对象。
|
||||
var File *ini.File
|
||||
|
||||
func Init() error {
|
||||
@@ -110,19 +110,14 @@ func Init() error {
|
||||
}
|
||||
|
||||
// validateTextCensorSource 校验文本审核通道([security] text_censor_source)与凭据的搭配。
|
||||
// 抽成纯函数(只依赖入参、不读全局)以便单测覆盖,也让 Init 保持轻薄。
|
||||
//
|
||||
// 单独抽成纯函数(只依赖入参、不读全局)是为了能直接被单测覆盖,也让 Init 保持轻薄。
|
||||
//
|
||||
// 规则:
|
||||
// - 审核整体关闭(enabled=false)→ 不校验,此时不需要任何凭据。
|
||||
// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都没配凭据时
|
||||
// 维持既有行为(运行期 fail-closed),不阻断启动 —— 否则 app.sample.ini 原样拷贝
|
||||
// (enable_text_censor = true 且凭据留空)将无法启动,破坏开箱流程。
|
||||
// - 显式选 qiniu / aliyun → 必须配齐该通道的凭据,否则启动即失败。
|
||||
// 理由:缺凭据时运行期审核必然失败,而审核失败是 fail-closed(拒绝提交),
|
||||
// 会让发布/私信整站不可用,且现象隐蔽(只在日志可见)——不如启动就报错。
|
||||
// - 其它取值 → 直接报错,不做静默回退:拼写错误若被悄悄当成 auto,
|
||||
// 运维会以为「已经只用七牛了」,实际仍在走双通道。
|
||||
// - 审核整体关闭(enabled=false)→ 不校验,不需要任何凭据。
|
||||
// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都无凭据时
|
||||
// 维持既有运行期 fail-closed 行为,不阻断启动(否则 app.sample.ini 原样拷贝无法启动)。
|
||||
// - 显式选 qiniu / aliyun → 必须配齐该通道凭据,否则启动即失败:缺凭据时运行期审核必失败,
|
||||
// 而审核失败是 fail-closed(拒绝提交),会让发布/私信整站不可用且现象隐蔽。
|
||||
// - 其它取值 → 直接报错,不做静默回退:拼写错误若被当成 auto,运维会误以为已切通道。
|
||||
func validateTextCensorSource(enabled bool, source, qiniuKey, qiniuSecret, aliyunKey, aliyunSecret string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
|
||||
+21
-45
@@ -23,62 +23,39 @@ const (
|
||||
|
||||
// LimitsConfig 全站内容 / 数量上限([limits] 配置段)的字段集合。
|
||||
//
|
||||
// 过去这些上限以常量形式散落在 internal/db(MaxTags / MaxCustomTextLen 等)与
|
||||
// 路由层硬编码(私聊单条 2000 字、会话条数 clamp 5~200 等)中,调整需改代码重编译,
|
||||
// 且前后端、i18n 文案各自携带一份数值容易漂移。统一收口到 [limits] 段后:
|
||||
// - 后端校验、模板注入、前端提示全部引用 conf.Limits 的同一份值;
|
||||
// - 调整仅需修改 conf/app.ini 并重启进程,无需改代码;
|
||||
// - 长度类上限统一按字符(rune / Unicode 码点)计数。
|
||||
// 收口到本段前,这些上限散落在 internal/db 与路由层硬编码,改一次要重编译且前后端数值易漂移。
|
||||
// 现后端校验 / 模板注入 / 前端提示统一引用同一份值,调整只需改 app.ini 重启;
|
||||
// 长度类上限一律按字符(rune)计数。
|
||||
type LimitsConfig struct {
|
||||
// ── 标签与屏蔽词(账号设置页)──
|
||||
|
||||
// 标签个数上限
|
||||
MaxTags int `ini:"max_tags"`
|
||||
// 单个标签最大字符数
|
||||
MaxTagLen int `ini:"max_tag_len"`
|
||||
// 屏蔽词个数上限
|
||||
MaxBlockWords int `ini:"max_block_words"`
|
||||
// 单个屏蔽词最大字符数
|
||||
MaxTags int `ini:"max_tags"`
|
||||
MaxTagLen int `ini:"max_tag_len"`
|
||||
MaxBlockWords int `ini:"max_block_words"`
|
||||
MaxBlockWordLen int `ini:"max_block_word_len"`
|
||||
|
||||
// ── 投票 ──
|
||||
|
||||
// 参与者填写的「自定义补充选项」文本最大字符数。
|
||||
// 覆盖两类入口:单选/多选/排序的全局补充选项,以及表单收集字段的
|
||||
// 「其他/自定义选项」(field_{id}_custom)。
|
||||
// 参与者填写的「自定义补充选项」文本最大字符数(含表单字段的「其他」)。
|
||||
CustomAnswerLen int `ini:"custom_answer_len"`
|
||||
// 站长创建投票时单个「选项」文本的最大字符数。
|
||||
// 覆盖单选/多选/排序的预设选项,以及表单收集字段(select/radio/checkbox/rank)
|
||||
// 的预置选项。与 CustomAnswerLen 是两条独立规则(站长侧预设刻意放宽),可各自调参。
|
||||
// 站长创建投票时单个「选项」文本最大字符数。与 CustomAnswerLen 是两条独立规则
|
||||
// (站长侧预设刻意放宽),可各自调参。
|
||||
VoteOptionLen int `ini:"vote_option_len"`
|
||||
|
||||
// ── 提问 / 回答 / 追问 ──
|
||||
|
||||
// 提问正文最大字符数(访客向提问箱提交的问题内容)。
|
||||
// 旧实现依赖 govalid 的 maxlen 标签(编译期写死 2000),无法随配置调整,
|
||||
// 且与前端 maxlength=1000 不一致,现已移到路由层按本值做 rune 校验。
|
||||
// 提问正文最大字符数。旧实现靠 govalid maxlen 写死 2000 且与前端 1000 不一致,
|
||||
// 现改为路由层按本值做 rune 校验。
|
||||
QuestionContentLen int `ini:"question_content_len"`
|
||||
// 回答正文最大字符数:箱主对提问的正式回答,以及箱主对追问的回复(共用)。
|
||||
AnswerContentLen int `ini:"answer_content_len"`
|
||||
// 追问正文最大字符数(提问者在问题被回答后继续追问的内容)。
|
||||
// 回答正文最大字符数:箱主对提问的正式回答,以及对追问的回复(共用)。
|
||||
AnswerContentLen int `ini:"answer_content_len"`
|
||||
FollowUpContentLen int `ini:"followup_content_len"`
|
||||
|
||||
// ── 私聊 ──
|
||||
|
||||
// 单条消息(发送 / 回复共用)最大字符数
|
||||
ChatMessageLen int `ini:"chat_message_len"`
|
||||
// 每个私聊会话的消息条数上限(系统硬顶)。用户只能在 1..该值 之间设置自己的
|
||||
// chat_max_messages;新注册用户的默认值即该硬顶(而非固定 20)。
|
||||
// 每个会话的消息条数硬顶。用户只能在 1..该值间设置 chat_max_messages,
|
||||
// 新用户默认值即该硬顶(而非固定 20)。
|
||||
ChatMaxMessages int `ini:"chat_max_messages"`
|
||||
// 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)。
|
||||
// 该值只是「用户可设置的上限」,不是站点强制的每日配额。
|
||||
// 每日私信条数的「用户可设上限」(非站点强制配额):用户可设 0..该值,0 表示不限制。
|
||||
DailyMessageLimitMax int `ini:"daily_message_limit_max"`
|
||||
}
|
||||
|
||||
// Limits 保存运行时的全部上限配置。conf.Init 中会先 MapTo [limits] 段覆盖显式键,
|
||||
// 再调用 sanitizeLimits 把 <1 的值回退到内置默认,因此:
|
||||
// - 缺失 [limits] 段或个别键 → 使用内置默认;
|
||||
// - ini 键留空(int 解析为 0)或误填 0/负数 → 自动回退默认,不会把上限静默置零。
|
||||
// Limits 保存运行时的全部上限配置。conf.Init 先 MapTo [limits] 段,再由 sanitizeLimits
|
||||
// 把 <1 的值回退内置默认,因此缺段、缺键、留空(解析为 0)或填负数都不会让上限静默置零。
|
||||
var Limits = LimitsConfig{
|
||||
MaxTags: defaultMaxTags,
|
||||
MaxTagLen: defaultMaxTagLen,
|
||||
@@ -94,10 +71,9 @@ var Limits = LimitsConfig{
|
||||
DailyMessageLimitMax: defaultDailyMessageLimitMax,
|
||||
}
|
||||
|
||||
// sanitizeLimits 归一化 [limits] 配置:所有上限最小为 1(0 / 负数说明未配置或手滑,
|
||||
// 一律回退内置默认,避免「留空=0」把标签全拒、消息全超长等隐性灾难)。
|
||||
// 注意 DailyMessageLimitMax 同样要求 >=1:0 会让用户侧永远只能设为 0(=不限制),
|
||||
// 语义上等于关闭每日配额功能,需显式使用该字段其余语义时请另行设计。
|
||||
// sanitizeLimits 归一化 [limits]:所有上限最小为 1,0 / 负数一律回退内置默认,
|
||||
// 避免「留空=0」把标签全拒、消息全超长等隐性灾难。
|
||||
// DailyMessageLimitMax 同样要求 >=1:为 0 会让用户只能设为 0(=不限制),等于关闭每日配额功能。
|
||||
func sanitizeLimits(l *LimitsConfig) {
|
||||
clamp1 := func(p *int, def int) {
|
||||
if *p < 1 {
|
||||
|
||||
+29
-50
@@ -4,24 +4,17 @@
|
||||
|
||||
package conf
|
||||
|
||||
// Build time and commit information.
|
||||
//
|
||||
// ⚠️ WARNING: should only be set by "-ldflags".
|
||||
// ⚠️ 仅应由 "-ldflags" 注入。
|
||||
var (
|
||||
BuildTime string
|
||||
BuildCommit = "dev"
|
||||
)
|
||||
|
||||
// 文本审核服务通道取值([security] text_censor_source)。
|
||||
//
|
||||
// 定义在 conf 侧是为了让「配置校验」(conf.Init)与「通道装配」
|
||||
// (internal/security/censor)引用同一份字面量,避免两边各写一份字符串而漂移。
|
||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||
// 让配置校验(conf.Init)与通道装配(internal/security/censor)共用同一份字面量。
|
||||
const (
|
||||
// CensorSourceAuto 七牛优先、阿里云兜底(历史行为,留空同义)。
|
||||
CensorSourceAuto = "auto"
|
||||
// CensorSourceQiniu 只用七牛。
|
||||
CensorSourceQiniu = "qiniu"
|
||||
// CensorSourceAliyun 只用阿里云。
|
||||
CensorSourceAuto = "auto" // 七牛优先、阿里云兜底(历史行为,留空同义)
|
||||
CensorSourceQiniu = "qiniu"
|
||||
CensorSourceAliyun = "aliyun"
|
||||
)
|
||||
|
||||
@@ -47,43 +40,32 @@ var (
|
||||
IPBlacklist string `ini:"ip_blacklist"`
|
||||
IPBlacklistRedirectURL string `ini:"ip_blacklist_redirect_url"`
|
||||
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
|
||||
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律使用 RemoteAddr,
|
||||
// 防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
|
||||
// 防止伪造 header 绕过速率限制与 IP 黑名单。
|
||||
TrustProxy bool `ini:"trust_proxy"`
|
||||
// TrustedProxies 逗号分隔的 IP / CIDR 列表,仅限“你自己的”反向代理/负载均衡基础设施
|
||||
// (例如本机 nginx 的 127.0.0.1、私有网络 LB)。仅当 TCP 对端落在该清单内时才会解析
|
||||
// X-Forwarded-For / X-Real-IP。留空则这两个头永不被信任(CDN 权威头 CF-Connecting-IP /
|
||||
// EO-Client-IP 仍会被采用,前提是源站已被对应 CDN 锁定)。
|
||||
// 目的:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单——源站应配合
|
||||
// Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源网段锁定,
|
||||
// 使 CF-Connecting-IP / EO-Client-IP 成为权威客户端 IP。
|
||||
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
|
||||
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任
|
||||
// (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定,
|
||||
// 从而免于维护上游 CDN 的庞大 CIDR 清单)。
|
||||
TrustedProxies string `ini:"trusted_proxies"`
|
||||
// 自动封禁(fail2ban 风格):检测批量扫描/探测特征后临时封禁来源 IP。
|
||||
EnableAutoBan bool `ini:"enable_auto_ban"`
|
||||
// BanTrackNotFound 是否把「未注册路径的 404」计入封禁统计。默认 false:正常业务
|
||||
// (域名/题目不存在等均走 302 业务跳转,不产生计数)一律放行,仅命中后台扫描特征
|
||||
// 路径(KindSensitive)才参与统计封禁。避免共享出口(CDN 边缘 / CGNAT NAT444)下
|
||||
// 正常用户被 404 探测流量连坐误伤。如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
|
||||
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
|
||||
// 扫描特征路径(KindSensitive)才统计,避免共享出口(CDN 边缘 / CGNAT)下
|
||||
// 正常用户被 404 探测流量连坐误伤。
|
||||
BanTrackNotFound bool `ini:"ban_track_notfound"`
|
||||
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
|
||||
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
|
||||
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
|
||||
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
|
||||
// DefaultModerationStrictness 站点默认的「内容审核严格度」档位
|
||||
// (lax / standard / strict)。仅对未单独设置档位的账号生效:
|
||||
// 账号一旦显式选择了档位就以账号设置为准(见 moderation.StrictnessFor)。
|
||||
// 留空或非法值回退 strict(最严档)—— 保证升级不会放宽任何拦截;
|
||||
// 严格档拦截面不小于升级前,部署后默认拦截面可能略有扩大;
|
||||
// 若希望默认更少误伤,在本段显式写 standard 即可,无需改代码。
|
||||
// DefaultModerationStrictness 站点默认审核严格度(lax / standard / strict),
|
||||
// 仅对未单独设置档位的账号生效(见 moderation.StrictnessFor)。留空或非法值回退
|
||||
// strict(最严档),保证升级不会放宽拦截;默认拦截面可能较升级前略有扩大,
|
||||
// 希望更少误伤则显式写 standard。
|
||||
DefaultModerationStrictness string `ini:"default_moderation_strictness"`
|
||||
// TextCensorSource 文本审核服务通道,取值:
|
||||
// qiniu —— 只用七牛(凭据见 [app] qiniu_access_key / qiniu_access_secret)
|
||||
// aliyun —— 只用阿里云(走内容安全 2.0 / 文本审核增强版 TextModeration;
|
||||
// 开通只需实名认证,个人账号可用。凭据见 [app] aliyun_access_key / aliyun_access_key_secret)
|
||||
// auto(默认,留空同义)—— 保持历史行为:七牛优先,未通过时用阿里云二次复核
|
||||
// 仅显式选定的通道会被调用;选定通道缺少凭据时启动即报错(见 conf.Init),
|
||||
// 避免运行时审核必然失败 —— 审核失败是 fail-closed(拒绝提交),
|
||||
// 会让发布/私信整站不可用,且现象隐蔽(只在日志里)。
|
||||
// TextCensorSource 文本审核通道:qiniu / aliyun(内容安全 2.0 TextModeration,
|
||||
// 个人实名账号即可开通)/ auto(默认,留空同义:七牛优先、阿里云复核)。
|
||||
// 仅显式选定的通道会被调用,缺凭据时启动即报错(见 conf.Init)——
|
||||
// 审核失败是 fail-closed(拒绝提交),会让发布与私信整站不可用且现象隐蔽。
|
||||
TextCensorSource string `ini:"text_censor_source"`
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
}
|
||||
@@ -142,12 +124,11 @@ var (
|
||||
}
|
||||
|
||||
Mail struct {
|
||||
Account string `ini:"account"`
|
||||
Password string `ini:"password"`
|
||||
Port int `ini:"port"`
|
||||
SMTP string `ini:"smtp"`
|
||||
// SkipTLSVerify 跳过 SMTP TLS 证书校验(仅自签名/内网 SMTP 需要,默认 false 校验证书)
|
||||
SkipTLSVerify bool `ini:"skip_tls_verify"`
|
||||
Account string `ini:"account"`
|
||||
Password string `ini:"password"`
|
||||
Port int `ini:"port"`
|
||||
SMTP string `ini:"smtp"`
|
||||
SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要
|
||||
}
|
||||
|
||||
Service struct {
|
||||
@@ -155,8 +136,6 @@ var (
|
||||
Prefix string `ini:"prefix"`
|
||||
ForwardURL string `ini:"forward_url"`
|
||||
}
|
||||
// TLSInsecureSkipVerify 跳过反向代理到后端服务的 TLS 证书校验
|
||||
// (仅内网后端使用自签名证书时需要,默认 false 校验证书)
|
||||
TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"`
|
||||
TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"` // 仅内网自签名后端需要
|
||||
}
|
||||
)
|
||||
|
||||
@@ -223,7 +223,8 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
|
||||
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
|
||||
// 否则达上限后即便删掉旧会话,功能仍会显示 quota_reached 关闭,须手动重置计数。
|
||||
if conv.OwnerID != 0 {
|
||||
if err := FeatureControls.IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
|
||||
// 用本 store 的 *gorm.DB 构造,避免依赖包级全局单例(详见 DeleteByID 注释)。
|
||||
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement chat quota for user %d", conv.OwnerID)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -42,21 +42,15 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
return nil, errors.Wrap(err, "connect to database")
|
||||
}
|
||||
|
||||
// 配置连接池
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get sql.DB from gorm")
|
||||
}
|
||||
|
||||
// 1C1G 低配环境:调低连接池,避免每个 DB 连接占用过多内存。
|
||||
// 提问箱是低并发场景,10 个打开连接绰绰有余。
|
||||
// 最大打开连接数
|
||||
// 1C1G 低配环境:调低连接池避免单连接占用过多内存。提问箱低并发,10 个打开连接绰绰有余。
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
// 最大空闲连接数
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
// 连接最大存活时间(避免长连接被数据库服务端断开)
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
// 空闲连接最大存活时间
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute) // 避免长连接被数据库服务端断开
|
||||
sqlDB.SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
// 仅在明确开启迁移清理开关时处理 users 表历史重复值,避免服务启动时静默改写账号数据。
|
||||
@@ -89,6 +83,12 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
|
||||
}
|
||||
|
||||
// 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价
|
||||
// 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。
|
||||
if err := normalizeHarassmentSetting(db); err != nil {
|
||||
logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)")
|
||||
}
|
||||
|
||||
// 启动期补充高频查询复合索引(幂等,适合既有库)。
|
||||
// 仅 PostgreSQL 需要:这些索引针对服务端真实查询模式(按归属用户+私密+时间、
|
||||
// 按 asker、按展示/投票状态+可见性+时间、按会话/发送者+时间)。
|
||||
|
||||
@@ -66,29 +66,23 @@ type featureControls struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
// FeatureControl 提问箱单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
|
||||
// FeatureControl 单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
|
||||
// Enabled 为手动总开关;OpenAt / CloseAt 为一次性定时(nil 表示不限该侧);
|
||||
// QuotaLimit / QuotaCount 为定量收集(0 表示不限制)。最终状态由 Evaluate 综合计算。
|
||||
//
|
||||
// 三类控制互不覆盖,最终可用状态由 Evaluate 综合计算:
|
||||
// - Enabled:手动总开关;
|
||||
// - OpenAt / CloseAt:定时开放与定时关闭(nil 表示不限制该侧);
|
||||
// - QuotaLimit / QuotaCount:定量收集,累计收集数达到上限后自动关闭(0 表示不限制)。
|
||||
//
|
||||
// 计数不做定时重置:一轮收集结束后由箱主在功能管理页手动「重置计数」开启下一轮,
|
||||
// 避免与定时开关叠加产生难以预期的自动恢复行为。
|
||||
// 计数不做定时重置:一轮结束后由箱主手动「重置计数」,避免与定时开关叠加产生
|
||||
// 难以预期的自动恢复行为。
|
||||
type FeatureControl struct {
|
||||
dbutil.Model
|
||||
UserID uint `gorm:"uniqueIndex:idx_feature_control_user_feature;not null" json:"-"`
|
||||
Feature string `gorm:"size:20;uniqueIndex:idx_feature_control_user_feature;not null" json:"feature"`
|
||||
// ⚠️ 不要给 Enabled 加 gorm:"default:true" 标签:
|
||||
// GORM 在 Create 时会把零值 false 替换为字段默认值(true),导致「取消勾选保存」后
|
||||
// 数据库里仍是 true、复选框自动恢复为勾选。去掉标签后 GORM 会原样写入显式设置的值。
|
||||
Enabled bool `json:"enabled"`
|
||||
// OpenAt / CloseAt:一次性定时开放 / 关闭(nil 表示不限制该侧)。
|
||||
// ⚠️ 不要给 Enabled 加 gorm:"default:true":GORM 在 Create 时会把零值 false 替换为
|
||||
// 默认值 true,导致「取消勾选保存」后库里仍是 true、复选框自动恢复勾选。
|
||||
Enabled bool `json:"enabled"`
|
||||
OpenAt *time.Time `json:"open_at"`
|
||||
CloseAt *time.Time `json:"close_at"`
|
||||
// 循环定时(每周循环):ScheduleDays 为逗号分隔的周几(0=周日…6=周六,与 time.Weekday 一致),
|
||||
// 空表示每天都开放;ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示该日全天)。
|
||||
// 两者都为空时表示未启用循环定时。
|
||||
// 循环定时(每周):ScheduleDays 为逗号分隔的周几(0=周日…6=周六),空表示每天都开放;
|
||||
// ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示全天)。两者皆空即未启用。
|
||||
ScheduleDays string `gorm:"size:31" json:"schedule_days"`
|
||||
ScheduleStart string `gorm:"size:5" json:"schedule_start"`
|
||||
ScheduleEnd string `gorm:"size:5" json:"schedule_end"`
|
||||
@@ -178,8 +172,8 @@ func (fc *FeatureControl) scheduleDayOpen(day time.Time) bool {
|
||||
return fc.ScheduleDays == "" || containsWeekday(fc.ScheduleDays, int(day.Weekday()))
|
||||
}
|
||||
|
||||
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。
|
||||
// 未设置时间段时视为全天 [0, 1440)。配置非法时返回错误(调用方按"不限制"处理)。
|
||||
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。未设置时间段时视为全天;
|
||||
// 配置非法时返回错误(调用方按「不限制」处理)。
|
||||
func (fc *FeatureControl) scheduleWindow() (startM, endM int, err error) {
|
||||
if fc.ScheduleStart == "" && fc.ScheduleEnd == "" {
|
||||
return 0, 24 * 60, nil
|
||||
@@ -342,9 +336,8 @@ func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。
|
||||
// 提问与投票默认开启(与历史行为一致);私聊默认关闭,沿用 users.allow_chat 的默认值,
|
||||
// 避免历史账号在功能上线后被动开启私聊。
|
||||
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。提问与投票默认开启
|
||||
// (与历史行为一致);私聊默认关闭,沿用 users.allow_chat 默认值,避免历史账号被动开启。
|
||||
func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
|
||||
return &FeatureControl{
|
||||
UserID: userID,
|
||||
@@ -353,8 +346,8 @@ func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
|
||||
}
|
||||
}
|
||||
|
||||
// FeatureStateOf 便捷入口:读取某用户某功能在当前的综合状态。
|
||||
// 查询失败或无存储时回退到默认配置,保证单个依赖表异常不会让整站功能不可用。
|
||||
// FeatureStateOf 读取某用户某功能当前的综合状态。查询失败或无存储时回退默认配置,
|
||||
// 保证单个依赖表异常不会让整站功能不可用。
|
||||
func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureState {
|
||||
if FeatureControls == nil {
|
||||
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
|
||||
@@ -433,7 +426,7 @@ type UpdateFeatureControlOptions struct {
|
||||
QuotaLimit int64
|
||||
}
|
||||
|
||||
// Save 写入功能配置。使用 ON CONFLICT 幂等写入,并显式列出更新列,
|
||||
// Save 写入功能配置。用 ON CONFLICT 幂等写入并显式列出更新列,
|
||||
// 避免把 quota_count(已收集数量)误重置为 0。
|
||||
func (db *featureControls) Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error {
|
||||
now := time.Now()
|
||||
@@ -544,13 +537,12 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
|
||||
|
||||
// backfillFeatureControls 为尚未初始化的历史账号补齐功能配置行。
|
||||
//
|
||||
// 背景:私聊开关此前落在 users.allow_chat,提问/投票则一直默认开启。功能上线后若没有
|
||||
// 对应的配置行,Get 会回退到默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭。
|
||||
// 因此在 AutoMigrate 之后按 users.allow_chat 回填私聊开关,其余功能按"开启"回填。
|
||||
// 私聊开关此前落在 users.allow_chat,提问/投票一直默认开启;功能上线后没有配置行时
|
||||
// Get 会回退默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭,
|
||||
// 故在 AutoMigrate 之后按 users.allow_chat 回填私聊,其余按「开启」回填。
|
||||
//
|
||||
// 幂等:每轮只挑出仍缺配置行的账号,写完后进入下一轮,直到没有遗漏。
|
||||
// 分批 + ON CONFLICT DO NOTHING,避免"边插入边查同一张表"在不同数据库上的差异行为,
|
||||
// 并保证并发启动(多实例同时启动)时不会重复插入。
|
||||
// 幂等:每轮只挑仍缺行的账号,分批 + ON CONFLICT DO NOTHING,避免不同数据库上
|
||||
// 「边插入边查同一张表」的差异行为,也保证多实例并发启动时不重复插入。
|
||||
func backfillFeatureControls(db *gorm.DB) error {
|
||||
// backfillBatchSize 每轮处理的账号数,控制单次事务体积与内存占用。
|
||||
const backfillBatchSize = 500
|
||||
@@ -616,28 +608,20 @@ func backfillFeatureControls(db *gorm.DB) error {
|
||||
|
||||
// reconcileLegacyChatEnabled 修复旧版 backfill 写入的私聊错误开关。
|
||||
//
|
||||
// 背景:早期版本 FeatureControl.Enabled 带 gorm:"default:true" 标签,backfill 为
|
||||
// allow_chat=false 的用户补私聊行时,false 被 GORM 替换为默认值 true,导致私聊被被动开启;
|
||||
// 且 UpdateChatSettings 会把 users.allow_chat 反向同步为 true,形成持久化。
|
||||
// 背景:早期 Enabled 带 gorm:"default:true",backfill 为 allow_chat=false 的用户补行时
|
||||
// false 被替换为 true,私聊被被动开启;UpdateChatSettings 还会把 allow_chat 同步为 true。
|
||||
//
|
||||
// 纠偏条件(必须同时满足,只命中"纯 backfill 默认行",绝不碰箱主配置过的行):
|
||||
// - feature = 'chat' 且 enabled = true;
|
||||
// - 对应 users.allow_chat = false(箱主从未在面板开启私聊——面板开启会同步 allow_chat=true);
|
||||
// - 该行无任何自定义配置(open_at / close_at 为空、quota_limit = 0、循环定时为空);
|
||||
// - updated_at <= created_at(从未被面板保存过,updated_at 未变化)。
|
||||
//
|
||||
// 满足条件即复位 feature_controls.enabled = false,并同步 users.allow_chat = false。
|
||||
// 幂等:每轮只处理仍满足条件的行,直到没有遗漏;已复位/已配置的行不会重复命中。
|
||||
// 纠偏条件(必须同时满足,只命中「纯 backfill 默认行」,绝不碰箱主配置过的行):
|
||||
// feature='chat' 且 enabled=true;对应 users.allow_chat=false;该行无任何自定义配置
|
||||
// (open_at/close_at 为空、quota_limit=0、循环定时为空);updated_at <= created_at。
|
||||
// 满足即复位 enabled=false 并同步 users.allow_chat=false。幂等,已配置行不会重复命中。
|
||||
func reconcileLegacyChatEnabled(db *gorm.DB) error {
|
||||
// reconcileBatchSize 每轮处理的账号数,控制单次事务体积。
|
||||
const reconcileBatchSize = 500
|
||||
|
||||
// pureDefaultFilterFC / pureDefaultFilterPlain 只匹配纯默认配置的私聊行
|
||||
// (无一次性窗口/循环定时/定量上限)。两者仅列前缀不同:
|
||||
// - FC 版带 "fc." 前缀,用于 SELECT(feature_controls fc 别名场景);
|
||||
// - 纯版不带前缀,用于 UPDATE(GORM 不会为 UPDATE 自动加别名,带前缀会报
|
||||
// missing FROM-clause entry for table "fc")。
|
||||
// 同时兼容旧行 schedule 列为 NULL 与新行空串两种情况。
|
||||
// 两者只匹配纯默认配置的私聊行(无一次性窗口/循环定时/定量上限),仅列前缀不同:
|
||||
// FC 版带 "fc." 用于 SELECT;纯版不带前缀用于 UPDATE(GORM 不会为 UPDATE 自动加别名,
|
||||
// 带前缀会报 missing FROM-clause entry for table "fc")。同时兼容旧行 NULL 与新行空串。
|
||||
pureDefaultFilterFC := "fc.open_at IS NULL AND fc.close_at IS NULL AND fc.quota_limit = 0" +
|
||||
" AND (fc.schedule_days IS NULL OR fc.schedule_days = '')" +
|
||||
" AND (fc.schedule_start IS NULL OR fc.schedule_start = '')" +
|
||||
@@ -703,11 +687,9 @@ func reconcileLegacyChatEnabled(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// normalizeChatQuota 清零私聊行上历史残留的收集上限与计数。
|
||||
//
|
||||
// 背景:私聊不提供收集上限能力(会话是双向对话,限制「新建会话数」意义不大),
|
||||
// UI 已隐藏该字段、保存时强制清零;此处在启动期做一次性兜底,把旧版本
|
||||
// 设置过的私聊上限/计数统一清零,避免达到残留上限后私聊「莫名关闭」。
|
||||
// 幂等:重复执行不产生任何变更,失败由调用方告警(不阻断启动)。
|
||||
// 私聊不提供收集上限(会话是双向对话,限制「新建会话数」意义不大),UI 已隐藏该字段、
|
||||
// 保存时强制清零;此处在启动期兜底清零,避免达到残留上限后私聊「莫名关闭」。
|
||||
// 幂等,失败由调用方告警(不阻断启动)。
|
||||
func normalizeChatQuota(db *gorm.DB) error {
|
||||
res := db.Model(&FeatureControl{}).
|
||||
Where("feature = ? AND (quota_limit > 0 OR quota_count > 0)", FeatureChat).
|
||||
|
||||
+12
-22
@@ -11,39 +11,31 @@ import (
|
||||
)
|
||||
|
||||
// MaxFollowUpCount 每条提问允许的最大追问次数(防滥用)。
|
||||
// 提问者被回答后,用 token(或登录账号)继续追问,达到上限后不可再追问。
|
||||
const MaxFollowUpCount = 3
|
||||
|
||||
// 追问相关错误(供 handler 区分处理,避免把业务错误当 500)
|
||||
// 追问相关错误,供 handler 区分处理,避免把业务错误当 500。
|
||||
var (
|
||||
// ErrFollowUpLimitReached 追问次数已达上限
|
||||
ErrFollowUpLimitReached = errors.New("follow up limit reached")
|
||||
// ErrFollowUpNotFound 追问不存在或不属于该问题
|
||||
ErrFollowUpNotFound = errors.New("follow up not found")
|
||||
// ErrFollowUpAlreadyAnswered 追问已被回答(禁止覆盖)
|
||||
ErrFollowUpLimitReached = errors.New("follow up limit reached")
|
||||
ErrFollowUpNotFound = errors.New("follow up not found")
|
||||
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
|
||||
)
|
||||
|
||||
var FollowUps FollowUpsStore
|
||||
|
||||
type FollowUpsStore interface {
|
||||
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建。
|
||||
// 返回 created=false 且 err==ErrFollowUpLimitReached 表示已达上限。
|
||||
// 通过事务内锁定问题行,串行化同一问题的并发追问,消除 count-then-create 竞态。
|
||||
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建;
|
||||
// 事务内锁定问题行串行化并发追问,消除 count-then-create 竞态。已达上限返回 (false, ErrFollowUpLimitReached)。
|
||||
CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (created bool, err error)
|
||||
GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error)
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问;无追问时返回 (nil, nil)。
|
||||
GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error)
|
||||
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回。
|
||||
// 用于账号导出等场景,将 N 次单题查询合并为 1 次 IN 查询,避免 N+1。
|
||||
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(导出场景合并 N+1 为 1 次 IN 查询)。
|
||||
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error)
|
||||
CountByQuestionID(ctx context.Context, questionID uint) (int64, error)
|
||||
// AnswerByID 回答某条追问;questionID 用于防止越权(必须属于该问题)。
|
||||
// 仅允许回答尚未回答的追问(answer 为空),已回答的返回 ErrFollowUpAlreadyAnswered,
|
||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
|
||||
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
|
||||
// 与 AnswerByID 不同,此处允许覆盖已回答的追问(供主人修改回复使用)。
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
|
||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
@@ -120,8 +112,7 @@ func (db *followUps) GetByQuestionID(ctx context.Context, questionID uint) ([]*F
|
||||
return followUps, nil
|
||||
}
|
||||
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序取首条);
|
||||
// 无追问时返回 (nil, nil)。供发布追问前校验"上一条是否已回复"。
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序);无追问时返回 (nil, nil),供发布前校验上一条是否已回复。
|
||||
func (db *followUps) GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) {
|
||||
var fu FollowUp
|
||||
err := db.WithContext(ctx).
|
||||
@@ -169,9 +160,8 @@ func (db *followUps) CountByQuestionID(ctx context.Context, questionID uint) (in
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// AnswerByID 更新某条追问的回答内容(带 question_id 条件防止越权)。
|
||||
// 原子更新条件含 answer=”:已回答的追问不会被覆盖(避免重复提交时邮件/回答被二次写入)。
|
||||
// RowsAffected==0 时再查一次以区分"不存在"与"已回答",返回对应错误。
|
||||
// AnswerByID 更新回答(带 question_id 防越权)。
|
||||
// 原子条件含 answer=”,已回答的不被覆盖;RowsAffected==0 再查一次以区分"不存在"与"已回答"。
|
||||
func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
|
||||
res := db.WithContext(ctx).
|
||||
Model(&FollowUp{}).
|
||||
|
||||
@@ -92,7 +92,7 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
|
||||
question := Question{
|
||||
FromIP: opts.FromIP,
|
||||
UserID: opts.UserID,
|
||||
Token: randstr.String(6),
|
||||
Token: randstr.String(12),
|
||||
Content: opts.Content,
|
||||
ReceiveReplyEmail: opts.ReceiveReplyEmail,
|
||||
AskerUserID: opts.AskerUserID,
|
||||
@@ -401,17 +401,19 @@ func (db *questions) DeleteByID(ctx context.Context, id uint) error {
|
||||
}
|
||||
|
||||
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
|
||||
if err := FollowUps.DeleteByQuestionID(ctx, id); err != nil {
|
||||
// 直接用本 store 的 *gorm.DB 构造子 store,避免依赖包级全局单例
|
||||
// (全局单例仅在 db.Init 装配,测试/部分初始化场景下为 nil 会 panic)。
|
||||
if err := NewFollowUpsStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
|
||||
}
|
||||
if err := UploadImages.DeleteByQuestionID(ctx, id); err != nil {
|
||||
if err := NewUploadImagesStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id)
|
||||
}
|
||||
|
||||
// 回退定量收集计数:删除提问等价于少一条已收集内容,
|
||||
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
|
||||
if question.UserID != 0 {
|
||||
if err := FeatureControls.IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
|
||||
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
)
|
||||
|
||||
func TestUploadImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 注意:各子测试都创建 question_id=1 的上传图,cleanup 会 truncate 并 RESTART IDENTITY,
|
||||
// 并行子测试会拿到相同自增 id,撞 upload_image_questions 的复合主键
|
||||
// (upload_image_id, question_id);故顺序执行,使每个子测试独占全表。
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -29,7 +30,10 @@ func TestUploadImages(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("upload_images"); err != nil {
|
||||
// 必须一并清链接表:upload_image_questions 无 FK 级联依赖 upload_images,
|
||||
// 仅 truncate upload_images 会残留 (upload_image_id, question_id) 链接行,
|
||||
// 下一个子测试因身份重启拿到相同 id 而撞复合主键。
|
||||
if err := cleanup("upload_images", "upload_image_questions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
+24
-2
@@ -86,7 +86,7 @@ type User struct {
|
||||
UID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"-"`
|
||||
Email string `json:"email" gorm:"uniqueIndex:idx_users_email_unique"`
|
||||
Email string `json:"-" gorm:"uniqueIndex:idx_users_email_unique"`
|
||||
Avatar string `json:"avatar"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
|
||||
Background string `json:"background"`
|
||||
@@ -105,8 +105,12 @@ type User struct {
|
||||
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
|
||||
Backgroundimagealpha string `json:"Backgroundimagealpha"`
|
||||
Notify NotifyType `json:"notify"`
|
||||
HarassmentSetting HarassmentSettingType `json:"harassment_setting"`
|
||||
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
|
||||
BlockWords string `json:"-"`
|
||||
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
|
||||
// 与 none 语义等价但取值不同,会导致「按 == none 判断」的隐性漏判 → 已补 schema 默认值、
|
||||
// Create 显式写入、启动期归一历史空值(见 db.Init 的 normalizeHarassmentSetting)。
|
||||
//
|
||||
// ModerationStrictness 内容审核严格度:lax / standard / strict。
|
||||
// 空值表示「跟随站点默认」([security] default_moderation_strictness),
|
||||
// 存量账号迁移后即为空值形态 —— 缺省回退 strict(最严档),
|
||||
@@ -244,6 +248,7 @@ func (db *users) Create(ctx context.Context, opts CreateUserOptions) error {
|
||||
Backgroundimagealpha: opts.Backgroundimagealpha,
|
||||
Qrcodepdpcolor: opts.Qrcodepdpcolor,
|
||||
Notify: NotifyTypeEmail,
|
||||
HarassmentSetting: HarassmentSettingNone,
|
||||
Usernamecolor: opts.Usernamecolor,
|
||||
Introcolor: opts.Introcolor,
|
||||
Acfunlink: opts.Acfunlink,
|
||||
@@ -388,6 +393,23 @@ func (db *users) UpdateHarassmentType(ctx context.Context, id uint, typ Harassme
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHarassmentSetting 把 users.harassment_setting 的历史空值(空字符串)归一为 none。
|
||||
// 空串与 none 语义等价(均表示不做限制),归一后取值唯一,避免将来按 == none 判断时漏判。
|
||||
// 幂等:无空值时不做写入;失败由调用方告警(不阻断启动)。
|
||||
func normalizeHarassmentSetting(db *gorm.DB) error {
|
||||
res := db.Model(&User{}).
|
||||
Where("harassment_setting IS NULL OR harassment_setting = ''").
|
||||
Update("harassment_setting", HarassmentSettingNone)
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(res.Error, "normalize harassment setting")
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
logrus.WithField("count", res.RowsAffected).
|
||||
Info("防骚扰设置已归一:历史空值已置为 none")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBlockWords 仅更新全局屏蔽词表(逗号分隔,保存前已去重/去空白/限额)。
|
||||
// 只写 block_words 列,不动 harassment_setting。
|
||||
func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) error {
|
||||
|
||||
+59
-15
@@ -11,11 +11,26 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
func TestUsers(t *testing.T) {
|
||||
t.Parallel()
|
||||
// requireUserEqual 比较期望与实际的 User。
|
||||
// bcrypt 哈希每次加盐不同,无法逐字节比较:先按明文校验密码,再把哈希字段对齐后比较其余字段。
|
||||
// Create 会按 conf.Limits.ChatMaxMessages 显式写入该字段,故期望值同步取该配置默认。
|
||||
func requireUserEqual(t *testing.T, want, got *User) {
|
||||
t.Helper()
|
||||
require.True(t, got.Authenticate(want.Password))
|
||||
want.Password = got.Password
|
||||
want.ChatMaxMessages = conf.Limits.ChatMaxMessages
|
||||
want.HarassmentSetting = HarassmentSettingNone // Create 显式写入 none
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestUsers(t *testing.T) {
|
||||
// 注意:本测试各子测试创建用户均使用固定邮箱/域名([email protected] / e99),
|
||||
// 且每个子测试结束会 truncate 整张 users 表。若开启 t.Parallel(),并发子测试会在
|
||||
// 同一张表上同时插入相同唯一键导致"邮箱已注册"误报;故顺序执行,使每个子测试独占全表。
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -122,8 +137,7 @@ func testUsersGetByID(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm.",
|
||||
Notify: NotifyTypeEmail,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
@@ -165,8 +179,7 @@ func testUsersGetByEmail(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm.",
|
||||
Notify: NotifyTypeEmail,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
@@ -208,8 +221,7 @@ func testUsersGetByDomain(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm.",
|
||||
Notify: NotifyTypeEmail,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
@@ -260,8 +272,7 @@ func testUsersUpdate(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm!!",
|
||||
Notify: NotifyTypeNone,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -307,6 +318,10 @@ func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) {
|
||||
require.Nil(t, err)
|
||||
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
// 先显式设定防骚扰类型,验证 UpdateBlockWords 不会误改该列(类型列默认为空串,
|
||||
// 与 HarassmentSettingNone 等价但值不同,故显式设定后再断言更可靠)。
|
||||
require.Nil(t, db.UpdateHarassmentType(ctx, 1, HarassmentSettingTypeRegisterOnly))
|
||||
|
||||
err := db.UpdateBlockWords(ctx, 1, "广告,抽奖")
|
||||
require.Nil(t, err)
|
||||
|
||||
@@ -314,7 +329,7 @@ func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "广告,抽奖", got.BlockWords)
|
||||
// 只应更新屏蔽词,类型列不受影响
|
||||
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting)
|
||||
require.Equal(t, HarassmentSettingTypeRegisterOnly, got.HarassmentSetting)
|
||||
})
|
||||
|
||||
t.Run("empty clears words", func(t *testing.T) {
|
||||
@@ -359,8 +374,7 @@ func testUsersAuthenticate(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm.",
|
||||
Notify: NotifyTypeEmail,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
}
|
||||
|
||||
func testUsersChangePassword(t *testing.T, ctx context.Context, db *users) {
|
||||
@@ -408,9 +422,9 @@ func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) {
|
||||
err := db.Create(ctx, CreateUserOptions{
|
||||
Name: "E99p1ant",
|
||||
Password: "super_secret",
|
||||
Email: "i@github.red",
|
||||
Email: "deactivate@github.red",
|
||||
Avatar: "avater.png",
|
||||
Domain: "e99",
|
||||
Domain: "deactivate",
|
||||
Background: "background.png",
|
||||
Intro: "Be cool, but also be warm.",
|
||||
})
|
||||
@@ -426,3 +440,33 @@ func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) {
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestHarassmentSettingNormalize 覆盖防骚扰设置取值归一:
|
||||
// 新建用户即写入规范值 none;历史空值经启动期归一(normalizeHarassmentSetting)后也为 none。
|
||||
func TestHarassmentSettingNormalize(t *testing.T) {
|
||||
db, _ := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
usersStore := NewUsersStore(db)
|
||||
|
||||
require.Nil(t, usersStore.Create(ctx, CreateUserOptions{
|
||||
Name: "E99p1ant",
|
||||
Password: "super_secret",
|
||||
Email: "[email protected]",
|
||||
Avatar: "avater.png",
|
||||
Domain: "normalize",
|
||||
Background: "background.png",
|
||||
Intro: "Be cool, but also be warm.",
|
||||
}))
|
||||
|
||||
got, err := usersStore.GetByID(ctx, 1)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "新建用户应为规范值 none")
|
||||
|
||||
// 模拟历史行:直接把列置为空串,再跑一次启动期归一。
|
||||
require.Nil(t, db.Exec("UPDATE users SET harassment_setting = '' WHERE id = 1").Error)
|
||||
require.Nil(t, normalizeHarassmentSetting(db))
|
||||
|
||||
got, err = usersStore.GetByID(ctx, 1)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "历史空值应被归一为 none")
|
||||
}
|
||||
|
||||
+15
-29
@@ -31,25 +31,18 @@ type Error struct {
|
||||
}
|
||||
|
||||
const (
|
||||
// multipartMemoryLimit 是 multipart 解析时驻留内存的上限,超出部分由
|
||||
// mime/multipart 落盘到临时文件。
|
||||
multipartMemoryLimit = 10 << 20 // 10 MiB
|
||||
multipartMemoryLimit = 10 << 20 // 10 MiB:multipart 内存驻留上限,超出由 mime/multipart 落盘
|
||||
|
||||
// MaxRequestBodySize 是单次请求体的**总**大小上限(含落盘部分)。
|
||||
// MaxRequestBodySize 请求体总大小上限(含落盘部分),略高于内存上限以留出开销。
|
||||
//
|
||||
// ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限地
|
||||
// 写入临时文件。匿名可达的提问接口即接受 multipart,若不加总量限制,攻击者可持续
|
||||
// POST 超大 body 撑满磁盘(http.Server 的 ReadTimeout 在高带宽下不足以形成约束)。
|
||||
// 略高于内存上限,为 multipart 边界与其他表单字段留出开销。
|
||||
// 导出供 internal/route 的全局 requestBodyLimitMiddleware 复用同一上限:
|
||||
// 仅靠本 Bind 内的 MaxBytesReader 不够——Contexter 组级 CSRF 校验先于 Bind 执行,
|
||||
// 无 X-CSRF-Token 头时它会整包惰性解析 body,超大 multipart 会在 Bind 限制生效前
|
||||
// 落盘;全局中间件把上限提前到一切解析之前(见 internal/route/bodylimit.go)。
|
||||
// ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限写入
|
||||
// 临时文件;匿名可达的提问接口即接受 multipart,无总量限制则可持续撑满磁盘。
|
||||
// 导出供 internal/route/bodylimit.go 的全局中间件复用——仅靠本 Bind 内限制不够,
|
||||
// 组级 CSRF 校验先于 Bind 执行且会整包惰性解析 body,须把上限提前到一切解析之前。
|
||||
MaxRequestBodySize = 12 << 20 // 12 MiB
|
||||
)
|
||||
|
||||
func Bind(model interface{}) flamego.Handler {
|
||||
// Ensure not pointer.
|
||||
if reflect.TypeOf(model).Kind() == reflect.Ptr {
|
||||
panic("form: pointer can not be accepted as binding model")
|
||||
}
|
||||
@@ -60,11 +53,9 @@ func Bind(model interface{}) flamego.Handler {
|
||||
|
||||
r := c.Request()
|
||||
|
||||
// 在任何解析之前封顶请求体总大小。
|
||||
// 注意:c.Request() 返回 flamego 的包装类型,其 Body 是方法而非字段,
|
||||
// 必须取内嵌的 *http.Request 才能替换 Body。
|
||||
// MaxBytesReader 在超限时让后续 Read 返回错误,从而使 ParseForm /
|
||||
// ParseMultipartForm 失败并走下方的反序列化错误分支,避免超大 body 落盘或读入内存。
|
||||
// 在任何解析之前封顶请求体总大小。c.Request() 是 flamego 包装类型(Body 是方法
|
||||
// 而非字段),须取内嵌 *http.Request 才能替换。超限后 Read 报错,使 ParseForm /
|
||||
// ParseMultipartForm 失败并走反序列化错误分支,避免超大 body 落盘或读入内存。
|
||||
httpReq := r.Request
|
||||
httpReq.Body = http.MaxBytesReader(c.ResponseWriter(), httpReq.Body, MaxRequestBodySize)
|
||||
|
||||
@@ -72,11 +63,9 @@ func Bind(model interface{}) flamego.Handler {
|
||||
switch contentType {
|
||||
case "application/x-www-form-urlencoded", "":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
// ⚠️ 必须同时 SetError(写响应则在此提前中止):flamego 的 handler 链
|
||||
// 只要响应未写入就会自动执行下一 handler,仅 Map(Error) 不中断链,
|
||||
// 零值结构体会照常进入业务动作(如把用户资料/屏蔽词静默清空、解除页面密码)。
|
||||
// SetError 置位 HasError,业务 action 开头若检查 ctx.HasError() 即会早退;
|
||||
// 不检查的 action 也已在 route/user 各 handler 补了 abortOnFormError 守卫。
|
||||
// ⚠️ 必须 SetError:flamego 的 handler 链只要响应未写入就会自动执行下一 handler,
|
||||
// 仅 Map(Error) 不中断链,零值结构体会照常进入业务动作(静默清空屏蔽词、解除
|
||||
// 页面密码等)。SetError 置位 HasError,action 开头的 abortOnFormError 守卫会早退。
|
||||
c.SetError(err)
|
||||
c.Map(Error{Category: ErrorCategoryDeserialization, Error: err})
|
||||
return
|
||||
@@ -89,7 +78,6 @@ func Bind(model interface{}) flamego.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// Bind the form data to the given struct.
|
||||
typ := reflect.TypeOf(obj.Interface())
|
||||
val := reflect.ValueOf(obj.Interface())
|
||||
if typ.Kind() == reflect.Ptr {
|
||||
@@ -131,11 +119,9 @@ func Bind(model interface{}) flamego.Handler {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 非 multipart 请求(application/x-www-form-urlencoded):仅绑定 string 字段。
|
||||
// 其余类型(如 []*multipart.FileHeader 图片字段)保持零值,
|
||||
// 否则 reflect.Set 会因类型不匹配 panic(string → []*multipart.FileHeader)。
|
||||
// 此前提问表单因带 enctype="multipart/form-data" 走上面分支而从未触发;
|
||||
// 独立私聊表单等纯文本提交会命中此路径,必须先做类型判断。
|
||||
// 非 multipart 请求仅绑定 string 字段,其余类型(如 []*multipart.FileHeader
|
||||
// 图片字段)保持零值,否则 reflect.Set 会因类型不匹配 panic。
|
||||
// 带 enctype 的提问表单走上面分支从未触发,纯文本提交(如私聊)会命中此路径。
|
||||
if val.Field(i).Kind() == reflect.String {
|
||||
val.Field(i).Set(reflect.ValueOf(r.Form.Get(fieldName)))
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
|
||||
// Language 语言代码常量。
|
||||
const (
|
||||
LangZhCN = "zh-CN" // 简体中文
|
||||
LangZhTW = "zh-TW" // 繁體中文
|
||||
LangEn = "en" // English
|
||||
LangJa = "ja" // 日本語
|
||||
LangZhCN = "zh-CN"
|
||||
LangZhTW = "zh-TW"
|
||||
LangEn = "en"
|
||||
LangJa = "ja"
|
||||
|
||||
Default = LangZhCN
|
||||
)
|
||||
@@ -26,7 +26,7 @@ type LangInfo struct {
|
||||
Name string // 语言自身名称,如 "简体中文" / "繁體中文" / "English" / "日本語"
|
||||
}
|
||||
|
||||
// supported 支持的语语言列表,顺序即切换按钮展示顺序。
|
||||
// supported 支持的语言列表,顺序即切换按钮展示顺序。
|
||||
var supported = []LangInfo{
|
||||
{Code: LangZhCN, Name: "简体中文"},
|
||||
{Code: LangZhTW, Name: "繁體中文"},
|
||||
|
||||
@@ -54,10 +54,11 @@ func SendPasswordRecoveryMail(email, code string) error {
|
||||
}
|
||||
|
||||
// SendNewFollowUpMail 通知提问箱主人:有人对某条已回答的提问进行了追问。
|
||||
// 链接无需 token,主人登录后即可查看。
|
||||
func SendNewFollowUpMail(email, domain string, questionID uint, questionContent string, followUpContent string) error {
|
||||
// 链接带 token,与 SendNewQuestionMail 对齐——主人于未登录设备(手机邮件客户端)点开私密提问时
|
||||
// 也能凭 token 查看,否则会被 Questioner 中间件重定向到首页。
|
||||
func SendNewFollowUpMail(email, domain string, questionID uint, questionContent string, followUpContent string, token string) error {
|
||||
params := map[string]string{
|
||||
"link": fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, domain, questionID),
|
||||
"link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
|
||||
"question": questionContent,
|
||||
"followup": followUpContent,
|
||||
}
|
||||
|
||||
@@ -12,18 +12,10 @@ import (
|
||||
)
|
||||
|
||||
// TestCSRFTokenBoundToUserSession 是 P1-1 的回归测试:CSRF token 必须绑定到登录用户的
|
||||
// 会话键(CSRFUserSessionKey = "uid"),而非 flamego/csrf 默认的 "userID"。
|
||||
//
|
||||
// 背景:flamego/csrf 的校验是无状态 HMAC 重算(token = HMAC(secret, id:action:ts),不与
|
||||
// session 内存储的 token 比对)。一旦 SessionKey 与登录时写入的会话键(login.go 中的
|
||||
// Session.Set("uid", ...))不一致,token 内绑定的 id 恒为 "0",任意匿名访客从公开 /login
|
||||
// 页面取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站请求伪造可自由发起。
|
||||
//
|
||||
// 本测试用内存会话(无需数据库)重放「会话 B 携带会话 A 的 token 提交」这一跨会话复用攻击:
|
||||
// - 会话 A(已登录 uid=42)携带自己的 token 提交 → 必须通过(200);
|
||||
// - 会话 B(匿名)携带会话 A 的 token 跨会话提交 → 必须被拒绝(400)。
|
||||
//
|
||||
// 若 CSRFUserSessionKey 被误改回 "userID",第二项断言会失败,CI 即报警。
|
||||
// 会话键(CSRFUserSessionKey="uid"),否则 flamego/csrf 的无状态 HMAC 重算会让匿名访客
|
||||
// 取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站伪造可自由发起。
|
||||
// 测试用内存会话重放跨会话复用攻击:会话 A(uid=42)自带的 token 提交须 200,会话 B 跨会话
|
||||
// 复用会话 A 的 token 须 400。若 CSRFUserSessionKey 被误改回 "userID",第二项断言即失败报警。
|
||||
func TestCSRFTokenBoundToUserSession(t *testing.T) {
|
||||
const secret = "test-secret-for-csrf-regression"
|
||||
|
||||
@@ -49,14 +41,12 @@ func TestCSRFTokenBoundToUserSession(t *testing.T) {
|
||||
SetHeader: true, // 便于测试从响应头取出 token
|
||||
}))
|
||||
|
||||
// 受保护路由:仅当 CSRF 校验通过才返回 200;失败由 csrf.Error 写 400。
|
||||
// 受保护路由:校验通过才 200;失败由 csrf.Error 写 400。
|
||||
f.Post("/protected", func(ctx flamego.Context, x csrf.CSRF) {
|
||||
x.Validate(ctx)
|
||||
// 校验失败时 x.Validate 已写入 400;此处 Write 在已写 400 时仅追加 body,
|
||||
// 状态保持 400;校验通过时此处隐式返回 200。
|
||||
// 校验失败时 x.Validate 已写 400,此处 Write 仅追加 body,状态保持 400。
|
||||
ctx.ResponseWriter().Write([]byte("ok"))
|
||||
})
|
||||
// 生成 token 的 GET 路由(token 在 GET 时生成,与真实应用一致)。
|
||||
f.Get("/token", func(ctx flamego.Context) {
|
||||
ctx.ResponseWriter().Write([]byte("ok"))
|
||||
})
|
||||
@@ -106,13 +96,11 @@ func TestCSRFTokenBoundToUserSession(t *testing.T) {
|
||||
clientA := newClient()
|
||||
tokenA := getToken(clientA, true)
|
||||
|
||||
// 同一会话携带自己的 token 提交 —— 必须通过(200)。
|
||||
if code := postProtected(clientA, tokenA); code != http.StatusOK {
|
||||
t.Fatalf("会话 A 携带自身 token 提交,期望 200,实际 %d(修复后不应被拒绝)", code)
|
||||
}
|
||||
|
||||
// 会话 B:匿名访客,跨会话复用会话 A 的 token 提交 —— 必须被拒绝(400)。
|
||||
// 这是 P1-1 的核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配。
|
||||
// 核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配,须被拒绝(400)。
|
||||
clientB := newClient()
|
||||
if code := postProtected(clientB, tokenA); code != http.StatusBadRequest {
|
||||
t.Fatalf("会话 B 跨会话复用会话 A 的 token,期望 400,实际 %d(CSRF 绑定失效!)", code)
|
||||
|
||||
+16
-24
@@ -13,15 +13,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GzipHandler 返回一个压缩中间件,用于包装 http.Handler。
|
||||
// 它对可压缩的响应(HTML / CSS / JS / JSON 等文本类型)做 gzip 压缩,
|
||||
// 自动跳过图片、视频、字体等已经压缩过的格式。
|
||||
//
|
||||
// 由于 flamego 的 Context 无法替换底层 ResponseWriter,这里采用在
|
||||
// http.Server 最外层包装 Handler 的方式,保证对所有响应(含静态文件)生效。
|
||||
// GzipHandler 对可压缩的文本响应(HTML / CSS / JS / JSON 等)做 gzip 压缩,
|
||||
// 自动跳过图片、视频、字体等已压缩格式。
|
||||
// flamego 的 Context 无法替换底层 ResponseWriter,故在 http.Server 最外层包装
|
||||
// Handler,保证对所有响应(含静态文件)生效。
|
||||
func GzipHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 客户端不支持 gzip 时直接透传
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -32,8 +29,7 @@ func GzipHandler(next http.Handler) http.Handler {
|
||||
gz: gzip.NewWriter(w),
|
||||
}
|
||||
defer func() {
|
||||
// 只有真正写入过 body 才关闭 gzip 流,避免 302/204 等
|
||||
// 无 body 响应被追加空的 gzip 尾块
|
||||
// 只有真正写入过 body 才关闭,避免 302/204 等无 body 响应被追加空的 gzip 尾块
|
||||
if gw.compress && gw.wrote {
|
||||
_ = gw.gz.Close()
|
||||
}
|
||||
@@ -42,8 +38,8 @@ func GzipHandler(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// gzipResponseWriter 包装 http.ResponseWriter,在 WriteHeader 时根据
|
||||
// Content-Type 决定是否启用 gzip,保持接口兼容(Flusher / Hijacker / Pusher)。
|
||||
// gzipResponseWriter 在 WriteHeader 时按 Content-Type 决定是否启用 gzip,
|
||||
// 并保持 Flusher / Hijacker / Pusher 接口兼容。
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
gz *gzip.Writer
|
||||
@@ -52,8 +48,8 @@ type gzipResponseWriter struct {
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
// WriteHeader 记录状态码,并根据 Content-Type 决定是否压缩。
|
||||
// 由于 Content-Encoding 改变后 Content-Length 不再准确,需要删除该头。
|
||||
// WriteHeader 记录状态码并决定是否压缩;Content-Encoding 改变后 Content-Length
|
||||
// 不再准确,需删除该头。
|
||||
func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
@@ -75,15 +71,14 @@ func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Write 写入响应体。若尚未 WriteHeader(handler 依赖 net/http 自动嗅探
|
||||
// Content-Type 的情况),先用首个字节块探测类型再决策。
|
||||
// Write 写入响应体。若尚未 WriteHeader,先用首个字节块探测类型再决策。
|
||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = http.DetectContentType(b)
|
||||
// 将嗅探到的类型写回响应头,使 WriteHeader 的压缩判定能正确生效
|
||||
// (否则空 Content-Type 会被判定为"不压缩",导致本应压缩的纯文本响应未压缩)。
|
||||
// 嗅探结果须写回头,否则空 Content-Type 会被判为「不压缩」,
|
||||
// 导致本应压缩的纯文本响应漏压。
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -95,7 +90,7 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush 转发底层 Flusher,并在压缩模式下先刷新 gzip 缓冲。
|
||||
// Flush 转发底层 Flusher,压缩模式下先刷新 gzip 缓冲。
|
||||
func (w *gzipResponseWriter) Flush() {
|
||||
if w.compress && w.gz != nil {
|
||||
_ = w.gz.Flush()
|
||||
@@ -121,24 +116,21 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
|
||||
return http.ErrNotSupported
|
||||
}
|
||||
|
||||
// shouldCompressContentType 判断响应 Content-Type 是否值得压缩。
|
||||
// 空类型、二进制图片/视频/音频/字体等已压缩格式直接跳过。
|
||||
// shouldCompressContentType 判断 Content-Type 是否值得压缩:空类型与已压缩的
|
||||
// 二进制格式(图片 / 视频 / 音频 / 字体 / 归档等)直接跳过。
|
||||
func shouldCompressContentType(ct string) bool {
|
||||
ct = strings.ToLower(strings.TrimSpace(ct))
|
||||
if ct == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// SVG 是文本格式,值得压缩;其余图片格式已是压缩格式
|
||||
if strings.HasPrefix(ct, "image/") {
|
||||
return strings.Contains(ct, "svg")
|
||||
return strings.Contains(ct, "svg") // SVG 是文本格式
|
||||
}
|
||||
// 视频 / 音频 / 字体已是压缩格式
|
||||
if strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") ||
|
||||
strings.HasPrefix(ct, "font/") {
|
||||
return false
|
||||
}
|
||||
// 已知压缩或二进制格式
|
||||
switch ct {
|
||||
case "application/zip", "application/gzip", "application/x-7z-compressed",
|
||||
"application/x-rar-compressed", "application/pdf",
|
||||
|
||||
+33
-49
@@ -49,13 +49,11 @@ import (
|
||||
// 用于防止把路由参数直接拼进 embed FS 路径时发生路径穿越(如 "../robots.txt")。
|
||||
var indexNowKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}\.txt$`)
|
||||
|
||||
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
|
||||
// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
|
||||
// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
|
||||
// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
|
||||
//
|
||||
// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
|
||||
// CI 失败,避免修复悄悄倒退。
|
||||
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的键一致
|
||||
// (见 route/auth/login.go 的 ctx.Session.Set("uid", ...))。flamego/csrf 默认用
|
||||
// "userID",不对齐则 token id 恒为 "0",匿名访客从公开页取到的 token 对所有已登录
|
||||
// 用户均有效,CSRF 退化为全站公开常量。
|
||||
// 本常量被 csrf_binding_test.go 引用作为单一事实来源,误改会让回归测试失败。
|
||||
const CSRFUserSessionKey = "uid"
|
||||
|
||||
// securityHeaders adds security-related HTTP headers to every response.
|
||||
@@ -80,9 +78,8 @@ func New() *flamego.Flame {
|
||||
}
|
||||
|
||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||
// 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
|
||||
// 404 是否参与统计由 ban_track_notfound 控制(默认 false:正常业务一律放行,
|
||||
// 仅敏感扫描路径计数封禁,见 conf.Security.BanTrackNotFound)。
|
||||
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
|
||||
// 404 是否计入由 ban_track_notfound 控制(默认 false,仅敏感扫描路径计数)。
|
||||
ab := autoban.New(autoban.Options{
|
||||
Enabled: conf.Security.EnableAutoBan,
|
||||
TrackNotFound: conf.Security.BanTrackNotFound,
|
||||
@@ -98,10 +95,6 @@ func New() *flamego.Flame {
|
||||
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
|
||||
},
|
||||
})
|
||||
// 需要输出详细日志时配置如下
|
||||
// if conf.App.Production {
|
||||
// flamego.SetEnv(flamego.EnvTypeProd)
|
||||
// }
|
||||
|
||||
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
|
||||
if err != nil {
|
||||
@@ -134,11 +127,9 @@ func New() *flamego.Flame {
|
||||
sessioner := session.Sessioner(session.Options{
|
||||
Initer: initer,
|
||||
Config: sessionStorage,
|
||||
// 显式声明 cookie 安全属性,不依赖库默认值:
|
||||
// - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
|
||||
// - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
|
||||
// - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
|
||||
// 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
|
||||
// 显式声明 cookie 安全属性:HTTPOnly 防 XSS 窃取会话;SameSite=Lax 阻断跨站表单
|
||||
// POST;Secure 仅在 https 对外服务时下发,避免 http 本地开发下 cookie 不生效
|
||||
// (缺失 Secure 时,HSTS 尚未落地的首次访问可被 MITM 诱导明文请求窃取会话)。
|
||||
Cookie: session.CookieOptions{
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
@@ -227,11 +218,11 @@ func New() *flamego.Flame {
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
// IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex / Seznam
|
||||
// 等支持 IndexNow 的搜索引擎校验站点所有权(提交 URL 时的 keyLocation 指向此处)。
|
||||
// ⚠️ flamego 的 Static 中间件 Prefix 为 "/static",不会接管本路径,必须在此显式注册,
|
||||
// 否则请求会落到 f.NotFound 被 302 重定向到首页,搜索引擎校验必然失败。
|
||||
// 文件名用 indexNowKeyPattern 严格白名单校验,避免路径穿越读取 embed FS 内其它文件。
|
||||
// IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex 等
|
||||
// 搜索引擎校验站点所有权。
|
||||
// ⚠️ flamego Static 的 Prefix 是 "/static",不会接管本路径,必须显式注册,
|
||||
// 否则会落到 f.NotFound 被 302 到首页,校验必然失败。
|
||||
// 文件名经 indexNowKeyPattern 白名单校验,避免路径穿越读 embed FS 内其它文件。
|
||||
f.Get("/index-now/{key}", func(c context.Context) {
|
||||
key := c.Param("key")
|
||||
if !indexNowKeyPattern.MatchString(key) {
|
||||
@@ -282,7 +273,7 @@ func New() *flamego.Flame {
|
||||
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
|
||||
}, question.Questioner)
|
||||
|
||||
// 新增:私聊路由(需要登录,使用 Token 标识对话)
|
||||
// 私聊路由(需登录,Token 标识对话)
|
||||
f.Group("/chat/{token}", func() {
|
||||
f.Get("", chat.ConversationPage)
|
||||
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
|
||||
@@ -300,16 +291,14 @@ func New() *flamego.Flame {
|
||||
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
|
||||
})
|
||||
|
||||
// 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
|
||||
// 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
|
||||
// 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
|
||||
// 公开页语言切换:语言偏好写入 session 后跳回原页面。此前写独立 lang cookie
|
||||
// 会被 session 中间件的 Set-Cookie 整体覆盖而失效,故托管在 session 中。
|
||||
f.Get("/i18n/{lang}", func(c context.Context) {
|
||||
lang := i18n.Resolve(c.Param("lang"))
|
||||
// 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
|
||||
c.Session.Set("lang", lang)
|
||||
to := c.Query("to")
|
||||
// 仅允许站内相对路径,防开放重定向:
|
||||
// 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
|
||||
// 仅允许站内相对路径,防开放重定向:拒绝空、非 "/" 开头、协议相对路径 "//",
|
||||
// 以及含反斜杠 / 控制字符的变体。
|
||||
if to == "" || !strings.HasPrefix(to, "/") ||
|
||||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
|
||||
to = "/"
|
||||
@@ -360,13 +349,10 @@ func New() *flamego.Flame {
|
||||
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
|
||||
})
|
||||
})
|
||||
// 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
|
||||
// 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
|
||||
// ?tab= 参数回跳到来源面板。
|
||||
// 防骚扰与全局屏蔽词拆为两个独立入口,避免同表单一次写两列时相互清空。
|
||||
// 保存后经 ?tab= 参数回跳到来源面板。
|
||||
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
|
||||
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
|
||||
// 内容审核严格度:与屏蔽词一样在提问/私聊/投票三个面板共用一份,
|
||||
// 保存后经 ?tab= 参数回跳到来源面板。
|
||||
f.Post("/moderation-strictness/update", form.Bind(form.UpdateModerationStrictness{}), user.UpdateModerationStrictness)
|
||||
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
|
||||
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
|
||||
@@ -418,12 +404,11 @@ func New() *flamego.Flame {
|
||||
csrf.Csrfer(csrf.Options{
|
||||
Secret: conf.Server.XSRFKey,
|
||||
Header: "X-CSRF-Token",
|
||||
// ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
|
||||
// flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
|
||||
// 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
|
||||
// (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
|
||||
// /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
|
||||
// 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
|
||||
// ⚠️ 必须与 login.go 的 ctx.Session.Set("uid", ...) 一致。flamego/csrf 默认
|
||||
// SessionKey 为 "userID",不显式指定则 s.Get("userID") 恒为 nil、token 内 id
|
||||
// 恒为 "0";校验是无状态 HMAC 重算(不与 session 内 token 比对),匿名访客从
|
||||
// 公开页取到的 token 会对所有已登录用户有效,CSRF 退化为全站公开常量。
|
||||
// 回归测试见 route/csrf_binding_test.go。
|
||||
SessionKey: CSRFUserSessionKey,
|
||||
}),
|
||||
template.Templater(template.Options{
|
||||
@@ -433,13 +418,12 @@ func New() *flamego.Flame {
|
||||
context.Contexter(),
|
||||
)
|
||||
f.NotFound(func(ctx flamego.Context) {
|
||||
// 真正的 404 探测在此记录(仅此处,不把业务 302→/ 误判为扫描)。
|
||||
// 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
|
||||
// 因此这里在 Redirect 之前记录,确保状态为 404。
|
||||
// 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
|
||||
// 否则 404 计数会记在另一个键上,导致封禁永不生效。
|
||||
// ⚠️ 该 404 计数受 ban_track_notfound 配置控制,默认关闭(正常业务一律放行,
|
||||
// 仅敏感扫描路径计数封禁);Record 内部对未开启的 KindNotFound 直接忽略。
|
||||
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。flamego 的 NotFound 会先
|
||||
// 写 404 状态再 Redirect,故须在 Redirect 前记录。
|
||||
// 键必须与 autobanMiddleware 的 IsBanned/Record 一致(同样经 LimitKey 聚合),
|
||||
// 否则计数落在另一个键上,封禁永不生效。
|
||||
// ⚠️ 计数受 ban_track_notfound 控制,默认关闭;Record 内部会忽略未开启的
|
||||
// KindNotFound。
|
||||
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
|
||||
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
|
||||
}
|
||||
|
||||
@@ -4,20 +4,14 @@
|
||||
|
||||
package censor
|
||||
|
||||
// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」。
|
||||
// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」(TextModeration)。
|
||||
//
|
||||
// 迁移记录(2026-09):本通道原走「内容安全 1.0」(/green/text/scan + alibaba-cloud-sdk-go),
|
||||
// 但 1.0 的开通前提是**企业实名认证**,个人认证账号无法开通,接口会永久返回
|
||||
// `code=596 You have not opened Yundun Content Moderation Service`。
|
||||
// 现改为 2.0(TextModeration + alibabacloud-go/green-20220302/v3):2.0 只要求实名认证
|
||||
// (个人可开通),且标签体系对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。
|
||||
// 1.0(/green/text/scan)需企业实名认证,个人账号永久返回 code=596,故迁 2.0(仅需实名认证);
|
||||
// 2.0 标签对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。官方参考:
|
||||
// - API:https://help.aliyun.com/practice_detail/434034
|
||||
// - 标签:https://help.aliyun.com/practice_detail/465315
|
||||
//
|
||||
// 官方文档:
|
||||
// - 文本审核增强版通用服务 API:https://help.aliyun.com/practice_detail/434034
|
||||
// - 标签处理建议参考: https://help.aliyun.com/practice_detail/465315
|
||||
//
|
||||
// ⚠️ 不要把 SDK 里 `TextModerationResponseBodyData.Labels` 的内联注释当作权威标签表:
|
||||
// 那份列表是旧的且不完整(没有 political 相关标签),以官方《标签处理建议参考》为准。
|
||||
// ⚠️ SDK 里 TextModerationResponseBodyData.Labels 的内联注释是旧表且不完整(缺涉政标签),以官方为准。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -35,38 +29,28 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// aliyunRegionID 内容安全 2.0 的接入地域,SDK 据此解析出
|
||||
// green-cip.cn-shanghai.aliyuncs.com(见绿网 SDK 的 EndpointMap)。
|
||||
// aliyunRegionID 接入地域,SDK 据此解析出 green-cip.cn-shanghai.aliyuncs.com。
|
||||
aliyunRegionID = "cn-shanghai"
|
||||
|
||||
// aliyunTextService 文本审核增强版的服务场景。
|
||||
//
|
||||
// 选 comment_detection(公聊评论内容检测):官方定位「评论/弹幕/公聊/转发」,
|
||||
// 明确覆盖涉黄/涉政/涉暴等底线违规与广告导流,是本站(公开问答为主、私聊为辅)
|
||||
// 最通用的单一场景。本通道被提问/回答、投票、私聊三条链路共用,接口层不带场景信息;
|
||||
// 若要按场景细分(例如私聊改用 chat_detection),需给审核入口补 scene 参数。
|
||||
// aliyunTextService 服务场景。选 comment_detection:官方定位「评论/弹幕/公聊/转发」,
|
||||
// 覆盖涉黄/涉政/涉暴底线与广告导流。三条链路共用且接口层不带场景,
|
||||
// 若要按场景细分(如私聊改 chat_detection)需给审核入口补 scene 参数。
|
||||
aliyunTextService = "comment_detection"
|
||||
|
||||
// aliyunContentRuneLimit 2.0 通用服务单次 content 的硬上限。
|
||||
// 官方文档:content 限定在 600 字符以内。这里按 550 切分以留出安全余量。
|
||||
// aliyunContentRuneLimit 单次 content 硬上限(官方 600),按 550 切分留安全余量。
|
||||
aliyunContentRuneLimit = 550
|
||||
|
||||
// aliyunChunkOverlapRunes 相邻分片的重叠字符数。
|
||||
// 切分点可能正好落在一个风险词中间,两段各自都看不出问题;重叠可消除该盲区。
|
||||
// 代价是少量重复计费(文本审核标准 7.5 元/万次,可忽略)。
|
||||
// aliyunChunkOverlapRunes 相邻分片重叠字符数:防止风险词正好落在切分点而被漏判。
|
||||
aliyunChunkOverlapRunes = 50
|
||||
|
||||
aliyunReadTimeoutMs = 10000
|
||||
aliyunConnectTimeoutMs = 5000
|
||||
|
||||
// aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),
|
||||
// 避免异常响应体过大时把日志刷屏。
|
||||
// aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),避免日志刷屏。
|
||||
aliyunMaxRawLogBytes = 512
|
||||
|
||||
// aliyunTextModerationAPI 归一化响应的格式标记。
|
||||
//
|
||||
// 解析器据此拒绝「非本格式」的载荷(例如迁移前内容安全 1.0 的响应),
|
||||
// 让 censor.go 的缓存回放能安全地退回一次实时调用,
|
||||
// aliyunTextModerationAPI 归一化响应的格式标记:解析器据此拒绝非本格式载荷
|
||||
// (如迁移前 1.0 的响应),使 censor.go 的缓存回放能安全退回实时调用,
|
||||
// 而不是把无法识别的历史缓存误判成「通过」。
|
||||
aliyunTextModerationAPI = "TextModeration"
|
||||
)
|
||||
@@ -82,8 +66,7 @@ func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor {
|
||||
}
|
||||
}
|
||||
|
||||
// 包级复用的 client:审核请求高频且密钥在启动后不变,
|
||||
// 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。
|
||||
// 包级复用 client:密钥启动后不变,用 sync.Once 懒加载一次,避免每次审核都重建连接。
|
||||
var (
|
||||
aliyunGreenOnce sync.Once
|
||||
aliyunGreenClient *green20220302.Client
|
||||
@@ -93,11 +76,8 @@ var (
|
||||
warnAliyunAccountIssueDoOnce sync.Once
|
||||
)
|
||||
|
||||
// newAliyunClient 构造内容安全 2.0 客户端。
|
||||
//
|
||||
// 不设置 Endpoint 而设置 RegionId:SDK 内置 EndpointMap 会把 cn-shanghai
|
||||
// 解析成 green-cip.cn-shanghai.aliyuncs.com。超时单位是毫秒
|
||||
// (见 darabonba-openapi 的 client.ReadTimeout → 请求参数 readTimeout)。
|
||||
// newAliyunClient 构造 2.0 客户端。设 RegionId 而非 Endpoint:SDK 的 EndpointMap 会解析出
|
||||
// green-cip.cn-shanghai.aliyuncs.com。超时单位为毫秒。
|
||||
func newAliyunClient(accessKey, accessKeySecret string) (*green20220302.Client, error) {
|
||||
regionID := aliyunRegionID
|
||||
readTimeout := aliyunReadTimeoutMs
|
||||
@@ -123,10 +103,8 @@ func (c *AliyunTextCensor) getClient() (*green20220302.Client, error) {
|
||||
return aliyunGreenClient, aliyunGreenErr
|
||||
}
|
||||
|
||||
// newAliyunTextModerationRequest 构造 TextModeration 请求。
|
||||
//
|
||||
// 单独抽出便于测试断言请求形态:Service 必须是选定场景,
|
||||
// ServiceParameters 是 JSON 字符串 {"content": ...}。
|
||||
// newAliyunTextModerationRequest 构造 TextModeration 请求(Service 为场景,
|
||||
// ServiceParameters 为 JSON 串 {"content": ...})。单独抽出便于测试断言请求形态。
|
||||
func newAliyunTextModerationRequest(text string) (*green20220302.TextModerationRequest, error) {
|
||||
serviceParameters, err := json.Marshal(map[string]string{"content": text})
|
||||
if err != nil {
|
||||
@@ -137,13 +115,10 @@ func newAliyunTextModerationRequest(text string) (*green20220302.TextModerationR
|
||||
SetServiceParameters(string(serviceParameters)), nil
|
||||
}
|
||||
|
||||
// splitAliyunText 把待审文本切成不超过 2.0 单次上限的分片(按 rune 切,避免切坏多字节字符)。
|
||||
//
|
||||
// 为什么必须切分:2.0 通用服务单次 content 硬上限 600 字符,而本站内容上限为
|
||||
// 提问 1000 / 回答·追问·私聊 2000 / 投票描述 5000 字,长文本必然超限。
|
||||
// 把超长文本直接发给接口会被拒,进而在 fail-closed 下变成「长内容一律发不出去」。
|
||||
//
|
||||
// 分片带 aliyunChunkOverlapRunes 的重叠,消除「风险词正好跨切分点」的漏判盲区。
|
||||
// splitAliyunText 按 rune 切成不超过单次上限的分片。
|
||||
// 必须切分:2.0 单次硬上限 600 字符,而本站上限为 提问 1000 / 回答·追问·私聊 2000 /
|
||||
// 投票描述 5000 字;超长直发会被拒,在 fail-closed 下等于「长内容一律发不出去」。
|
||||
// 分片带重叠以消除「风险词正好跨切分点」的漏判盲区。
|
||||
func splitAliyunText(text string) []string {
|
||||
runes := []rune(text)
|
||||
if len(runes) <= aliyunContentRuneLimit {
|
||||
@@ -165,19 +140,16 @@ func splitAliyunText(text string) []string {
|
||||
return chunks
|
||||
}
|
||||
|
||||
// aliyunTextModerationReason 是 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。
|
||||
//
|
||||
// 只声明用到的字段:riskLevel 风险级别、riskTips 子标签、riskWords 命中词。
|
||||
// aliyunTextModerationReason 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。
|
||||
type aliyunTextModerationReason struct {
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
RiskTips string `json:"riskTips"`
|
||||
RiskWords string `json:"riskWords"`
|
||||
}
|
||||
|
||||
// parseAliyunReason 解析 Data.Reason。
|
||||
//
|
||||
// 字段缺失或非法 JSON 时返回零值 —— 忽略解析错误是刻意的:Reason 是辅助字段,
|
||||
// 不应因为它的格式变化导致整条审核失败。风险级别拿不到时的保守处理见 aliyunRiskLevelScore。
|
||||
// parseAliyunReason 解析 Data.Reason。字段缺失或非法 JSON 时返回零值 ——
|
||||
// 忽略解析错误是刻意的:Reason 是辅助字段,不应因其格式变化导致整条审核失败
|
||||
// (风险级别拿不到时的保守处理见 aliyunRiskLevelScore)。
|
||||
func parseAliyunReason(raw string) aliyunTextModerationReason {
|
||||
var reason aliyunTextModerationReason
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
@@ -254,11 +226,8 @@ func aliyunHighestRiskLevel(levels []string) string {
|
||||
return best
|
||||
}
|
||||
|
||||
// aliyunTextModerationRaw 归一化后的可落库响应。
|
||||
//
|
||||
// 它是「在线调用」与「缓存回放」两条路径的唯一输入格式:
|
||||
// Censor 把(可能多分片的)2.0 响应合并成它并序列化,随后交给 AliyunTextCensorParser;
|
||||
// censor.go 命中落库缓存时,也把这段 JSON 交给同一个 parser。
|
||||
// aliyunTextModerationRaw 归一化后的可落库响应:在线调用与缓存回放两条路径的唯一输入格式,
|
||||
// 最终都交给 AliyunTextCensorParser 解析。
|
||||
type aliyunTextModerationRaw struct {
|
||||
// API 格式标记,取值恒为 aliyunTextModerationAPI,供解析器甄别旧格式载荷。
|
||||
API string `json:"api"`
|
||||
@@ -266,7 +235,7 @@ type aliyunTextModerationRaw struct {
|
||||
Code int32 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestIDs []string `json:"requestIds,omitempty"`
|
||||
// Labels 已过滤掉 nonLabel / normal,且已去重。
|
||||
// Labels 已过滤 nonLabel / normal 并去重。
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
RiskLevel string `json:"riskLevel,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
@@ -275,8 +244,7 @@ type aliyunTextModerationRaw struct {
|
||||
// aliyunEmptyCleanRaw 空内容对应的归一化响应(无标签 → 通过)。见 Censor 的空内容分支。
|
||||
var aliyunEmptyCleanRaw = []byte(`{"api":"TextModeration","statusCode":200,"code":200,"message":"OK"}`)
|
||||
|
||||
// aliyunChunkResult 单个分片审核结果里我们关心的最小信息集。
|
||||
// 抽出这一层是为了让「多分片合并」这段逻辑可以脱离 SDK 与网络单独测试。
|
||||
// aliyunChunkResult 单个分片结果的最小信息集,使「多分片合并」可脱离 SDK 单独测试。
|
||||
type aliyunChunkResult struct {
|
||||
StatusCode int32
|
||||
Code int32
|
||||
@@ -288,9 +256,8 @@ type aliyunChunkResult struct {
|
||||
}
|
||||
|
||||
// extractAliyunChunkResult 从 SDK 响应里提取分片结果。
|
||||
//
|
||||
// code 非 200/0 表示请求被阿里云拒绝(服务未开通、限流、参数错误等),
|
||||
// 必须报错而不是当成「未命中」—— 否则 fail-closed 会被悄悄变成 fail-open。
|
||||
// code 非 200/0 表示请求被拒(未开通、限流、参数错误等),必须报错而非当成「未命中」
|
||||
// —— 否则 fail-closed 会被悄悄变成 fail-open。
|
||||
func extractAliyunChunkResult(resp *green20220302.TextModerationResponse) (aliyunChunkResult, error) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return aliyunChunkResult{}, errors.New("text moderation returned an empty response body")
|
||||
@@ -338,12 +305,8 @@ func extractAliyunChunkResult(resp *green20220302.TextModerationResponse) (aliyu
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mergeAliyunChunkResults 把多个分片的结果合并成一份归一化响应。
|
||||
//
|
||||
// 合并口径:
|
||||
// - 标签取并集(任一分片命中即视为整条内容命中);
|
||||
// - 风险级别取最高(high > medium > low > none),未知级别按最高处理;
|
||||
// - Hint 取首个非空,与「首个命中维度决定提示文案」的既有语义保持一致。
|
||||
// mergeAliyunChunkResults 把多个分片结果合并成一份归一化响应:标签取并集、
|
||||
// 风险级别取最高(未知级别按最高处理)、Hint 取首个非空。
|
||||
func mergeAliyunChunkResults(results []aliyunChunkResult) aliyunTextModerationRaw {
|
||||
merged := aliyunTextModerationRaw{
|
||||
API: aliyunTextModerationAPI,
|
||||
@@ -440,17 +403,14 @@ func aliyunRawDigest(raw []byte) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse。
|
||||
//
|
||||
// 在线调用与缓存回放共用本函数(见 aliyunTextModerationRaw 的说明)。
|
||||
// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse(在线调用与缓存回放共用)。
|
||||
func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
|
||||
var parsed aliyunTextModerationRaw
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, errors.Wrapf(err, "unmarshal response (raw=%s)", aliyunRawDigest(raw))
|
||||
}
|
||||
|
||||
// 格式标记不符:典型是迁移前内容安全 1.0 落库的缓存(形如 {"code":200,"data":[...]})。
|
||||
// 这种载荷无法按 2.0 语义解释,必须报错,交由 censor.go 退回一次实时调用;
|
||||
// 格式标记不符(典型是迁移前 1.0 落库的缓存)必须报错,交由 censor.go 退回实时调用;
|
||||
// 若当成「无标签 → 通过」,历史缓存就会变成绕过审核的后门。
|
||||
if parsed.API != aliyunTextModerationAPI {
|
||||
return nil, errors.Errorf("unsupported aliyun response format (api=%q raw=%s)",
|
||||
@@ -460,14 +420,12 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
|
||||
// 2.0 不返回逐标签置信度,只有整条内容的 riskLevel,所有标签共用折算后的分值。
|
||||
score := aliyunRiskLevelScore(parsed.RiskLevel)
|
||||
|
||||
// Pass 取「审核服务未给出任何风险标签」。注意它不是最终裁决 ——
|
||||
// 是否拦截由 policy.go 按箱主严格度决定。
|
||||
// Pass 只表示「未给出风险标签」,不是最终裁决(是否拦截由 policy.go 按严格度决定)。
|
||||
pass := len(parsed.Labels) == 0
|
||||
|
||||
suggestion := aliyunRiskLevelSuggestion(parsed.RiskLevel)
|
||||
// 与七牛保持一致:审核服务判为「非通过」却没给出建议时补成 block。
|
||||
// 典型场景是命中了尚未映射的新标签(既没有明细维度、也没有 riskLevel),
|
||||
// 不补的话策略层会把它当成未命中放行,与改造前的 fail-closed 行为不一致。
|
||||
// 与七牛一致:判为「非通过」却没给建议时补成 block(如命中尚未映射的新标签),
|
||||
// 不补会被策略层当成未命中放行,与改造前的 fail-closed 行为不一致。
|
||||
if suggestion == "" && !pass {
|
||||
suggestion = SuggestionBlock
|
||||
}
|
||||
@@ -480,8 +438,7 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
|
||||
for _, label := range parsed.Labels {
|
||||
mapped := formatAliyunForbiddenType(label)
|
||||
if mapped == "" {
|
||||
// 未映射的标签不进入明细:它无法参与按维度裁决。
|
||||
// 原始标签仍完整保留在 RawResponse 中供审计。
|
||||
// 未映射的标签无法参与按维度裁决,不进明细(原始标签仍保留在 RawResponse 供审计)。
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -506,25 +463,10 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// formatAliyunForbiddenType 把内容安全 2.0 的标签映射为本项目的 ForbiddenType。
|
||||
//
|
||||
// 标签取值以官方《文本审核增强版标签处理建议参考》为准;括号内为官方释义与风险级别:
|
||||
//
|
||||
// political_content / regional 涉政 (高)
|
||||
// violence 暴恐 (高)
|
||||
// sexual_content / sexuality 色情 (高)
|
||||
// contraband 违禁 (高)
|
||||
// ad / PullinTraffic / ad_compliance 广告引流/广告法合规(中高)
|
||||
// profanity 辱骂 (中高)
|
||||
// religion 宗教 (中高)
|
||||
// negative_content / cyberbullying 不良内容/网络暴力 (中)
|
||||
// nonsense 无意义 (中)
|
||||
// spam 垃圾信息
|
||||
// C_customized 自定义词库命中
|
||||
//
|
||||
// 映射结果决定 policy.go 的裁决:政治/暴恐/色情/自定义属底线维度,任何档位都拦。
|
||||
// 注意 2.0 没有「灌水(flood)」维度(同类内容归入 spam),因此 ForbiddenTypeFlood
|
||||
// 不会由本通道产生,七牛仍可能返回它。
|
||||
// formatAliyunForbiddenType 把 2.0 标签映射为 ForbiddenType(取值以官方
|
||||
// 《文本审核增强版标签处理建议参考》为准;SDK 内联的标签表是旧版且不完整,勿采信)。
|
||||
// 政治 / 暴恐 / 色情 / 自定义属底线维度,任何档位都拦。
|
||||
// 2.0 没有「灌水(flood)」维度(同类归入 spam),故 ForbiddenTypeFlood 不由本通道产生。
|
||||
func formatAliyunForbiddenType(typ string) ForbiddenType {
|
||||
switch strings.ToLower(strings.TrimSpace(typ)) {
|
||||
case "political_content", "regional":
|
||||
@@ -553,10 +495,9 @@ func formatAliyunForbiddenType(typ string) ForbiddenType {
|
||||
}
|
||||
}
|
||||
|
||||
// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本两条路径:
|
||||
// - 596 / "not opened":服务未开通
|
||||
// - Forbidden:RAM 未授权
|
||||
// - InvalidAccessKeyId / SignatureDoesNotMatch:密钥错误
|
||||
// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本:
|
||||
// 596 / "not opened" 服务未开通;Forbidden / no permission RAM 未授权;
|
||||
// InvalidAccessKeyId / SignatureDoesNotMatch 密钥错误。
|
||||
var aliyunAccountIssueSignals = []string{
|
||||
"596",
|
||||
"not opened",
|
||||
@@ -592,12 +533,9 @@ func aliyunIsAccountIssue(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// warnAliyunAccountIssueOnce 在首次出现「账号侧未就绪」类失败时告警一次。
|
||||
//
|
||||
// 这类问题(服务未开通 / RAM 未授权 / 密钥无效)不会自愈,会把 fail-closed 的审核链路
|
||||
// 永久卡死:外部表现是「全站内容都发不出去」,或(auto 模式下)内容静默落到下一个通道,
|
||||
// 但日志里原本只是一条普通 ERRO,极易被当成偶发网络抖动而忽略。
|
||||
// 因此第一次遇到就打出可操作的指引,并用 sync.Once 约束为只打一次,避免按请求刷屏。
|
||||
// warnAliyunAccountIssueOnce 首次出现「账号侧未就绪」失败时告警一次(sync.Once 避免刷屏)。
|
||||
// 这类问题(未开通 / RAM 未授权 / 密钥无效)不会自愈,会永久卡死 fail-closed 的审核链路,
|
||||
// 表现为「全站内容发不出去」,而日志里原本只是一条普通 ERRO、极易被当成偶发网络抖动。
|
||||
func warnAliyunAccountIssueOnce(ctx context.Context, err error) {
|
||||
if !aliyunIsAccountIssue(err) {
|
||||
return
|
||||
|
||||
@@ -24,13 +24,10 @@ var (
|
||||
warnNoCensorCredentialsOnce sync.Once
|
||||
)
|
||||
|
||||
// censorChain 返回按配置选定的审核通道列表,顺序即优先级:
|
||||
// 靠前的通道判定「通过」即返回;未通过则继续交给后面的通道复核。
|
||||
//
|
||||
// 取值来源 [security] text_censor_source(见 conf.CensorSource*):
|
||||
// - qiniu → 只用七牛(凭据齐备性已由 conf.Init 启动校验)
|
||||
// - aliyun → 只用阿里云(同上)
|
||||
// - 其它(含留空 / auto)→ 七牛优先、阿里云兜底,与引入该配置前完全一致
|
||||
// censorChain 返回按 [security] text_censor_source 选定的通道列表,顺序即优先级:
|
||||
// 靠前通道判定通过即返回,未通过则交给后面的通道复核。
|
||||
// qiniu / aliyun 只用对应通道(凭据齐备性已由 conf.Init 启动校验);
|
||||
// 其它取值(含留空 / auto)为七牛优先、阿里云兜底,与引入该配置前一致。
|
||||
func censorChain() []TextCensor {
|
||||
qiniu := NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret)
|
||||
aliyun := NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret)
|
||||
@@ -45,11 +42,9 @@ func censorChain() []TextCensor {
|
||||
}
|
||||
}
|
||||
|
||||
// warnIfNoCensorCredentials 在「配置里一条可用凭据都没有」时告警一次。
|
||||
//
|
||||
// 这种情况下审核调用必然失败,而调用方是 fail-closed(拒绝提交),表现为
|
||||
// 全站发不出内容/私信——现象隐蔽、只在日志可见,因此第一次触发就留下明确线索。
|
||||
// 显式选定通道但缺凭据的情况已由 conf.Init 直接挡住,这里兜的是 auto 的漏网情况。
|
||||
// warnIfNoCensorCredentials 在一条可用凭据都没有时告警一次:此时审核必然失败,
|
||||
// 而调用方是 fail-closed(拒绝提交),表现为全站发不出内容/私信且只在日志可见。
|
||||
// 显式选定通道但缺凭据已由 conf.Init 挡住,这里兜的是 auto 的漏网情况。
|
||||
func warnIfNoCensorCredentials(ctx context.Context) {
|
||||
qiniuReady := conf.App.QiniuAccessKey != "" && conf.App.QiniuAccessSecret != ""
|
||||
aliyunReady := conf.App.AliyunAccessKey != "" && conf.App.AliyunAccessKeySecret != ""
|
||||
@@ -62,35 +57,35 @@ func warnIfNoCensorCredentials(ctx context.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Text checks the text for sensitive content.
|
||||
// It will save the censor log to the database and invoke the callback function.
|
||||
// Text 检查文本是否含敏感内容,并把审核结果落库(31 天内可回放复用)。
|
||||
func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
if !conf.Security.EnableTextCensor {
|
||||
return &TextCensorResponse{Pass: true}, nil
|
||||
}
|
||||
|
||||
// 缓存键与送审内容统一用归一化文本(已剔除可信域名链接):否则缓存按原文命中、
|
||||
// 实际送审的却是归一化结果 —— 审计对不上,且归一化后相同的原文会各占一条缓存、重复调 API。
|
||||
normalized := RemoveTrustedURL(text)
|
||||
|
||||
var responses []*TextCensorResponse
|
||||
|
||||
warnIfNoCensorCredentials(ctx)
|
||||
for _, censor := range censorChain() {
|
||||
sourceName := censor.String()
|
||||
|
||||
// Try to get the censor from the database log.
|
||||
censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, text, time.Now().Add(-censorCacheNoMoreThan))
|
||||
censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, normalized, time.Now().Add(-censorCacheNoMoreThan))
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrCensorLogsNotFound) {
|
||||
logrus.WithContext(ctx).WithError(err).Error("Failed to get censor log")
|
||||
}
|
||||
} else {
|
||||
raw := censorLog.RawResponse
|
||||
// We got the previous censor log cache, seems like we saved money for the API call.
|
||||
switch censorLog.SourceName {
|
||||
case "qiniu":
|
||||
// 七牛缓存命中:只有「通过」才可直接复用(省一次 API 调用)。
|
||||
// 未通过时不在此下最终结论,而是交给链上的下一个通道复核(auto 模式下即阿里云,
|
||||
// 这是引入通道配置前就有的行为)。
|
||||
// text_censor_source = qiniu 时链上只有七牛:continue 后循环结束,
|
||||
// 仍会由函数末尾返回这份七牛结果,判定不会丢失。
|
||||
// 只有「通过」才直接复用(省一次 API 调用);未通过则交给链上下一个通道复核
|
||||
// (auto 模式下即阿里云,引入通道配置前就是如此)。
|
||||
// text_censor_source=qiniu 时链上只有七牛,continue 后循环结束,
|
||||
// 仍由函数末尾返回这份结果,判定不会丢失。
|
||||
response, err := QiniuTextCensorParser(raw)
|
||||
if err == nil {
|
||||
if response.Pass {
|
||||
@@ -102,14 +97,19 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
}
|
||||
|
||||
case "aliyun":
|
||||
// 阿里云缓存命中:能解析就直接复用(省一次 API 调用)。
|
||||
// 解析失败必须退回一次实时调用,而不是把错误往上抛:
|
||||
// 最典型的失败源是「迁移到内容安全 2.0 之前落库的 1.0 缓存」,
|
||||
// 它无法按 2.0 语义解释;若在此返回错误,fail-closed 会让这些
|
||||
// 历史文本在缓存过期(31 天)前一直发不出去。
|
||||
// 能解析就复用;解析失败必须退回实时调用而不是把错误往上抛:
|
||||
// 最典型的失败源是迁到内容安全 2.0 前落库的 1.0 缓存,无法按 2.0 语义解释,
|
||||
// 若在此报错,fail-closed 会让这些历史文本在缓存过期前一直发不出去。
|
||||
// 与七牛一致:仅「通过」才直接返回,未通过则交给链上下一个通道复核
|
||||
// (阿里云现为链尾,continue 后循环结束,仍由函数末尾返回同一份结果)。
|
||||
response, err := AliyunTextCensorParser(raw)
|
||||
if err == nil {
|
||||
return response, nil
|
||||
if response.Pass {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
responses = append(responses, response)
|
||||
continue
|
||||
}
|
||||
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).
|
||||
Warn("Failed to replay cached aliyun censor result; falling back to a live call")
|
||||
@@ -119,19 +119,16 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// response, err := censor.Censor(ctx, text)
|
||||
censorText := RemoveTrustedURL(text)
|
||||
response, err := censor.Censor(ctx, censorText)
|
||||
response, err := censor.Censor(ctx, normalized)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).Error("Failed to censor text")
|
||||
continue
|
||||
}
|
||||
responses = append(responses, response)
|
||||
|
||||
// Save the censor log to the database.
|
||||
if err := db.CensorLogs.Create(ctx, db.CreateCensorLogOptions{
|
||||
SourceName: sourceName,
|
||||
Input: text,
|
||||
Input: normalized,
|
||||
Pass: response.Pass,
|
||||
RawResponse: response.RawResponse,
|
||||
}); err != nil {
|
||||
|
||||
@@ -5,40 +5,21 @@
|
||||
package censor
|
||||
|
||||
// Strictness 内容审核严格度档位。
|
||||
//
|
||||
// 背景:审核服务(本项目为七牛云文本审核)对「辱骂」等维度的召回偏激进,
|
||||
// 正常提问/对话经常被判为不通过而被整条拒绝。但我们既不能让审核服务判得更准,
|
||||
// 也不能让它判得更松(七牛文本审核不支持白名单,自定义词库需提交工单),
|
||||
// 唯一能控制的是「审核命中之后到底拦不拦」。
|
||||
//
|
||||
// 因此把「是否拦截」从审核服务自身的 Pass 判定中剥离出来,改为由本档位策略裁决,
|
||||
// 让箱主在「少误伤」与「少漏放」之间自行取舍。
|
||||
// 审核服务调不松、不可配白名单,可控的只有「命中后拦不拦」,故把拦截从 Pass 判定剥离、改由本档位裁决。
|
||||
type Strictness string
|
||||
|
||||
const (
|
||||
// StrictnessLax 宽松:仅拦截底线维度,其余一律放行。误伤最小,漏放最多。
|
||||
// StrictnessLax 宽松:仅拦底线维度,误伤最小、漏放最多。
|
||||
StrictnessLax Strictness = "lax"
|
||||
// StrictnessStandard 标准:底线维度 + 高置信度的辱骂/违禁。
|
||||
// StrictnessStandard 标准:底线 + 高置信度的辱骂/违禁。
|
||||
StrictnessStandard Strictness = "standard"
|
||||
// StrictnessStrict 严格:底线维度 + 全部非正常维度(不限置信度)+ 采信审核服务的 block 建议。
|
||||
//
|
||||
// 这是三个档位中最严的一档,且是唯一保留「采信审核服务整体 block 建议」的档位。
|
||||
// 改造前的拦截条件是 `!Pass`(即 涉政/暴恐/色情 命中,或审核服务建议为 block),
|
||||
// 该条件可被本档完全覆盖,但本档会额外拦截全部带维度的非正常内容(如 review 的辱骂)。
|
||||
// 换言之:严格档 ⊇ 改造前的行为,是「至少与改造前一样严」,并非完全等价。
|
||||
// StrictnessStrict 严格:底线 + 全部非正常维度(不限置信度)+ 采信服务 block 建议。
|
||||
// 严格档 ⊇ 改造前行为(改造前为 !Pass),「至少与改造前一样严」,并非完全等价。
|
||||
StrictnessStrict Strictness = "strict"
|
||||
)
|
||||
|
||||
// DefaultStrictness 缺省档位。
|
||||
//
|
||||
// 取 strict 的取舍:本能力只用于「让箱主把审核调松」,从不用于「在用户未选择时把审核调松」。
|
||||
// 存量账号从未设置过该值,缺省落到最严档,保证部署本功能这一动作本身不会让任何账号
|
||||
// 的拦截面变小(合规上更保守);想减少误伤的箱主再显式切到 标准 / 宽松。
|
||||
//
|
||||
// ⚠️ 注意方向:因为严格档比改造前更严(见 StrictnessStrict 注释),缺省为 strict 意味着
|
||||
// 部署后默认拦截面会略有扩大 —— 这是刻意的取舍(已于 2026-09-10 确认维持 strict):
|
||||
// 合规上宁紧勿松,且「调松」应当是箱主的显式动作,部署本身不应悄悄放宽任何账号。
|
||||
// 若哪天希望默认更少误伤,把下面这行改成 StrictnessStandard 即可(一行常量)。
|
||||
// DefaultStrictness 缺省取 strict:部署不应悄悄放宽任何账号,「调松」必须是箱主显式动作;
|
||||
// 想默认更少误伤可改 Standard。
|
||||
const DefaultStrictness = StrictnessStrict
|
||||
|
||||
// Suggestion 审核服务自身给出的管控建议取值。
|
||||
@@ -48,14 +29,9 @@ const (
|
||||
SuggestionPass = "pass"
|
||||
)
|
||||
|
||||
// baselineBlockLabels 底线拦截维度:涉政 / 暴恐 / 色情 / 自定义词库。
|
||||
//
|
||||
// 这是合规底线:任何档位下都强制生效、不受置信度门槛约束、不可关闭,
|
||||
// 也不能通过任何用户配置绕过 —— 见 Decide 中它始终排在最先、无条件返回拦截。
|
||||
//
|
||||
// ForbiddenTypeCustomized 归入底线的原因:它是运营方在审核服务控制台(阿里云)
|
||||
// 显式配置的词库命中,属「用户明写的规则」,与箱主自定义屏蔽词同一性质,
|
||||
// 不应被档位调低后放行。运营方若要放开,正确的做法是调整控制台词库,而不是调档位。
|
||||
// baselineBlockLabels 底线拦截维度:涉政/暴恐/色情/自定义词库。
|
||||
// 合规底线:任何档位强制生效、不受置信度约束、不可关闭、不可绕过(见 Decide 最先判定)。
|
||||
// Customized 归入底线:它是控制台显式配置的词库,要放开应改控制台词库而非调档位。
|
||||
var baselineBlockLabels = map[ForbiddenType]struct{}{
|
||||
ForbiddenTypePolitics: {},
|
||||
ForbiddenTypeTerrorism: {},
|
||||
@@ -63,35 +39,23 @@ var baselineBlockLabels = map[ForbiddenType]struct{}{
|
||||
ForbiddenTypeCustomized: {},
|
||||
}
|
||||
|
||||
// abuseScoreFloor 标准档位下「辱骂」维度生效所需的最低置信度。
|
||||
//
|
||||
// 辱骂是审核服务误伤最集中的维度(普通吐槽、语气较冲的表达常被判为辱骂),
|
||||
// 因此标准档要求高置信度才拦截;严格档则为任意置信度。
|
||||
//
|
||||
// 注:置信度的来源因服务商而异。七牛直接给 0~1 的 score;阿里云内容安全 2.0 只给
|
||||
// 整条内容的 riskLevel,折算为 1 / 0.6 / 0.3(high / medium / low),因此本阈值
|
||||
// 在阿里云下的实际语义是「标准档只拦 riskLevel=high 的辱骂」。
|
||||
// abuseScoreFloor 标准档下「辱骂」生效所需最低置信度。
|
||||
// 辱骂误伤最集中(吐槽常被判辱骂),故标准档要求高置信度;严格档不限。阿里云 2.0 只给 riskLevel,下语义为「只拦 high」。
|
||||
const abuseScoreFloor = 0.9
|
||||
|
||||
// levelPolicy 单一档位的拦截规则。
|
||||
type levelPolicy struct {
|
||||
// blockLabels 该档位额外拦截的维度 -> 生效所需最低置信度(0 表示不限置信度)。
|
||||
// 注意:底线维度不在此表中,由 Decide 无条件前置判定。
|
||||
// blockLabels 该档位额外拦截的维度 -> 生效最低置信度(0 表示不限)。底线维度不在此表,由 Decide 前置判定。
|
||||
blockLabels map[ForbiddenType]float64
|
||||
// blockOnSuggestion 是否采信审核服务自身的 block 建议。
|
||||
//
|
||||
// 该建议不携带具体维度(可能是任何原因),无法做置信度收敛,误伤面最大,
|
||||
// 也是此前正常对话被整条拒绝的主要来源,因此仅严格档启用。
|
||||
// blockOnSuggestion 是否采信服务自身的 block 建议:不携带维度、无法收敛置信度,误伤面最大,仅严格档启用。
|
||||
blockOnSuggestion bool
|
||||
}
|
||||
|
||||
var strictnessPolicies = map[Strictness]levelPolicy{
|
||||
// 宽松:只留底线,其余全部放行。
|
||||
StrictnessLax: {
|
||||
blockLabels: map[ForbiddenType]float64{},
|
||||
blockOnSuggestion: false,
|
||||
},
|
||||
// 标准:底线 + 高置信度辱骂 + 违禁。
|
||||
StrictnessStandard: {
|
||||
blockLabels: map[ForbiddenType]float64{
|
||||
ForbiddenTypeAbuse: abuseScoreFloor,
|
||||
@@ -99,7 +63,6 @@ var strictnessPolicies = map[Strictness]levelPolicy{
|
||||
},
|
||||
blockOnSuggestion: false,
|
||||
},
|
||||
// 严格:底线 + 全部非正常维度(不限置信度)+ 采信审核服务 block 建议。
|
||||
StrictnessStrict: {
|
||||
blockLabels: map[ForbiddenType]float64{
|
||||
ForbiddenTypeSpam: 0,
|
||||
@@ -127,8 +90,8 @@ const (
|
||||
// Blocked 是否应当拒绝提交。
|
||||
func (d Decision) Blocked() bool { return d == DecisionBlock }
|
||||
|
||||
// IsValidStrictness 判断取值是否为合法档位,供后台保存前校验。
|
||||
// 非法取值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
|
||||
// IsValidStrictness 校验档位取值合法性,供后台保存前校验;
|
||||
// 非法值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
|
||||
func IsValidStrictness(s string) bool {
|
||||
switch Strictness(s) {
|
||||
case StrictnessLax, StrictnessStandard, StrictnessStrict:
|
||||
@@ -138,8 +101,8 @@ func IsValidStrictness(s string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseStrictness 归一化严格度取值,未知/空值回退到 DefaultStrictness(保守方向,即最严)。
|
||||
// 方向刻意选保守:宁可沿用严格判定,也不要因为识别失败把账号静默放到最松档。
|
||||
// ParseStrictness 归一化严格度,未知/空值回退 DefaultStrictness(最严方向);
|
||||
// 宁可沿用严格判定,也不要因识别失败静默放到最松档。
|
||||
func ParseStrictness(s string) Strictness {
|
||||
if !IsValidStrictness(s) {
|
||||
return DefaultStrictness
|
||||
@@ -147,15 +110,9 @@ func ParseStrictness(s string) Strictness {
|
||||
return Strictness(s)
|
||||
}
|
||||
|
||||
// Decide 按档位裁决一次内容审核结果,返回裁决结果与触发拦截的维度(未命中具体维度时为 nil)。
|
||||
//
|
||||
// 判定顺序固定为「底线维度 → 档位维度 → 审核服务建议」,且不可调换:
|
||||
// 1. 底线维度排在最前且无条件返回拦截,保证任何档位都无法把它关掉;
|
||||
// 2. 档位维度带各自的置信度门槛,用于收敛误伤最集中的维度;
|
||||
// 3. 审核服务自身的 block 建议排在最后,且仅严格档启用。
|
||||
//
|
||||
// Details 为空(例如审核服务返回了 suggestion=block 但没给出任何非 normal 维度)时,
|
||||
// 只有开启 blockOnSuggestion 的档位会拦截。
|
||||
// Decide 按档位裁决一次审核结果,返回裁决与触发拦截的维度(无具体维度时 nil)。
|
||||
// 判定顺序固定不可调换:底线(无条件)→ 档位维度(带置信度门槛)→ 服务 block 建议(仅严格档)。
|
||||
// Details 为空时仅开启 blockOnSuggestion 的档位会拦截。
|
||||
func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
|
||||
if r == nil {
|
||||
return DecisionAllow, nil
|
||||
@@ -163,7 +120,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
|
||||
|
||||
policy := strictnessPolicies[ParseStrictness(strictness)]
|
||||
|
||||
// 1. 底线:合规底线,任何档位强制生效、不可关闭。
|
||||
// 1. 底线:任何档位强制生效、不可关闭。
|
||||
for i := range r.Details {
|
||||
detail := &r.Details[i]
|
||||
if _, ok := baselineBlockLabels[detail.Label]; ok {
|
||||
@@ -171,7 +128,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 档位维度:命中所配维度且达到该维度的置信度门槛。
|
||||
// 2. 档位维度:命中维度且达到置信度门槛。
|
||||
for i := range r.Details {
|
||||
detail := &r.Details[i]
|
||||
scoreFloor, ok := policy.blockLabels[detail.Label]
|
||||
@@ -184,7 +141,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
|
||||
return DecisionBlock, detail
|
||||
}
|
||||
|
||||
// 3. 审核服务自身的 block 建议:仅严格档采信。
|
||||
// 3. 服务 block 建议:仅严格档采信。
|
||||
if policy.blockOnSuggestion && r.ProviderSuggestion == SuggestionBlock {
|
||||
return DecisionBlock, nil
|
||||
}
|
||||
@@ -192,12 +149,8 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
|
||||
return DecisionAllow, nil
|
||||
}
|
||||
|
||||
// BlockedLabels 返回指定档位最终会拦截的全部维度(含底线维度)。
|
||||
//
|
||||
// 用途:策略层的自描述 API —— 目前由 policy_test.go 用来守住「底线维度在每个档位都必须出现
|
||||
// 且不重复」这条不变量。设置页展示的拦截范围是 i18n 静态文案
|
||||
// (templates/user/moderation-strictness.html),尚未消费本函数;若将来要让 UI 直接由策略层
|
||||
// 生成文案,这里是唯一事实来源。
|
||||
// BlockedLabels 返回指定档位最终拦截的全部维度(含底线)。
|
||||
// 不变量由 policy_test.go 守住(底线维度在每个档位都出现且不重复);设置页文案仍是 i18n 静态文本,未消费本函数。
|
||||
func BlockedLabels(strictness string) []ForbiddenType {
|
||||
policy := strictnessPolicies[ParseStrictness(strictness)]
|
||||
|
||||
|
||||
@@ -24,9 +24,8 @@ const (
|
||||
ForbiddenTypeContraband ForbiddenType = "contraband"
|
||||
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
|
||||
ForbiddenTypeHarmful ForbiddenType = "harmful"
|
||||
// ForbiddenTypeCustomized 命中审核服务侧的自定义词库(阿里云控制台配置,
|
||||
// 七牛不支持服务端词库因此不会返回该维度)。属运营方显式配置的规则,
|
||||
// 在 policy.go 中被归入底线集合,任何档位都不放行。
|
||||
// ForbiddenTypeCustomized 命中服务侧自定义词库(阿里云控制台配置,七牛无服务端词库)。
|
||||
// 属运营方显式规则,在 policy.go 中归入底线集合,任何档位都不放行。
|
||||
ForbiddenTypeCustomized ForbiddenType = "customized"
|
||||
)
|
||||
|
||||
@@ -46,16 +45,12 @@ func (f ForbiddenType) String() string {
|
||||
}[f]
|
||||
}
|
||||
|
||||
// LabelScore 审核服务针对单个维度(label)给出的判定与置信度。
|
||||
// LabelScore 单个维度(label)的判定与置信度。过去只取首个非 normal 维度,
|
||||
// 丢掉了其余维度信息,无法按维度差异化处置(例如只拦高置信度的辱骂)。
|
||||
//
|
||||
// 各服务商都会返回「多维度 + 每个维度一个分值」的结果,过去只取首个非 normal 维度,
|
||||
// 丢掉了其余维度的信息,导致无法按维度做差异化处置(例如只拦高置信度的辱骂)。
|
||||
//
|
||||
// ⚠️ Score 已统一归一化到 0~1,与提供方无关:七牛本身即 0~1。
|
||||
// 阿里云内容安全 2.0 不返回逐标签置信度,只返回整条内容的 riskLevel,
|
||||
// 由 AliyunTextCensorParser 折算为 0~1(high=1 / medium=0.6 / low=0.3)后由该响应的
|
||||
// 所有标签共用 —— 这是有损的近似,详见 AliyunTextCensorParser 与 aliyunRiskLevelScore 的注释。
|
||||
// policy.go 的阈值因此对两家服务商都成立(对阿里云的语义为「按风险级别分级」)。
|
||||
// ⚠️ Score 统一归一化到 0~1:七牛本身即 0~1;阿里云 2.0 无逐标签置信度,只给整条内容的
|
||||
// riskLevel,由 AliyunTextCensorParser 折算(high=1 / medium=0.6 / low=0.3)后该响应内所有
|
||||
// 标签共用——有损近似。policy.go 的阈值因此对两家都成立(对阿里云语义为「按风险级别分级」)。
|
||||
type LabelScore struct {
|
||||
Label ForbiddenType `json:"label"`
|
||||
Score float64 `json:"score"`
|
||||
@@ -68,11 +63,9 @@ type TextCensorResponse struct {
|
||||
Hint string `json:"hint"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
RawResponse json.RawMessage `json:"raw_response"`
|
||||
// Details 全部非 normal 维度的判定明细,供严格度策略按维度裁决。
|
||||
// 仅在回放缓存结果时为 nil(见 censor.go),此时 Pass 已足以表达判定。
|
||||
Details []LabelScore `json:"details,omitempty"`
|
||||
// ProviderSuggestion 审核服务自身给出的管控建议(block / review / pass)。
|
||||
// 它不携带具体维度,无法做置信度收敛,因此只在严格档被采信(见 policy.go)。
|
||||
Details []LabelScore `json:"details,omitempty"` // 全部非 normal 维度,供严格度策略裁决;由各 parser 从 RawResponse 重建,缓存回放同样有值(故改档位对历史缓存也生效)
|
||||
// ProviderSuggestion 服务方管控建议(block / review / pass)。不携带维度、无法做
|
||||
// 置信度收敛,因此只在严格档被采信(见 policy.go)。
|
||||
ProviderSuggestion string `json:"provider_suggestion,omitempty"`
|
||||
}
|
||||
|
||||
@@ -81,12 +74,9 @@ func (r *TextCensorResponse) ToJSON() []byte {
|
||||
return jsonBytes
|
||||
}
|
||||
|
||||
// ErrorMessageFor 生成针对指定命中维度的拒绝提示。
|
||||
//
|
||||
// 与已移除的 ErrorMessage 的区别:后者固定使用首个非 normal 维度,而实际触发拦截的
|
||||
// 可能是另一个维度(策略层按档位裁决),两者不一致时用户会看到对不上的原因。
|
||||
// 因此拦截时统一用本方法,按真正命中的维度出文案。
|
||||
// detail 为 nil(即由审核服务的整体 block 建议触发)时退化为不带维度的基础文案。
|
||||
// ErrorMessageFor 按真正命中的维度生成拒绝提示——已移除的 ErrorMessage 固定用首个非
|
||||
// normal 维度,而触发拦截的可能是另一维度(策略层按档位裁决),会导致文案对不上。
|
||||
// detail 为 nil(由服务方整体 block 建议触发)时退化为不带维度的基础文案。
|
||||
func (r *TextCensorResponse) ErrorMessageFor(detail *LabelScore) string {
|
||||
errorMessage := "内容安全检查不通过"
|
||||
if detail != nil && detail.Label != "" {
|
||||
@@ -110,8 +100,7 @@ var TrustedHosts = []string{
|
||||
"acfun.cn",
|
||||
"aixifan.com",
|
||||
"github.com",
|
||||
// 自有域名:旧域 tama.guru 保留至 2026-12 到期前的过渡期结束,
|
||||
// 期间用户分享的旧链接仍需视为可信(不被判为广告);新域 shiroko.one 为主域。
|
||||
// 旧域 tama.guru 保留至 2026-12 到期,过渡期内旧分享链接仍需视为可信。
|
||||
"tama.guru",
|
||||
"shiroko.one",
|
||||
"337845818.best",
|
||||
@@ -119,10 +108,9 @@ var TrustedHosts = []string{
|
||||
"bilibili.com",
|
||||
}
|
||||
|
||||
// RemoveTrustedURL removes the trusted URL from the text,
|
||||
// in order to bypass the URL censoring.
|
||||
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信,
|
||||
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。
|
||||
// RemoveTrustedURL 移除可信域名以免链接被判为广告。
|
||||
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于串尾时才视为可信,
|
||||
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 子域而漏审。
|
||||
func RemoveTrustedURL(str string) string {
|
||||
for _, host := range TrustedHosts {
|
||||
for _, protocol := range []string{"http://", "https://"} {
|
||||
|
||||
+20
-46
@@ -9,11 +9,8 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
// NormalizeIP 规范化 IP 字符串:
|
||||
// - 去除两端空白
|
||||
// - 剥离端口("1.2.3.4:8080" / "[::1]:8080")
|
||||
// - 校验合法性,非法返回空串
|
||||
// - IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单/限流精确匹配
|
||||
// NormalizeIP 规范化 IP:去空白、剥端口、校验合法性(非法返回空串),
|
||||
// 并把 IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单 / 限流精确匹配。
|
||||
func NormalizeIP(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@@ -33,21 +30,15 @@ func NormalizeIP(s string) string {
|
||||
}
|
||||
|
||||
// LimitKey 把客户端 IP 归一化为「限流 / 自动封禁」用的聚合键。
|
||||
// IPv4 原样;IPv6 掩码到 /64 —— 住宅 / 隧道分配通常就是一个 /64(2^64 个地址),
|
||||
// 若按完整地址计数,攻击者每请求换源地址即可让阈值永不触发,且键会无限膨胀内存。
|
||||
//
|
||||
// - IPv4:原样返回(每个地址独立计数)。
|
||||
// - IPv6:掩码到 /64 前缀后返回,形如 "2001:db8:1:2::/64"。
|
||||
//
|
||||
// 为什么 IPv6 必须聚合:单个住宅或隧道 IPv6 分配通常就是一个 /64(2^64 个地址)。
|
||||
// 若以完整地址作键,攻击者每次请求换一个源地址即可让每个键只命中一次,
|
||||
// 使限流与 autoban 的阈值永远无法触发,同时海量键还会持续膨胀内存。
|
||||
// /64 是 IPv6 的最小实际分配单位,按此聚合可在不误伤正常用户的前提下堵住绕过。
|
||||
//
|
||||
// ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续使用精确匹配(见 ipblacklist.go),
|
||||
// 不要套用本函数,否则会把整个 /64 段一并拉黑。
|
||||
// ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续精确匹配(见 ipblacklist.go),
|
||||
// 否则会把整个 /64 段一并拉黑。
|
||||
func LimitKey(ip string) string {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
// 无法解析时原样返回,保持调用方行为可预期(空串由调用方自行判断)。
|
||||
// 无法解析时原样返回,保持调用方可预期(空串由调用方自行判断)。
|
||||
return ip
|
||||
}
|
||||
if v4 := parsed.To4(); v4 != nil {
|
||||
@@ -59,14 +50,11 @@ func LimitKey(ip string) string {
|
||||
// trustedProxyNets 缓存解析后的可信代理网段(conf.Init 之后由 InitTrustedProxies 填充)。
|
||||
var trustedProxyNets []*net.IPNet
|
||||
|
||||
// InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔的 IP / CIDR 列表),
|
||||
// 仅限“你自己的”反向代理/负载均衡基础设施(例如本机 nginx 的 127.0.0.1、私有网络 LB),
|
||||
// 在 conf.Init 之后调用一次。列表为空时,X-Forwarded-For / X-Real-IP 永不被信任。
|
||||
// InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔 IP / CIDR),在 conf.Init 之后调用一次。
|
||||
// 仅限"你自己的"反向代理 / LB(如本机 nginx 127.0.0.1、私有网络 LB);列表为空时 XFF / X-Real-IP 永不被信任。
|
||||
//
|
||||
// 设计目标:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单。
|
||||
// 站点若架在 CDN 后,应配合 Cloudflare Tunnel / Authenticated Origin Pull(Cloudflare)或
|
||||
// 防火墙仅放行回源网段(Cloudflare / EdgeOne)锁定源站只接受 CDN 连接,此时
|
||||
// CF-Connecting-IP / EO-Client-IP 即为权威客户端 IP,无需任何 CIDR 白名单。
|
||||
// 不为 CDN 维护庞大 CIDR 清单:站点架在 CDN 后时,应配合 Tunnel / Authenticated Origin Pull
|
||||
// 或防火墙仅放行回源网段锁定源站,此时 CF-Connecting-IP / EO-Client-IP 即权威客户端 IP。
|
||||
func InitTrustedProxies() error {
|
||||
trustedProxyNets = nil
|
||||
for _, raw := range strings.Split(conf.Security.TrustedProxies, ",") {
|
||||
@@ -114,36 +102,22 @@ func isTrustedProxy(ipStr string) bool {
|
||||
|
||||
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
|
||||
//
|
||||
// 解析策略(不依赖上游 CDN 的庞大 CIDR 清单):
|
||||
// 1. 未开启 TrustProxy:一切以 TCP 对端 RemoteAddr 为准(不可伪造)。
|
||||
// 2. 开启 TrustProxy:
|
||||
// a. 优先信任 CDN 权威客户端头:CF-Connecting-IP(Cloudflare)→ EO-Client-IP
|
||||
// (腾讯 EdgeOne)。这类头只能由对应 CDN 边缘按真实客户端写入,前提是源站已锁定
|
||||
// 为仅接受这些 CDN 的回源连接(攻击者无法直连源站伪造)。
|
||||
// b. 仅当 TCP 对端落在 trusted_proxies(你自己的基础设施)内时,才解析
|
||||
// X-Forwarded-For / X-Real-IP,并从右向左剥离已知可信跳数取原始客户端。
|
||||
// c. 否则回退到 RemoteAddr,绝不信任客户端自行提供的头。
|
||||
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头
|
||||
// CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左
|
||||
// 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。
|
||||
//
|
||||
// ⚠️ 若 trust_proxy = true 但源站仍可被人直连(未用 Tunnel / 未锁源站),攻击者可自行
|
||||
// 发送 CF-Connecting-IP / EO-Client-IP 头,此时本函数会信任之。因此生产开启 trust_proxy
|
||||
// 时,务必配合「Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源
|
||||
// 网段」等手段锁定源站,使伪造头无法抵达应用。
|
||||
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
|
||||
// CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。
|
||||
//
|
||||
// 双 CDN 部署(境内 EdgeOne + 境外 Cloudflare)的额外约束:
|
||||
// 若回源请求携带对方 CDN 的权威头(经 EdgeOne 回源的请求带 CF-Connecting-IP、经
|
||||
// Cloudflare 回源的请求带 EO-Client-IP),攻击者即可借道另一家伪造身份,绕过限流 /
|
||||
// 自动封禁 / 单 IP 投票限制,或嫁祸他人。因此必须:① 防火墙仅放行两家 CDN 的回源网段;
|
||||
// ② 在各家 CDN 的规则引擎中删除入站请求里「对方 CDN」的权威头,使每个请求最终只携带
|
||||
// 自己所在 CDN 的头(操作步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md)。
|
||||
// Cloudflare 侧因 CF-Connecting-IP 恒被覆盖,且用 Transform Rule 删掉 EO-Client-IP 即可;
|
||||
// EdgeOne 侧用规则引擎删掉 CF-Connecting-IP 即可。
|
||||
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段;
|
||||
// ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份,
|
||||
// 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||||
func ResolveClientIP(r *http.Request) string {
|
||||
if !conf.Security.TrustProxy {
|
||||
return NormalizeIP(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// (a) CDN 权威客户端头:CF-Connecting-IP(Cloudflare)优先,其次 EO-Client-IP(腾讯
|
||||
// EdgeOne,等效于 CF-Connecting-IP 的权威头,回源时由 EdgeOne 边缘写入客户端真实 IP)。
|
||||
// (a) CDN 权威客户端头(只能由 CDN 边缘写入):Cloudflare 优先,其次腾讯 EdgeOne。
|
||||
if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
|
||||
return cfIP
|
||||
}
|
||||
|
||||
@@ -109,24 +109,22 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
|
||||
return "", errors.Wrap(err, "reset reader")
|
||||
}
|
||||
|
||||
// 4. 根据格式进行转换或直接使用原图
|
||||
// 4. 按格式转换或直接使用原图
|
||||
switch format {
|
||||
case "gif":
|
||||
webpData, err := convertGifToAnimatedWebP(fileData)
|
||||
if err != nil {
|
||||
// 转换失败则回退到原始 GIF
|
||||
uploadData = fileData
|
||||
uploadData = fileData // 转换失败回退原始 GIF
|
||||
contentType = "image/gif"
|
||||
} else {
|
||||
uploadData = webpData
|
||||
contentType = "image/webp"
|
||||
}
|
||||
default:
|
||||
// 静态图片(JPEG / PNG / WebP 等)转换为静态 WebP
|
||||
// 静态图片(JPEG / PNG / WebP 等)转静态 WebP
|
||||
webpData, err := ConvertStaticImageToWebP(fileReader)
|
||||
if err != nil {
|
||||
// 转换失败时回退到原始图片
|
||||
uploadData = fileData
|
||||
uploadData = fileData // 转换失败回退原图
|
||||
contentType = mimeTypeFromFormat(format)
|
||||
} else {
|
||||
uploadData = webpData
|
||||
|
||||
@@ -15,10 +15,9 @@ import (
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// blockEndTags 与 reBlockEndBrList:删除块级元素闭合标签紧前的 <br>。
|
||||
// HardLineBreak 会把块级元素末尾的换行也渲染成 <br>,产生多余空行;
|
||||
// 这里仅清除末尾的 <br>(可含中间空白),行内真正的换行保留。
|
||||
// 注意:RE2 不支持 lookahead,故对每个闭合标签单独编译。
|
||||
// blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的 <br>:HardLineBreak 会把
|
||||
// 块级元素末尾的换行也渲染成 <br>,产生多余空行(行内换行保留)。
|
||||
// RE2 不支持 lookahead,故对每个闭合标签单独编译。
|
||||
var blockEndTags = []string{
|
||||
"</li>", "</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>",
|
||||
"</blockquote>", "</pre>", "</div>",
|
||||
@@ -27,7 +26,6 @@ var blockEndTags = []string{
|
||||
var reBlockEndBrList = func() []*regexp.Regexp {
|
||||
res := make([]*regexp.Regexp, 0, len(blockEndTags))
|
||||
for _, tag := range blockEndTags {
|
||||
// 捕获闭合标签本身,替换时用 $1 保留
|
||||
res = append(res, regexp.MustCompile(`<br>[ \t\r\n]*(`+regexp.QuoteMeta(tag)+`)`))
|
||||
}
|
||||
return res
|
||||
@@ -36,8 +34,7 @@ var reBlockEndBrList = func() []*regexp.Regexp {
|
||||
// reBlankLines 压缩连续换行为单个换行(MarkdownToPlainText 提取时块级/br 会产生多个 \n)。
|
||||
var reBlankLines = regexp.MustCompile(`\n{2,}`)
|
||||
|
||||
// PlainText 渲染 Markdown 为纯文本并压缩连续空行为单个换行,
|
||||
// 供列表/摘要等紧凑展示场景使用(保留文字与换行,不显示 Markdown 符号与排版)。
|
||||
// PlainText 渲染为纯文本并压缩连续空行,供列表/摘要等紧凑场景使用。
|
||||
func PlainText(markdown string) string {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return ""
|
||||
@@ -45,15 +42,9 @@ func PlainText(markdown string) string {
|
||||
return reBlankLines.ReplaceAllString(MarkdownToPlainText(markdown), "\n")
|
||||
}
|
||||
|
||||
// MarkdownToHTML 将 Markdown 渲染为安全的 HTML 片段(供模板直接输出)。
|
||||
//
|
||||
// 安全性:先用 blackfriday 渲染,再经白名单过滤器清洗:
|
||||
// - 只保留常用排版标签与属性(a[href]/img[src,alt,title] 等)
|
||||
// - 丢弃 script / style / iframe / object / embed / form 等危险标签
|
||||
// - href/src 仅允许 http/https/mailto,其余一律去除
|
||||
// - 剥离所有 on* 事件属性
|
||||
//
|
||||
// 这样既支持 Markdown 排版,又不会引入存储型 XSS。
|
||||
// MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤:
|
||||
// 只保留常用排版标签与属性;丢弃 script / style / iframe / form 等危险标签;
|
||||
// href/src 仅允许 http/https/mailto;剥离所有 on* 事件属性。避免存储型 XSS。
|
||||
func MarkdownToHTML(markdown string) template.HTML {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return ""
|
||||
@@ -117,7 +108,6 @@ func extractPlainText(raw []byte) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 允许的标签集合。
|
||||
var allowedTags = map[string]bool{
|
||||
"p": true, "br": true, "hr": true, "div": true, "span": true,
|
||||
"strong": true, "em": true, "b": true, "i": true, "u": true, "s": true,
|
||||
@@ -141,13 +131,12 @@ func sanitizeHTML(raw []byte) string {
|
||||
case html.ErrorToken:
|
||||
return out.String()
|
||||
case html.TextToken:
|
||||
// Tokenizer.Text returns unescaped text. Escape it again before
|
||||
// rebuilding HTML, otherwise input such as <script> can become
|
||||
// executable markup in the template.HTML result.
|
||||
// ⚠️ Tokenizer.Text 返回已反转义文本,重建 HTML 前必须重新转义,
|
||||
// 否则 <script> 这类输入会在 template.HTML 结果中变成可执行标记。
|
||||
out.WriteString(stdhtml.EscapeString(string(tokenizer.Text())))
|
||||
case html.StartTagToken, html.SelfClosingTagToken:
|
||||
// SelfClosingTagToken 覆盖 `<br />` 这类自闭合写法(blackfriday 的硬换行即此形式),
|
||||
// 漏掉会导致 <br> 被丢弃、换行失效。
|
||||
// SelfClosingTagToken 覆盖 `<br />` 自闭合写法(blackfriday 硬换行即此形式),
|
||||
// 漏掉会丢弃 <br>、换行失效。
|
||||
writeSanitizedTag(&out, tokenizer.Token(), false)
|
||||
case html.EndTagToken:
|
||||
writeSanitizedTag(&out, tokenizer.Token(), true)
|
||||
@@ -182,7 +171,6 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
|
||||
key := strings.ToLower(attr.Key)
|
||||
val := strings.TrimSpace(attr.Val)
|
||||
|
||||
// 过滤事件属性与危险属性
|
||||
if strings.HasPrefix(key, "on") {
|
||||
continue
|
||||
}
|
||||
@@ -247,8 +235,7 @@ func safeURL(u string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(u))
|
||||
|
||||
// 必须先于下面的 "/" 前缀判断:协议相对 URL(//evil.com)同样以 "/" 开头,
|
||||
// 若不单独拦截会被误判为站内相对路径,从而绕过"仅 http/https/mailto"的白名单本意,
|
||||
// 使 markdown 中的 [x](//evil.com) 渲染成指向外站的链接(钓鱼 / 外链埋点)。
|
||||
// 不单独拦截会被误判为站内相对路径,[x](//evil.com) 即可指向外站(钓鱼 / 外链埋点)。
|
||||
// 反斜杠变体(\\evil.com、/\evil.com)在部分浏览器中等价于 //,一并拒绝。
|
||||
if strings.HasPrefix(lower, "//") || strings.HasPrefix(lower, `\\`) ||
|
||||
strings.HasPrefix(lower, `/\`) {
|
||||
|
||||
Reference in New Issue
Block a user