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
+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(),