feat: enforce tenant content moderation

This commit is contained in:
richjohnrambo
2026-09-16 22:21:25 +08:00
parent 0ae4af0c31
commit 0f8e3b82b7
26 changed files with 891 additions and 1 deletions
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS `im_content_moderation_logs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`request_id` VARCHAR(64) NOT NULL,
`scene` VARCHAR(32) NOT NULL,
`topic` VARCHAR(96) NOT NULL DEFAULT '',
`seqid` BIGINT DEFAULT NULL,
`actor_id` BIGINT DEFAULT NULL,
`provider` VARCHAR(32) NOT NULL DEFAULT '',
`provider_request_id` VARCHAR(96) NOT NULL DEFAULT '',
`risk_level` VARCHAR(16) NOT NULL DEFAULT 'none',
`risk_labels` JSON DEFAULT NULL,
`action` VARCHAR(16) NOT NULL,
`content_hash` CHAR(64) NOT NULL,
`latency_ms` BIGINT NOT NULL DEFAULT 0,
`error_code` VARCHAR(64) NOT NULL DEFAULT '',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_content_moderation_request` (`request_id`),
KEY `idx_im_content_moderation_scene_time` (`scene`,`created_at` DESC,`id` DESC),
KEY `idx_im_content_moderation_actor_time` (`actor_id`,`created_at` DESC,`id` DESC),
KEY `idx_im_content_moderation_topic_seq` (`topic`,`seqid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+1
View File
@@ -21,6 +21,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0
github.com/jmoiron/sqlx v1.4.0
github.com/mozillazg/go-pinyin v0.20.0
github.com/nyaruka/phonenumbers v1.8.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.69.0
+2
View File
@@ -265,6 +265,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+29
View File
@@ -167,6 +167,35 @@ func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (ma
return wrapper.Values, nil
}
func (c *adminInternalClient) ModerationProvider() (*types.ModerationProviderConfig, error) {
var result types.ModerationProviderConfig
if err := c.getJSON("/internal/im-config/moderation/provider", nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *adminInternalClient) ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error) {
var result types.ModerationPolicy
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/moderation-policy"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *adminInternalClient) SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error) {
var result types.SensitiveWordSet
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/sensitive-words"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
if result.Items == nil {
result.Items = []*types.SensitiveWordRule{}
}
return &result, nil
}
func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
+16
View File
@@ -10,11 +10,13 @@ package main
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/moderation"
"github.com/tinode/chat/server/store/types"
)
@@ -1619,6 +1621,20 @@ func ErrPolicyReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
return ErrPolicyExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
}
func ErrContentModerationReply(msg *ClientComMessage, ts time.Time, err error, result *moderation.Result) *ServerComMessage {
reply := ErrPolicyReply(msg, ts)
reason := "CONTENT_REJECTED"
if errors.Is(err, moderation.ErrUnavailable) {
reason = "CONTENT_REVIEW_UNAVAILABLE"
}
reply.Ctrl.Text = "content rejected"
reply.Ctrl.Params = map[string]any{"reason": reason}
if result != nil && strings.TrimSpace(result.BlockMessage) != "" {
reply.Ctrl.Params.(map[string]any)["message"] = result.BlockMessage
}
return reply
}
// ErrCallBusyExplicitTs indicates a "busy" reply to a video call request (486).
func ErrCallBusyExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
return &ServerComMessage{
+1
View File
@@ -245,6 +245,7 @@ type TenantSecurityAuditAdapter interface {
TenantLoginFailureCreate(t.TenantID, *t.LoginFailureEvent) error
TenantLoginAuditClose(t.TenantID, string, time.Time) error
TenantMessageAdminApply(t.TenantID, *t.MessageAdminAction) error
TenantContentModerationLogCreate(t.TenantID, *t.ContentModerationLog) error
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
+2
View File
@@ -587,6 +587,8 @@ func createTenantRegistry(tx *sql.Tx, registryDB, businessDB string) error {
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
aliyun_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Enable Aliyun content moderation',
local_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Enable local content moderation',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -63,6 +63,18 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
PRIMARY KEY(id), UNIQUE INDEX uk_im_user_ip_whitelist(user_id,ip_cidr),
INDEX idx_im_user_ip_whitelist_user(user_id,status), INDEX idx_im_user_ip_whitelist_deleted(deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_content_moderation_logs (
id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL, topic VARCHAR(96) NOT NULL DEFAULT '', seqid BIGINT,
actor_id BIGINT, provider VARCHAR(32) NOT NULL DEFAULT '', provider_request_id VARCHAR(96) NOT NULL DEFAULT '',
risk_level VARCHAR(16) NOT NULL DEFAULT 'none', risk_labels JSON, action VARCHAR(16) NOT NULL,
content_hash CHAR(64) NOT NULL, latency_ms BIGINT NOT NULL DEFAULT 0, error_code VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE INDEX uk_im_content_moderation_request(request_id),
INDEX idx_im_content_moderation_scene_time(scene,created_at DESC,id DESC),
INDEX idx_im_content_moderation_actor_time(actor_id,created_at DESC,id DESC),
INDEX idx_im_content_moderation_topic_seq(topic,seqid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
+24
View File
@@ -36,6 +36,30 @@ func (a *adapter) TenantLoginAuditCreate(id t.TenantID, event *t.LoginAuditEvent
return err
}
func (a *adapter) TenantContentModerationLogCreate(id t.TenantID, event *t.ContentModerationLog) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil || strings.TrimSpace(event.RequestID) == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantContentModerationLogCreate(id, event)
}
labels, err := json.Marshal(event.RiskLabels)
if err != nil {
return err
}
var actorID any
if !event.ActorID.IsZero() {
actorID = decodeUID(event.ActorID)
}
_, err = a.db.Exec(`INSERT INTO im_content_moderation_logs
(request_id,scene,topic,seqid,actor_id,provider,provider_request_id,risk_level,risk_labels,action,content_hash,latency_ms,error_code,created_at)
VALUES(?,?,?,?,?,?,?,?,CAST(? AS JSON),?,?,?,?,?)`, event.RequestID, event.Scene, event.Topic, event.SeqID, actorID,
event.Provider, event.ProviderRequestID, event.RiskLevel, string(labels), event.Action, event.ContentHash,
event.LatencyMS, event.ErrorCode, event.CreatedAt)
return err
}
func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureEvent) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil {
+54
View File
@@ -25,6 +25,7 @@ import (
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/moderation"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"golang.org/x/crypto/bcrypt"
@@ -57,8 +58,36 @@ const (
imReasonResourceNotFound = "RESOURCE_NOT_FOUND"
imReasonGroupDissolved = "GROUP_DISSOLVED"
imReasonValidationFailed = "VALIDATION_FAILED"
imReasonContentRejected = "CONTENT_REJECTED"
imReasonReviewUnavailable = "CONTENT_REVIEW_UNAVAILABLE"
)
func reviewIMHTTPText(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, scene, topic, text string) bool {
text = strings.TrimSpace(text)
if text == "" {
return true
}
tenant, err := store.Tenants.GetByID(principal.TenantID)
if err != nil || tenant == nil {
logs.Warn.Printf("content moderation skipped because tenant code is unavailable: tenant=%d scene=%s err=%v", principal.TenantID, scene, err)
return true
}
result, err := moderation.Review(req.Context(), moderation.Request{TenantID: principal.TenantID, TenantCode: tenant.Code, Scene: scene, ActorID: principal.Uid, Topic: topic, Text: text, TraceID: req.Header.Get("X-Request-ID")})
if err == nil {
return true
}
reason, message := imReasonContentRejected, "内容不符合平台规范,请修改后重试"
status := http.StatusUnprocessableEntity
if errors.Is(err, moderation.ErrUnavailable) {
reason, message, status = imReasonReviewUnavailable, "内容审核暂不可用,请稍后重试", http.StatusServiceUnavailable
}
if result != nil && strings.TrimSpace(result.BlockMessage) != "" {
message = result.BlockMessage
}
writeIMError(wrt, req, status, 30060, reason, message, nil)
return false
}
const (
imVerificationCodeLength = 6
imVerificationExpiresInSeconds = 300
@@ -3183,6 +3212,12 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) {
return
}
}
if body.Nickname != nil && !reviewIMHTTPText(wrt, req, principal, moderation.SceneUserNickname, "", *body.Nickname) {
return
}
if body.Signature != nil && !reviewIMHTTPText(wrt, req, principal, moderation.SceneUserSignature, "", *body.Signature) {
return
}
if body.AvatarFileID != nil {
if avatar := strings.TrimSpace(*body.AvatarFileID); avatar != "" {
if err := store.Files.LinkAttachments(principal.TenantID, principal.Uid.UserId(), types.ZeroUid, []string{avatar}); err != nil {
@@ -4585,6 +4620,13 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
if body.ClientMid != "" {
head["x-im-client-mid"] = body.ClientMid
}
scene := moderation.ScenePrivateMessage
if strings.HasPrefix(topic, "grp") {
scene = moderation.SceneGroupMessage
}
if !reviewIMHTTPText(wrt, req, principal, scene, topic, moderation.ExtractText(body.Content)) {
return
}
editedAt := time.Now().UTC().Round(time.Millisecond)
updated, err := store.Messages.Edit(principal.TenantID, topic, seq, principal.Uid, body.Version, head, body.Content, body.Reason, editedAt)
if err != nil {
@@ -4996,6 +5038,9 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupName, "", name) {
return
}
topicName := genTopicName()
now := time.Now().UTC().Round(time.Millisecond)
public := map[string]any{"name": name, "fn": name, "version": 1}
@@ -5307,6 +5352,9 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "name"})
return
}
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupName, topic, name) {
return
}
public["name"] = name
public["fn"] = name
}
@@ -5493,6 +5541,9 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"})
return
}
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupNickname, topic, nickname) {
return
}
if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
@@ -6115,6 +6166,9 @@ func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
return
}
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupAnnouncement, topic, content) {
return
}
now := time.Now().UTC().Round(time.Millisecond)
announcement, err := store.GroupBusiness.PutAnnouncement(principal.TenantID, &types.GroupAnnouncement{
AnnouncementID: "ann_" + store.Store.GetUidString(),
+3 -1
View File
@@ -6612,6 +6612,7 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUidString().Return("mid-3")
mockStore.EXPECT().GetUid().Return(types.Uid(103))
oldKafkaBus := kafkaBus
oldPublisher := publishKafkaMessagePersist
@@ -6736,7 +6737,8 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
func TestServeIMMessageForwardKafkaPublishUnknownReturnsPending(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUid().Return(types.Uid(104))
oldKafkaBus := kafkaBus
oldPublisher := publishKafkaMessagePersist
+4
View File
@@ -29,6 +29,10 @@ func (r *messageAdminAuditRecorder) ApplyMessageAction(_ types.TenantID, action
return nil
}
func (r *messageAdminAuditRecorder) RecordContentModeration(types.TenantID, *types.ContentModerationLog) error {
return nil
}
func TestExecuteKafkaMessageAdminCommand(t *testing.T) {
previous := store.SecurityAudit
recorder := &messageAdminAuditRecorder{}
+13
View File
@@ -21,6 +21,8 @@ const (
configEventThirdPartyUpdated = "third_party.updated"
configEventRegistrationUpdated = "registration.config.updated"
configEventAppReleaseUpdated = "app.release.updated"
configEventSecurityPolicyUpdated = "security.policy.updated"
configEventSensitiveWordsUpdated = "sensitive_words.updated"
)
type configEventPayload struct {
@@ -83,17 +85,28 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error {
switch strings.TrimSpace(envelope.EventType) {
case configEventTenantUpdated, configEventTenantStatusChanged, configEventTenantLimitsUpdated, configEventTenantFeaturesUpdated:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
if strings.TrimSpace(envelope.EventType) == configEventTenantFeaturesUpdated {
store.InvalidateModerationConfigCache(tenantCode, false, true, false)
}
case configEventDictionaryUpdated, configEventThirdPartyUpdated:
store.InvalidateThirdPartyConfigCache(payload.Group)
if payload.Group == "moderation" {
store.InvalidateModerationConfigCache(tenantCode, true, false, false)
}
case configEventRegistrationUpdated:
store.InvalidateRegistrationConfigCache()
case configEventAppReleaseUpdated:
store.InvalidateAppReleaseConfigCache()
case configEventSecurityPolicyUpdated:
store.InvalidateModerationConfigCache(tenantCode, false, true, false)
case configEventSensitiveWordsUpdated:
store.InvalidateModerationConfigCache(tenantCode, false, false, true)
default:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
store.InvalidateThirdPartyConfigCache(payload.Group)
store.InvalidateRegistrationConfigCache()
store.InvalidateAppReleaseConfigCache()
store.InvalidateModerationConfigCache(tenantCode, true, true, true)
}
logs.Info.Printf("kafka config event applied: event=%s type=%s tenant=%s group=%s", envelope.EventID, envelope.EventType, tenantCode, payload.Group)
return nil
+100
View File
@@ -0,0 +1,100 @@
package aliyun
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
green "github.com/alibabacloud-go/green-20220302/v3/client"
"github.com/alibabacloud-go/tea/dara"
"github.com/tinode/chat/server/store/types"
)
type TextClient struct {
client *green.Client
timeout time.Duration
}
type Result struct {
ProviderRequestID string
RiskLevel string
RiskLabels []string
Summary string
Latency time.Duration
}
func NewTextClient(config *types.ModerationProviderConfig) (*TextClient, error) {
if config == nil || strings.TrimSpace(config.AccessKeyID) == "" || strings.TrimSpace(config.AccessKeySecret) == "" {
return nil, errors.New("missing aliyun moderation credentials")
}
clientConfig := &openapi.Config{AccessKeyId: dara.String(config.AccessKeyID), AccessKeySecret: dara.String(config.AccessKeySecret), RegionId: dara.String(config.RegionID), Protocol: dara.String("https")}
if endpoint := strings.TrimSpace(config.Endpoint); endpoint != "" {
clientConfig.Endpoint = dara.String(endpoint)
}
client, err := green.NewClient(clientConfig)
if err != nil {
return nil, err
}
timeout := time.Duration(config.TimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 1500 * time.Millisecond
}
return &TextClient{client: client, timeout: timeout}, nil
}
func (c *TextClient) Review(ctx context.Context, service, text, dataID string) (*Result, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
params, err := json.Marshal(map[string]string{"content": text, "dataId": dataID})
if err != nil {
return nil, err
}
started := time.Now()
resp, err := c.client.TextModerationPlusWithContext(ctx, &green.TextModerationPlusRequest{Service: dara.String(service), ServiceParameters: dara.String(string(params))}, &dara.RuntimeOptions{})
latency := time.Since(started)
if err != nil {
return nil, err
}
if resp == nil || resp.Body == nil || dara.Int32Value(resp.Body.Code) != 200 || resp.Body.Data == nil {
return nil, errors.New("aliyun text moderation failed")
}
result := &Result{ProviderRequestID: dara.StringValue(resp.Body.RequestId), RiskLevel: moderationRisk(dara.StringValue(resp.Body.Data.RiskLevel)), Latency: latency}
seen := map[string]bool{}
for _, item := range resp.Body.Data.Result {
if item == nil {
continue
}
label := strings.TrimSpace(dara.StringValue(item.Label))
if label != "" && !seen[label] {
seen[label] = true
result.RiskLabels = append(result.RiskLabels, label)
}
}
if ext := resp.Body.Data.Ext; ext != nil && ext.LlmContent != nil {
result.Summary = strings.TrimSpace(dara.StringValue(ext.LlmContent.OutputText))
if llmIndicatesRisk(result.Summary) && result.RiskLevel == "none" {
result.RiskLevel = "medium"
result.RiskLabels = append(result.RiskLabels, "llm_risk")
}
}
return result, nil
}
func moderationRisk(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "high", "medium", "low":
return strings.ToLower(strings.TrimSpace(value))
default:
return "none"
}
}
func llmIndicatesRisk(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
return !strings.Contains(value, "无风险") && !strings.HasPrefix(value, "正常") && (strings.Contains(value, "风险") || strings.Contains(value, "违规") || strings.Contains(value, "诈骗") || strings.Contains(value, "引流"))
}
+34
View File
@@ -0,0 +1,34 @@
package moderation
import "strings"
var visibleTextKeys = map[string]bool{
"txt": true, "text": true, "title": true, "caption": true,
"description": true, "file_name": true, "filename": true, "name": true,
}
func ExtractText(content any) string {
parts := make([]string, 0, 4)
var walk func(any, int, bool)
walk = func(value any, depth int, includeString bool) {
if depth > 6 || value == nil {
return
}
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); includeString && text != "" {
parts = append(parts, text)
}
case map[string]any:
for key, item := range typed {
walk(item, depth+1, visibleTextKeys[strings.ToLower(strings.TrimSpace(key))])
}
case []any:
for _, item := range typed {
walk(item, depth+1, includeString)
}
}
}
walk(content, 0, true)
return strings.TrimSpace(strings.Join(parts, "\n"))
}
+74
View File
@@ -0,0 +1,74 @@
package moderation
import (
"regexp"
"strings"
"github.com/mozillazg/go-pinyin"
"github.com/tinode/chat/server/store/types"
)
func matchLocalWords(text, scene string, words *types.SensitiveWordSet) *ProviderResult {
normalized := strings.ToLower(strings.TrimSpace(text))
if normalized == "" || words == nil {
return nil
}
for _, rule := range words.Items {
if rule == nil || strings.TrimSpace(rule.Word) == "" || !sceneEnabled(rule.Scenes, scene) {
continue
}
matched := false
switch strings.ToLower(strings.TrimSpace(rule.MatchType)) {
case "exact":
matched = normalized == strings.ToLower(strings.TrimSpace(rule.Word))
case "regex":
pattern, err := regexp.Compile(rule.Word)
matched = err == nil && pattern.MatchString(text)
case "pinyin":
args := pinyin.NewArgs()
args.Style = pinyin.Normal
textPinyin := strings.Join(pinyin.LazyConvert(text, &args), "")
wordPinyin := strings.Join(pinyin.LazyConvert(rule.Word, &args), "")
if wordPinyin == "" {
wordPinyin = strings.ToLower(strings.TrimSpace(rule.Word))
}
matched = wordPinyin != "" && strings.Contains(strings.ToLower(textPinyin), strings.ToLower(wordPinyin))
default:
matched = strings.Contains(normalized, strings.ToLower(strings.TrimSpace(rule.Word)))
}
if matched {
return &ProviderResult{RiskLevel: normalizedRisk(rule.RiskLevel), RiskLabels: []string{"local_sensitive_word"}, Summary: normalizedAction(rule.Action)}
}
}
return nil
}
func sceneEnabled(scenes []string, scene string) bool {
if len(scenes) == 0 {
return true
}
for _, value := range scenes {
if value == "*" || value == scene {
return true
}
}
return false
}
func normalizedRisk(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "high", "medium", "low":
return strings.ToLower(strings.TrimSpace(value))
default:
return "none"
}
}
func normalizedAction(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case ActionBlock, ActionAuditOnly:
return strings.ToLower(strings.TrimSpace(value))
default:
return ActionAllow
}
}
+67
View File
@@ -0,0 +1,67 @@
package moderation
import (
"context"
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testConfig struct {
policy *types.ModerationPolicy
words *types.SensitiveWordSet
}
func (c testConfig) Provider() (*types.ModerationProviderConfig, error) {
return &types.ModerationProviderConfig{}, nil
}
func (c testConfig) Policy(string) (*types.ModerationPolicy, error) { return c.policy, nil }
func (c testConfig) Words(string) (*types.SensitiveWordSet, error) { return c.words, nil }
type testAudit struct{}
func (testAudit) RecordLogin(types.TenantID, *types.LoginAuditEvent) error { return nil }
func (testAudit) RecordLoginFailure(types.TenantID, *types.LoginFailureEvent) error { return nil }
func (testAudit) CloseLogin(types.TenantID, string, time.Time) error { return nil }
func (testAudit) ApplyMessageAction(types.TenantID, *types.MessageAdminAction) error { return nil }
func (testAudit) RecordContentModeration(types.TenantID, *types.ContentModerationLog) error {
return nil
}
func TestExtractText(t *testing.T) {
content := map[string]any{"txt": "正文", "ent": []any{map[string]any{"data": map[string]any{"title": "查看详情", "url": "https://example.com"}}}, "meta": "ignore"}
if got := ExtractText(content); got != "正文\n查看详情" && got != "查看详情\n正文" {
t.Fatalf("ExtractText()=%q", got)
}
}
func TestReviewLocalBlock(t *testing.T) {
oldConfig, oldAudit := store.ModerationConfig, store.SecurityAudit
defer func() { store.ModerationConfig, store.SecurityAudit = oldConfig, oldAudit }()
store.ModerationConfig = testConfig{policy: &types.ModerationPolicy{Enabled: true, LocalWordsEnabled: true, Scenes: map[string]bool{ScenePrivateMessage: true}, RiskActions: map[string]string{"high": ActionBlock}}, words: &types.SensitiveWordSet{Items: []*types.SensitiveWordRule{{Word: "违规词", MatchType: "contains", Scenes: []string{ScenePrivateMessage}, Action: ActionBlock, RiskLevel: "high"}}}}
store.SecurityAudit = testAudit{}
result, err := Review(context.Background(), Request{TenantID: 1, TenantCode: "default", Scene: ScenePrivateMessage, Text: "包含违规词的消息"})
if err != ErrRejected || result.Action != ActionBlock || result.Provider != "local" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestReviewOtherTenantUsesOwnPolicy(t *testing.T) {
oldConfig, oldAudit := store.ModerationConfig, store.SecurityAudit
defer func() { store.ModerationConfig, store.SecurityAudit = oldConfig, oldAudit }()
store.ModerationConfig = testConfig{policy: &types.ModerationPolicy{Enabled: true, LocalWordsEnabled: true, Scenes: map[string]bool{ScenePrivateMessage: true}, RiskActions: map[string]string{"high": ActionBlock}}, words: &types.SensitiveWordSet{Items: []*types.SensitiveWordRule{{Word: "违规词", MatchType: "contains", Scenes: []string{ScenePrivateMessage}, Action: ActionBlock, RiskLevel: "high"}}}}
store.SecurityAudit = testAudit{}
result, err := Review(context.Background(), Request{TenantID: 2, TenantCode: "team", Scene: ScenePrivateMessage, Text: "违规词"})
if err != ErrRejected || result.Action != ActionBlock {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestMatchLocalWordsPinyin(t *testing.T) {
result := matchLocalWords("这里包含敏感内容", SceneGroupMessage, &types.SensitiveWordSet{Items: []*types.SensitiveWordRule{{Word: "mingan", MatchType: "pinyin", Scenes: []string{SceneGroupMessage}, RiskLevel: "medium"}}})
if result == nil || result.RiskLevel != "medium" {
t.Fatalf("result=%+v", result)
}
}
+62
View File
@@ -0,0 +1,62 @@
package moderation
import (
"context"
"errors"
"time"
"github.com/tinode/chat/server/store/types"
)
const (
SceneUserNickname = "user_nickname"
SceneUserSignature = "user_signature"
SceneGroupName = "group_name"
SceneGroupNickname = "group_nickname"
ScenePrivateMessage = "private_message"
SceneGroupMessage = "group_message"
SceneGroupAnnouncement = "group_announcement"
ActionAllow = "allow"
ActionBlock = "block"
ActionAuditOnly = "audit_only"
)
var (
ErrRejected = errors.New("content rejected")
ErrUnavailable = errors.New("content review unavailable")
)
type Request struct {
TenantID types.TenantID
TenantCode string
Scene string
ActorID types.Uid
Topic string
Text string
TraceID string
}
type ProviderResult struct {
ProviderRequestID string
RiskLevel string
RiskLabels []string
Summary string
Latency time.Duration
}
type Result struct {
RequestID string
Action string
RiskLevel string
RiskLabels []string
Provider string
ProviderRequestID string
Latency time.Duration
BlockMessage string
Err error
}
type Provider interface {
Review(context.Context, string, string, string) (*ProviderResult, error)
}
+172
View File
@@ -0,0 +1,172 @@
package moderation
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/moderation/aliyun"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type circuitState struct {
sync.Mutex
failures int
openUntil time.Time
}
var aliyunCircuit circuitState
func Review(ctx context.Context, req Request) (*Result, error) {
text := strings.TrimSpace(req.Text)
result := &Result{RequestID: moderationRequestID(), Action: ActionAllow, RiskLevel: "none"}
if text == "" || strings.TrimSpace(req.TenantCode) == "" {
return result, nil
}
policy, err := store.ModerationConfig.Policy(req.TenantCode)
if err != nil {
return failureResult(result, nil, err)
}
if policy == nil || !policy.Enabled || !policy.Scenes[req.Scene] {
return result, nil
}
defer func() { recordDecision(req, text, result) }()
result.BlockMessage = policy.BlockMessage
if policy.LocalWordsEnabled {
words, wordsErr := store.ModerationConfig.Words(req.TenantCode)
if wordsErr != nil {
if logs.Warn != nil {
logs.Warn.Printf("moderation local words unavailable: tenant=%s scene=%s err=%v", req.TenantCode, req.Scene, wordsErr)
}
} else if localResult := matchLocalWords(text, req.Scene, words); localResult != nil {
result.Provider, result.RiskLevel, result.RiskLabels = "local", localResult.RiskLevel, localResult.RiskLabels
result.Action = actionForRisk(policy, localResult.RiskLevel)
if localResult.Summary == ActionBlock {
result.Action = ActionBlock
}
if result.Action == ActionBlock {
result.Err = ErrRejected
return result, ErrRejected
}
}
}
if !policy.AliyunEnabled {
return result, nil
}
providerConfig, err := store.ModerationConfig.Provider()
if err != nil || providerConfig == nil || !providerConfig.Enabled {
return failureResult(result, policy, firstError(err, ErrUnavailable))
}
if circuitOpen() {
return failureResult(result, policy, ErrUnavailable)
}
provider, err := aliyun.NewTextClient(providerConfig)
if err != nil {
recordFailure()
return failureResult(result, policy, err)
}
service := providerConfig.ChatService
if req.Scene == SceneUserNickname || req.Scene == SceneUserSignature || req.Scene == SceneGroupName || req.Scene == SceneGroupNickname {
service = providerConfig.NicknameService
}
providerResult, err := provider.Review(ctx, service, text, result.RequestID)
if err != nil {
recordFailure()
return failureResult(result, policy, err)
}
recordSuccess()
result.Provider, result.ProviderRequestID, result.RiskLevel, result.RiskLabels, result.Latency = "aliyun_green", providerResult.ProviderRequestID, providerResult.RiskLevel, providerResult.RiskLabels, providerResult.Latency
result.Action = actionForRisk(policy, result.RiskLevel)
if result.Action == ActionBlock {
result.Err = ErrRejected
return result, ErrRejected
}
return result, nil
}
func failureResult(result *Result, policy *types.ModerationPolicy, err error) (*Result, error) {
result.Err = err
failureMode := "allow"
if policy != nil && policy.FailureMode == "block" {
failureMode = "block"
result.BlockMessage = policy.BlockMessage
}
if failureMode == "block" {
result.Action = ActionBlock
return result, ErrUnavailable
}
result.Action = ActionAuditOnly
return result, nil
}
func actionForRisk(policy *types.ModerationPolicy, risk string) string {
if policy != nil {
if action := normalizedAction(policy.RiskActions[normalizedRisk(risk)]); action != "" {
return action
}
}
return ActionAllow
}
func firstError(err, fallback error) error {
if err != nil {
return err
}
return fallback
}
func moderationRequestID() string {
var raw [12]byte
if _, err := rand.Read(raw[:]); err != nil {
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
}
return hex.EncodeToString(raw[:])
}
func circuitOpen() bool {
aliyunCircuit.Lock()
defer aliyunCircuit.Unlock()
return time.Now().Before(aliyunCircuit.openUntil)
}
func recordFailure() {
aliyunCircuit.Lock()
defer aliyunCircuit.Unlock()
aliyunCircuit.failures++
if aliyunCircuit.failures >= 5 {
aliyunCircuit.openUntil = time.Now().Add(30 * time.Second)
aliyunCircuit.failures = 0
}
}
func recordSuccess() {
aliyunCircuit.Lock()
aliyunCircuit.failures = 0
aliyunCircuit.openUntil = time.Time{}
aliyunCircuit.Unlock()
}
func logDecision(req Request, result *Result) {
if logs.Info != nil {
logs.Info.Printf("content moderation: request=%s tenant=%s scene=%s actor=%s provider=%s risk=%s action=%s latency_ms=%d", result.RequestID, req.TenantCode, req.Scene, req.ActorID.UserId(), result.Provider, result.RiskLevel, result.Action, result.Latency.Milliseconds())
}
}
func recordDecision(req Request, text string, result *Result) {
logDecision(req, result)
sum := sha256.Sum256([]byte(text))
errorCode := ""
if result.Err != nil {
errorCode = "PROVIDER_ERROR"
}
if err := store.SecurityAudit.RecordContentModeration(req.TenantID, &types.ContentModerationLog{
RequestID: result.RequestID, Scene: req.Scene, Topic: req.Topic, ActorID: req.ActorID,
Provider: result.Provider, ProviderRequestID: result.ProviderRequestID, RiskLevel: result.RiskLevel,
RiskLabels: result.RiskLabels, Action: result.Action, ContentHash: hex.EncodeToString(sum[:]),
LatencyMS: result.Latency.Milliseconds(), ErrorCode: errorCode, CreatedAt: time.Now().UTC().Round(time.Millisecond),
}); err != nil {
if logs.Warn != nil {
logs.Warn.Printf("content moderation audit write failed: request=%s tenant=%s err=%v", result.RequestID, req.TenantCode, err)
}
}
}
+23
View File
@@ -61,9 +61,32 @@ var (
registrationSMSCache = newConfigCache[bool]()
thirdPartyConfigCache = newConfigCache[map[string]string]()
appReleaseConfigCache = newConfigCache[*types.AppRelease]()
moderationProviderCache = newConfigCache[*types.ModerationProviderConfig]()
moderationPolicyCache = newConfigCache[*types.ModerationPolicy]()
moderationWordsCache = newConfigCache[*types.SensitiveWordSet]()
thirdPartyConfigAllKeys = "*"
)
func InvalidateModerationConfigCache(tenantCode string, provider, policy, words bool) {
if provider {
moderationProviderCache.clear()
}
if policy {
if strings.TrimSpace(tenantCode) == "" {
moderationPolicyCache.clear()
} else {
moderationPolicyCache.delete(strings.TrimSpace(tenantCode))
}
}
if words {
if strings.TrimSpace(tenantCode) == "" {
moderationWordsCache.clear()
} else {
moderationWordsCache.delete(strings.TrimSpace(tenantCode))
}
}
}
func cloneTenant(t *types.Tenant) *types.Tenant {
if t == nil {
return nil
+9
View File
@@ -12,6 +12,15 @@ type SecurityAuditPersistenceInterface interface {
RecordLoginFailure(types.TenantID, *types.LoginFailureEvent) error
CloseLogin(types.TenantID, string, time.Time) error
ApplyMessageAction(types.TenantID, *types.MessageAdminAction) error
RecordContentModeration(types.TenantID, *types.ContentModerationLog) error
}
func (securityAuditMapper) RecordContentModeration(tenantID types.TenantID, event *types.ContentModerationLog) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantContentModerationLogCreate(tenantID, event)
}
type securityAuditMapper struct{}
+57
View File
@@ -170,6 +170,9 @@ type AdminInternalConfigProvider interface {
RegistrationSMSEnabled() (bool, error)
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
LatestAppRelease(platform, channel string) (*types.AppRelease, error)
ModerationProvider() (*types.ModerationProviderConfig, error)
ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error)
SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error)
}
var adminInternalConfigProvider AdminInternalConfigProvider
@@ -178,6 +181,60 @@ func SetAdminInternalConfigProvider(provider AdminInternalConfigProvider) {
adminInternalConfigProvider = provider
}
type ModerationConfigPersistenceInterface interface {
Provider() (*types.ModerationProviderConfig, error)
Policy(tenantCode string) (*types.ModerationPolicy, error)
Words(tenantCode string) (*types.SensitiveWordSet, error)
}
type moderationConfigMapper struct{}
var ModerationConfig ModerationConfigPersistenceInterface = moderationConfigMapper{}
func (moderationConfigMapper) Provider() (*types.ModerationProviderConfig, error) {
if value, ok := moderationProviderCache.get("provider"); ok {
return value, nil
}
if adminInternalConfigProvider == nil {
return nil, types.ErrUnsupported
}
value, err := adminInternalConfigProvider.ModerationProvider()
if err == nil && value != nil {
moderationProviderCache.set("provider", value)
}
return value, err
}
func (moderationConfigMapper) Policy(tenantCode string) (*types.ModerationPolicy, error) {
tenantCode = strings.TrimSpace(tenantCode)
if value, ok := moderationPolicyCache.get(tenantCode); ok {
return value, nil
}
if adminInternalConfigProvider == nil {
return nil, types.ErrUnsupported
}
value, err := adminInternalConfigProvider.ModerationPolicy(tenantCode)
if err == nil && value != nil {
moderationPolicyCache.set(tenantCode, value)
}
return value, err
}
func (moderationConfigMapper) Words(tenantCode string) (*types.SensitiveWordSet, error) {
tenantCode = strings.TrimSpace(tenantCode)
if value, ok := moderationWordsCache.get(tenantCode); ok {
return value, nil
}
if adminInternalConfigProvider == nil {
return nil, types.ErrUnsupported
}
value, err := adminInternalConfigProvider.SensitiveWords(tenantCode)
if err == nil && value != nil {
moderationWordsCache.set(tenantCode, value)
}
return value, err
}
// RegistrationConfigPersistenceInterface exposes global registration policy.
type RegistrationConfigPersistenceInterface interface {
SMSEnabled() (bool, error)
+17
View File
@@ -57,3 +57,20 @@ type MessageAdminAction struct {
RequestID string
ProcessedAt time.Time
}
type ContentModerationLog struct {
RequestID string
Scene string
Topic string
SeqID *int64
ActorID Uid
Provider string
ProviderRequestID string
RiskLevel string
RiskLabels []string
Action string
ContentHash string
LatencyMS int64
ErrorCode string
CreatedAt time.Time
}
+45
View File
@@ -0,0 +1,45 @@
package types
type ModerationProviderConfig struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Enabled bool `json:"enabled"`
RegionID string `json:"region_id"`
NicknameService string `json:"nickname_service"`
ChatService string `json:"chat_service"`
TimeoutMS int `json:"timeout_ms"`
FailureMode string `json:"failure_mode"`
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
}
type ModerationPolicy struct {
TenantID TenantID `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
PolicyVersion uint `json:"policy_version"`
Enabled bool `json:"enabled"`
AliyunEnabled bool `json:"aliyun_enabled"`
LocalWordsEnabled bool `json:"local_words_enabled"`
Scenes map[string]bool `json:"scenes"`
RiskActions map[string]string `json:"risk_actions"`
FailureMode string `json:"failure_mode"`
BlockMessage string `json:"block_message"`
}
type SensitiveWordRule struct {
ID int64 `json:"id"`
Word string `json:"word"`
MatchType string `json:"match_type"`
Scenes []string `json:"scenes"`
Action string `json:"action"`
Replacement string `json:"replacement"`
RiskLevel string `json:"risk_level"`
Priority int `json:"priority"`
}
type SensitiveWordSet struct {
TenantID TenantID `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
Version int64 `json:"version"`
Items []*SensitiveWordRule `json:"items"`
}
+43
View File
@@ -20,6 +20,7 @@ import (
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/moderation"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -1494,6 +1495,22 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
msg.Pub.Head = head
msg.Pub.Content = content
}
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && !isCall {
scene := moderation.ScenePrivateMessage
if t.cat == types.TopicCatGrp {
scene = moderation.SceneGroupMessage
}
if text := moderation.ExtractText(msg.Pub.Content); text != "" {
result, reviewErr := moderation.Review(context.Background(), moderation.Request{
TenantID: t.tenantID, TenantCode: msg.sess.tenantCode, Scene: scene,
ActorID: asUid, Topic: t.name, Text: text, TraceID: msg.Id,
})
if reviewErr != nil {
msg.sess.queueOut(ErrContentModerationReply(msg, types.TimeNow(), reviewErr, result))
return
}
}
}
// Save to DB at master topic.
attachments := messageAttachmentURLs(msg)
@@ -2880,6 +2897,32 @@ func (t *Topic) replySetDesc(sess *Session, asUid types.Uid, asChan bool,
sess.queueOut(ErrPermissionDeniedReply(msg, now))
return errors.New("attempt to change Trusted by non-root")
}
if sess != nil && authLevel != auth.LevelRoot {
reviewMeta := func(scene string, value any) error {
text := moderation.ExtractText(value)
if text == "" {
return nil
}
result, reviewErr := moderation.Review(context.Background(), moderation.Request{TenantID: t.tenantID, TenantCode: sess.tenantCode, Scene: scene, ActorID: asUid, Topic: t.name, Text: text, TraceID: msg.Id})
if reviewErr != nil {
sess.queueOut(ErrContentModerationReply(msg, now, reviewErr, result))
return reviewErr
}
return nil
}
if t.cat == types.TopicCatMe {
if err = reviewMeta(moderation.SceneUserNickname, set.Desc.Public); err != nil {
return err
}
} else if t.cat == types.TopicCatGrp {
if err = reviewMeta(moderation.SceneGroupName, set.Desc.Public); err != nil {
return err
}
if err = reviewMeta(moderation.SceneGroupNickname, set.Desc.Private); err != nil {
return err
}
}
}
switch t.cat {
case types.TopicCatMe:
+5
View File
@@ -1061,15 +1061,20 @@ func TestSaveMessageMaybeKafkaPublishesWithoutDBPersist(t *testing.T) {
defer ctrl.Finish()
oldKafkaBus := kafkaBus
oldStore := store.Store
oldMessages := store.Messages
oldPublisher := publishKafkaMessagePersist
defer func() {
kafkaBus = oldKafkaBus
store.Store = oldStore
store.Messages = oldMessages
publishKafkaMessagePersist = oldPublisher
}()
kafkaBus = &kafkaRuntime{}
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
mockStore.EXPECT().GetUid().Return(types.Uid(101))
store.Store = mockStore
store.Messages = mock_store.NewMockMessagesPersistenceInterface(ctrl)
var publishedTopic string
var publishedKey string