148 lines
5.5 KiB
Go
148 lines
5.5 KiB
Go
// 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 选定的通道列表,顺序即优先级:
|
|
// 靠前通道判定通过即返回,未通过则交给后面的通道复核。
|
|
// qiniu / aliyun 只用对应通道(凭据齐备性已由 conf.Init 启动校验);
|
|
// 其它取值(含留空 / 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 检查文本是否含敏感内容,并把审核结果落库(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()
|
|
|
|
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
|
|
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":
|
|
// 能解析就复用;解析失败必须退回实时调用而不是把错误往上抛:
|
|
// 最典型的失败源是迁到内容安全 2.0 前落库的 1.0 缓存,无法按 2.0 语义解释,
|
|
// 若在此报错,fail-closed 会让这些历史文本在缓存过期前一直发不出去。
|
|
// 与七牛一致:仅「通过」才直接返回,未通过则交给链上下一个通道复核
|
|
// (阿里云现为链尾,continue 后循环结束,仍由函数末尾返回同一份结果)。
|
|
response, err := AliyunTextCensorParser(raw)
|
|
if err == 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")
|
|
|
|
default:
|
|
logrus.WithContext(ctx).WithField("censor_source", censorLog.SourceName).Error("Unknown censor source")
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
if err := db.CensorLogs.Create(ctx, db.CreateCensorLogOptions{
|
|
SourceName: sourceName,
|
|
Input: normalized,
|
|
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
|
|
}
|