// Copyright 2022 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package censor import ( "context" "strings" "sync" "time" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/db" ) var ( censorCacheNoMoreThan = 31 * 24 * time.Hour // 1 month // warnNoCensorCredentialsOnce 保证「没有可用凭据」的告警只打一次,避免按请求刷屏。 warnNoCensorCredentialsOnce sync.Once ) // censorChain 返回按配置选定的审核通道列表,顺序即优先级: // 靠前的通道判定「通过」即返回;未通过则继续交给后面的通道复核。 // // 取值来源 [security] text_censor_source(见 conf.CensorSource*): // - qiniu → 只用七牛(凭据齐备性已由 conf.Init 启动校验) // - aliyun → 只用阿里云(同上) // - 其它(含留空 / auto)→ 七牛优先、阿里云兜底,与引入该配置前完全一致 func censorChain() []TextCensor { qiniu := NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret) aliyun := NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret) switch strings.ToLower(strings.TrimSpace(conf.Security.TextCensorSource)) { case conf.CensorSourceQiniu: return []TextCensor{qiniu} case conf.CensorSourceAliyun: return []TextCensor{aliyun} default: return []TextCensor{qiniu, aliyun} } } // 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 != "" if qiniuReady || aliyunReady { return } warnNoCensorCredentialsOnce.Do(func() { logrus.WithContext(ctx).WithField("text_censor_source", conf.Security.TextCensorSource). Error("Text censor is enabled but no usable credentials are configured; all submissions will be rejected (fail-closed)") }) } // Text checks the text for sensitive content. // It will save the censor log to the database and invoke the callback function. func Text(ctx context.Context, text string) (*TextCensorResponse, error) { if !conf.Security.EnableTextCensor { return &TextCensorResponse{Pass: true}, nil } 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)) 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 后循环结束, // 仍会由函数末尾返回这份七牛结果,判定不会丢失。 response, err := QiniuTextCensorParser(raw) if err == nil { if response.Pass { return response, nil } responses = append(responses, response) continue } case "aliyun": // 阿里云缓存命中:能解析就直接复用(省一次 API 调用)。 // 解析失败必须退回一次实时调用,而不是把错误往上抛: // 最典型的失败源是「迁移到内容安全 2.0 之前落库的 1.0 缓存」, // 它无法按 2.0 语义解释;若在此返回错误,fail-closed 会让这些 // 历史文本在缓存过期(31 天)前一直发不出去。 response, err := AliyunTextCensorParser(raw) if err == nil { return response, nil } logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName). Warn("Failed to replay cached aliyun censor result; falling back to a live call") default: logrus.WithContext(ctx).WithField("censor_source", censorLog.SourceName).Error("Unknown censor source") } } // response, err := censor.Censor(ctx, text) censorText := RemoveTrustedURL(text) response, err := censor.Censor(ctx, censorText) 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, Pass: response.Pass, RawResponse: response.RawResponse, }); err != nil { logrus.WithContext(ctx).WithError(err).Error("Failed to save censor log") } if response.Pass { return response, nil } } if len(responses) == 0 { return nil, errors.New("no censor source available") } return responses[len(responses)-1], nil }