chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
// 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"
"encoding/json"
"sync"
"time"
"github.com/aliyun/alibaba-cloud-sdk-go/services/green"
"github.com/pkg/errors"
)
type AliyunTextCensor struct {
accessKey, accessKeySecret string
}
func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor {
return &AliyunTextCensor{
accessKey: accessKey,
accessKeySecret: accessKeySecret,
}
}
// 包级复用的 green.Client:审核请求高频且密钥在启动后不变,
// 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。
var (
aliyunGreenOnce sync.Once
aliyunGreenClient *green.Client
aliyunGreenErr error
)
func (c *AliyunTextCensor) getClient() (*green.Client, error) {
aliyunGreenOnce.Do(func() {
aliyunGreenClient, aliyunGreenErr = green.NewClientWithAccessKey("cn-shanghai", c.accessKey, c.accessKeySecret)
if aliyunGreenErr == nil {
aliyunGreenClient.SetConnectTimeout(5 * time.Second)
aliyunGreenClient.SetReadTimeout(10 * time.Second)
}
})
return aliyunGreenClient, aliyunGreenErr
}
type AliyunTextCensorResponse struct {
Code int `json:"code"`
Data []struct {
Code int `json:"code"`
Content string `json:"content"`
FilteredContent string `json:"filteredContent"`
Msg string `json:"msg"`
Results []struct {
Details []struct {
Contexts []struct {
Context string `json:"context"`
Positions []struct {
EndPos int `json:"endPos"`
StartPos int `json:"startPos"`
} `json:"positions"`
} `json:"contexts"`
Label string `json:"label"`
} `json:"details"`
Label string `json:"label"`
Rate float64 `json:"rate"`
Scene string `json:"scene"`
Suggestion string `json:"suggestion"`
} `json:"results"`
TaskId string `json:"taskId"`
} `json:"data"`
Msg string `json:"msg"`
RequestId string `json:"requestId"`
}
func (r *AliyunTextCensorResponse) IsPass() bool {
if len(r.Data) == 0 || len(r.Data[0].Results) == 0 {
return false
}
// ⚠️ Right now, we allow `review` and `pass` to pass the censor.
// 任一 scene 判定为 block 即不通过(此前只检查首个 result,存在漏审)。
for _, result := range r.Data[0].Results {
if result.Suggestion == "block" {
return false
}
}
return true
}
// Censor censors text with Aliyun API.
// https://developer.qiniu.com/censor/7260/api-text-censor
func (c *AliyunTextCensor) Censor(_ context.Context, text string) (*TextCensorResponse, error) {
client, err := c.getClient()
if err != nil {
return nil, errors.Wrap(err, "new client with access key")
}
content, err := json.Marshal(
map[string]interface{}{
"scenes": []string{"antispam"},
"tasks": []map[string]interface{}{
{"content": text},
},
},
)
if err != nil {
return nil, errors.Wrap(err, "marshal content")
}
req := green.CreateTextScanRequest()
req.SetContent(content)
resp, err := client.TextScan(req)
if err != nil {
return nil, errors.Wrap(err, "text scan")
}
if !resp.IsSuccess() {
return nil, errors.New("response is not success")
}
return AliyunTextCensorParser(resp.GetHttpContentBytes())
}
func (*AliyunTextCensor) String() string {
return "aliyun"
}
func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
var responseJSON AliyunTextCensorResponse
if err := json.Unmarshal(raw, &responseJSON); err != nil {
return nil, errors.Wrap(err, "unmarshal response")
}
if len(responseJSON.Data) == 0 {
return nil, errors.New("response data is empty")
}
var hint string
var label string
var confidence float64
for _, result := range responseJSON.Data[0].Results {
if result.Label == "normal" {
continue
}
// Get the first context as the hint, forbidden type, confidence.
for _, detail := range result.Details {
for _, context := range detail.Contexts {
hint = context.Context
}
}
label = result.Label
confidence = result.Rate
break
}
return &TextCensorResponse{
SourceName: "aliyun",
Pass: responseJSON.IsPass(),
ForbiddenType: formatAliyunForbiddenType(label),
Hint: hint,
Confidence: confidence,
RawResponse: raw,
}, nil
}
func formatAliyunForbiddenType(typ string) ForbiddenType {
switch typ {
case "spam":
return ForbiddenTypeSpam
case "ad":
return ForbiddenTypeAd
case "politics":
return ForbiddenTypePolitics
case "terrorism":
return ForbiddenTypeTerrorism
case "abuse":
return ForbiddenTypeAbuse
case "porn":
return ForbiddenTypePorn
case "flood":
return ForbiddenTypeFlood
case "contraband":
return ForbiddenTypeContraband
case "meaningless":
return ForbiddenTypeMeaningless
case "harmful":
return ForbiddenTypeHarmful
default:
return ""
}
}
+96
View File
@@ -0,0 +1,96 @@
// 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"
"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
)
// 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
for _, censor := range []TextCensor{
//No, we need to use qiniu's API.
NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret),
NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret),
} {
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":
// HACK: Qiniu's API response is not accurate, so we need to check the text again with aliyun API.
response, err := QiniuTextCensorParser(raw)
if err == nil {
if response.Pass {
return response, nil
}
responses = append(responses, response) // If the text is not passed, we need to check it with aliyun API.
continue
}
case "aliyun":
return AliyunTextCensorParser(raw)
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
}
+178
View File
@@ -0,0 +1,178 @@
// 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 (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/qiniu/go-sdk/v7/auth"
"github.com/samber/lo"
)
type QiniuTextCensor struct {
accessKey, accessSecret string
}
func NewQiniuTextCensor(accessKey, accessSecret string) *QiniuTextCensor {
return &QiniuTextCensor{
accessKey: accessKey,
accessSecret: accessSecret,
}
}
type QiniuTextCensorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Result struct {
Suggestion string `json:"suggestion"`
Scenes struct {
Antispam struct {
Suggestion string `json:"suggestion"`
Details []struct {
Label string `json:"label"`
Score float64 `json:"score"`
Contexts []struct {
Context string `json:"context"`
Positions []struct {
StartPos int `json:"startPos"`
EndPos int `json:"endPos"`
} `json:"positions"`
} `json:"contexts"`
} `json:"details"`
} `json:"antispam"`
} `json:"scenes"`
} `json:"result"`
}
func (r *QiniuTextCensorResponse) IsPass() bool {
for _, detail := range r.Result.Scenes.Antispam.Details {
label := detail.Label
// These are absolutely not allowed to display to users.
blockedLabels := []string{"politics", "terrorism", "porn"}
if lo.Contains(blockedLabels, label) {
return false
}
}
// ⚠️ Right now, we allow `review` and `pass` to pass the censor.
return r.Result.Scenes.Antispam.Suggestion != "block"
}
// Censor censors text with Qiniu API.
// https://developer.qiniu.com/censor/7260/api-text-censor
func (c *QiniuTextCensor) Censor(ctx context.Context, text string) (*TextCensorResponse, error) {
var bodyBuffer bytes.Buffer
if err := json.NewEncoder(&bodyBuffer).Encode(map[string]interface{}{
"data": map[string]interface{}{
"text": text,
},
"params": map[string]interface{}{
"scenes": []string{"antispam"},
},
}); err != nil {
return nil, errors.Wrap(err, "encode request body")
}
request, err := http.NewRequest(http.MethodPost, "https://ai.qiniuapi.com/v3/text/censor", &bodyBuffer)
if err != nil {
return nil, errors.Wrap(err, "new request")
}
request = request.WithContext(ctx)
credentials := auth.New(c.accessKey, c.accessSecret)
token, err := credentials.SignRequestV2(request)
if err != nil {
return nil, errors.Wrap(err, "sign request")
}
request.Header.Set("Authorization", "Qiniu "+token)
// 带超时:避免七牛审核服务无响应时请求永久挂起,拖垮提问/回答流程
client := http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(request)
if err != nil {
return nil, errors.Wrap(err, "do request")
}
defer func() { _ = resp.Body.Close() }()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "read body")
}
if resp.StatusCode/100 != 2 {
return nil, errors.Errorf("unexpected error code: %d, response body: %s", resp.StatusCode, string(bodyBytes))
}
return QiniuTextCensorParser(bodyBytes)
}
func (*QiniuTextCensor) String() string {
return "qiniu"
}
func QiniuTextCensorParser(raw []byte) (*TextCensorResponse, error) {
var responseJSON QiniuTextCensorResponse
if err := json.Unmarshal(raw, &responseJSON); err != nil {
return nil, errors.Wrap(err, "decode response body")
}
var hint string
var detailKey string
var confidence float64
for _, detail := range responseJSON.Result.Scenes.Antispam.Details {
if detail.Label == "normal" {
continue
}
// Get the first context as the hint, forbidden type, confidence.
for _, context := range detail.Contexts {
hint = context.Context
}
detailKey = detail.Label
confidence = detail.Score
break
}
return &TextCensorResponse{
SourceName: "qiniu",
Pass: responseJSON.IsPass(),
ForbiddenType: formatQiniuForbiddenType(detailKey),
Hint: hint,
Confidence: confidence,
RawResponse: raw,
}, nil
}
func formatQiniuForbiddenType(typ string) ForbiddenType {
switch typ {
case "spam":
return ForbiddenTypeSpam
case "ad":
return ForbiddenTypeAd
case "politics":
return ForbiddenTypePolitics
case "terrorism":
return ForbiddenTypeTerrorism
case "abuse":
return ForbiddenTypeAbuse
case "porn":
return ForbiddenTypePorn
case "flood":
return ForbiddenTypeFlood
case "contraband":
return ForbiddenTypeContraband
case "meaningless":
return ForbiddenTypeMeaningless
default:
return ""
}
}
+121
View File
@@ -0,0 +1,121 @@
// 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"
"encoding/json"
"fmt"
"strings"
)
type ForbiddenType string
const (
ForbiddenTypeSpam ForbiddenType = "spam"
ForbiddenTypeAd ForbiddenType = "ad"
ForbiddenTypePolitics ForbiddenType = "politics"
ForbiddenTypeTerrorism ForbiddenType = "terrorism"
ForbiddenTypeAbuse ForbiddenType = "abuse"
ForbiddenTypePorn ForbiddenType = "porn"
ForbiddenTypeFlood ForbiddenType = "flood"
ForbiddenTypeContraband ForbiddenType = "contraband"
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
ForbiddenTypeHarmful ForbiddenType = "harmful"
)
func (f ForbiddenType) String() string {
return map[ForbiddenType]string{
ForbiddenTypeSpam: "含垃圾信息",
ForbiddenTypeAd: "广告",
ForbiddenTypePolitics: "涉政",
ForbiddenTypeTerrorism: "暴恐",
ForbiddenTypeAbuse: "辱骂",
ForbiddenTypePorn: "色情",
ForbiddenTypeFlood: "灌水",
ForbiddenTypeContraband: "违禁",
ForbiddenTypeMeaningless: "无意义",
ForbiddenTypeHarmful: "不良场景",
}[f]
}
type TextCensorResponse struct {
SourceName string `json:"source_name"`
Pass bool `json:"pass"`
ForbiddenType ForbiddenType `json:"forbidden_type"`
Hint string `json:"hint"`
Confidence float64 `json:"confidence"`
RawResponse json.RawMessage `json:"raw_response"`
}
func (r *TextCensorResponse) ToJSON() []byte {
jsonBytes, _ := json.Marshal(r)
return jsonBytes
}
func (r *TextCensorResponse) ErrorMessage() string {
errorMessage := "内容安全检查不通过"
if r.ForbiddenType != "" {
errorMessage += fmt.Sprintf(" [%s]", r.ForbiddenType.String())
}
if r.Hint != "" {
errorMessage += fmt.Sprintf(",相关标签:%q", r.Hint)
}
return errorMessage
}
type TextCensor interface {
Censor(ctx context.Context, text string) (*TextCensorResponse, error)
String() string
}
var TrustedHosts = []string{
"xhslink.com", // 小红书
"weibo.com", // 微博
"b23.tv", // Bilibili
"acfun.cn",
"aixifan.com",
"github.com",
// 自有域名:旧域 tama.guru 保留至 2026-12 到期前的过渡期结束,
// 期间用户分享的旧链接仍需视为可信(不被判为广告);新域 shiroko.one 为主域。
"tama.guru",
"shiroko.one",
"337845818.best",
"x.com",
"bilibili.com",
}
// RemoveTrustedURL removes the trusted URL from the text,
// in order to bypass the URL censoring.
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信,
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。
func RemoveTrustedURL(str string) string {
for _, host := range TrustedHosts {
for _, protocol := range []string{"http://", "https://"} {
needle := protocol + host
var b strings.Builder
rest := str
for {
idx := strings.Index(rest, needle)
if idx < 0 {
b.WriteString(rest)
break
}
b.WriteString(rest[:idx])
after := rest[idx+len(needle):]
if len(after) == 0 || after[0] == '/' || after[0] == '?' || after[0] == '#' {
// 可信域名边界:跳过该前缀,从域名之后继续(域名本身被移除,不进入审核)。
rest = after
} else {
// 非可信域名边界(如 github.com.evil):保留前缀,从其后继续扫描。
b.WriteString(needle)
rest = after
}
}
str = b.String()
}
}
return str
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2025 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 (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRemoveTrustedURL(t *testing.T) {
for _, tc := range []struct {
name string
text string
want string
}{
{name: "empty string", text: "", want: ""},
{name: "normal", text: "你喜欢看什么书?", want: "你喜欢看什么书?"},
{name: "xiaohongshu", text: `59 茄酱发布了一篇小红书笔记,快来看吧! 😆 jk14SBf2G75FTW2 😆 http://xhslink.com/a/WYaQ6Tjn86Hab,复制本条信息,打开【小红书】App查看精彩内容!`, want: `59 茄酱发布了一篇小红书笔记,快来看吧! 😆 jk14SBf2G75FTW2 😆 /a/WYaQ6Tjn86Hab,复制本条信息,打开【小红书】App查看精彩内容!`},
{name: "weibo", text: `TK 发微博啦~ https://weibo.com/1401527553/5144129806795225 真好看!`, want: `TK 发微博啦~ /1401527553/5144129806795225 真好看!`},
} {
t.Run(tc.name, func(t *testing.T) {
got := RemoveTrustedURL(tc.text)
assert.Equal(t, tc.want, got)
})
}
}