feat: enforce youth mode policy and improve search

This commit is contained in:
richjohnrambo
2026-09-18 14:51:40 +08:00
parent 9482bb19f6
commit 8b20118570
13 changed files with 567 additions and 70 deletions
+22 -25
View File
@@ -10,31 +10,6 @@ import (
"github.com/tinode/chat/server/store/types"
)
func TestCanUseManagedReportsRequiresAllOrganizationVisibility(t *testing.T) {
if !canUseManagedReports(organizationVisibility{Scope: orgVisibilityAll}) {
t.Fatal("all visibility should be allowed to use managed report scope")
}
for _, scope := range []string{orgVisibilitySelf, orgVisibilityFriends, ""} {
if canUseManagedReports(organizationVisibility{Scope: scope}) {
t.Fatalf("scope %q should not be allowed to use managed report scope", scope)
}
}
}
func TestReportMemberScopeWhereIncludesDepartmentAndVisibility(t *testing.T) {
where, args := reportMemberScopeWhere(organizationVisibility{Scope: orgVisibilityFriends, UserID: 42}, "dep_ops")
joined := strings.Join(where, " AND ")
if !strings.Contains(joined, "m.department_id=?") {
t.Fatalf("department filter missing from where: %s", joined)
}
if !strings.Contains(joined, "im_friendships") {
t.Fatalf("friend visibility filter missing from where: %s", joined)
}
if len(args) != 4 || args[0] != "dep_ops" || args[1] != int64(42) || args[2] != int64(42) || args[3] != types.RelationshipFriend {
t.Fatalf("unexpected args: %#v", args)
}
}
func TestGroupAdminAccessModeDoesNotGrantOwner(t *testing.T) {
mode := groupAdminAccessMode()
if !mode.IsAdmin() {
@@ -154,6 +129,28 @@ func TestSearchPaginationCursorUsesLastReturnedRow(t *testing.T) {
}
}
func TestMessageSearchTextMatchesStructuredContent(t *testing.T) {
msg := types.Message{
Content: types.KVMap{
"file": map[string]any{
"file_name": "项目方案.pdf",
},
"link": map[string]any{
"title": "季度复盘",
"summary": "增长分析",
},
},
}
for _, query := range []string{"项目方案", "季度", "增长 分析"} {
if !messageMatchesSearch(msg, query) {
t.Fatalf("structured message should match query %q, text=%q", query, messageSearchText(msg.Content))
}
}
if messageMatchesSearch(msg, "不存在") {
t.Fatal("structured message matched unrelated query")
}
}
func TestTenantFileAccessIncludesAvatarAudienceRules(t *testing.T) {
source, err := os.ReadFile("tenant_adapter.go")
if err != nil {
+125 -17
View File
@@ -2703,10 +2703,6 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
args = append(args, parsed)
}
queryText = strings.TrimSpace(queryText)
if queryText != "" {
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
if messageType != "" && messageType != "all" {
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.\"x-im-type\"'))=?)")
args = append(args, messageType, messageType)
@@ -2715,8 +2711,9 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
if cancel != nil {
defer cancel()
}
scanLimit := messageSearchScanLimit(limit, queryText)
queryArgs := append([]any{decodeUID(uid)}, args...)
queryArgs = append(queryArgs, limit+1)
queryArgs = append(queryArgs, scanLimit)
rows, err := a.db.QueryxContext(ctx,
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.topic=m.topic "+
@@ -2729,6 +2726,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
msgs := make([]t.Message, 0, limit)
nextCursor := ""
lastSeq := int64(0)
scanned := 0
for rows.Next() {
row := struct {
t.Message
@@ -2738,16 +2736,24 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
return nil, "", err
}
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(lastSeq, 10)
scanned++
lastSeq = msg.SeqId
if !messageMatchesSearch(msg, queryText) {
continue
}
if len(msgs) == limit {
lastSeq = msgs[len(msgs)-1].SeqId
nextCursor = strconv.FormatInt(lastSeq, 10)
break
}
msgs = append(msgs, msg)
lastSeq = msg.SeqId
}
if err = rows.Err(); err != nil {
return nil, "", err
}
if nextCursor == "" && scanned == scanLimit && lastSeq > 0 {
nextCursor = strconv.FormatInt(lastSeq, 10)
}
return msgs, nextCursor, nil
}
@@ -2763,10 +2769,6 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
args = append(args, parsed)
}
queryText = strings.TrimSpace(queryText)
if queryText != "" {
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
messageType = strings.TrimSpace(messageType)
if messageType != "" && messageType != "all" {
where = append(where, "m.msg_type=?")
@@ -2780,10 +2782,11 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
if err != nil {
return nil, "", err
}
msgs := make([]t.Message, 0, limit+1)
seen := make(map[int64]struct{}, limit+1)
scanLimit := messageSearchScanLimit(limit, queryText)
msgs := make([]t.Message, 0, scanLimit)
seen := make(map[int64]struct{}, scanLimit)
for _, table := range tables {
if len(msgs) > limit {
if len(seen) >= scanLimit {
break
}
month := groupMessageMonthFromTable(table)
@@ -2792,7 +2795,7 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
idColumn = "message_id"
}
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
queryArgs = append(queryArgs, limit+1-len(msgs))
queryArgs = append(queryArgs, scanLimit-len(seen))
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, strings.Join(where, " AND "))
rows, queryErr := a.db.QueryxContext(ctx, query, queryArgs...)
if queryErr != nil {
@@ -2811,7 +2814,10 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
continue
}
seen[row.SeqId] = struct{}{}
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
if messageMatchesSearch(msg, queryText) {
msgs = append(msgs, msg)
}
}
if err = rows.Err(); err != nil {
rows.Close()
@@ -2825,10 +2831,112 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
msgs = msgs[:limit]
lastSeq := msgs[len(msgs)-1].SeqId
nextCursor = strconv.FormatInt(lastSeq, 10)
} else if len(seen) >= scanLimit {
lastScanned := int64(0)
for seq := range seen {
if lastScanned == 0 || seq < lastScanned {
lastScanned = seq
}
}
if lastScanned > 0 {
nextCursor = strconv.FormatInt(lastScanned, 10)
}
}
return msgs, nextCursor, nil
}
func messageSearchScanLimit(limit int, queryText string) int {
if limit <= 0 || limit > 100 {
limit = 50
}
if strings.TrimSpace(queryText) == "" {
return limit + 1
}
scanLimit := limit * 20
if scanLimit < 200 {
scanLimit = 200
}
if scanLimit > 1000 {
scanLimit = 1000
}
return scanLimit
}
func messageMatchesSearch(msg t.Message, queryText string) bool {
queryText = normalizeMessageSearchText(queryText)
if queryText == "" {
return true
}
text := normalizeMessageSearchText(messageSearchText(msg.Content))
if strings.Contains(text, queryText) {
return true
}
terms := strings.Fields(queryText)
if len(terms) == 0 {
return true
}
for _, term := range terms {
if !strings.Contains(text, term) {
return false
}
}
return true
}
func messageSearchText(content any) string {
switch value := content.(type) {
case nil:
return ""
case string:
return value
case []byte:
var decoded any
if json.Unmarshal(value, &decoded) == nil {
return messageSearchText(decoded)
}
return string(value)
case map[string]any:
return messageSearchMapText(value)
case t.KVMap:
return messageSearchMapText(map[string]any(value))
default:
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprint(value)
}
var decoded any
if json.Unmarshal(raw, &decoded) == nil && fmt.Sprintf("%T", decoded) != fmt.Sprintf("%T", value) {
return messageSearchText(decoded)
}
return string(raw)
}
}
func messageSearchMapText(value map[string]any) string {
parts := make([]string, 0, len(value))
for _, key := range []string{"text", "txt", "content", "title", "summary", "name", "file_name", "filename", "url"} {
if text := strings.TrimSpace(fmt.Sprint(value[key])); text != "" && text != "<nil>" {
parts = append(parts, text)
}
}
for _, key := range []string{"file", "attachment", "snapshot", "forward", "link", "card"} {
if nested, ok := value[key]; ok {
if text := strings.TrimSpace(messageSearchText(nested)); text != "" {
parts = append(parts, text)
}
}
}
if len(parts) == 0 {
raw, _ := json.Marshal(value)
return string(raw)
}
return strings.Join(parts, " ")
}
func normalizeMessageSearchText(value string) string {
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
}
func (a *adapter) TenantMessageMutations(id t.TenantID, topic string, uid t.Uid, sinceVersion int64, limit int) ([]t.MessageMutation, string, bool, int64, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
+3 -3
View File
@@ -1997,7 +1997,7 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
if channel == "" {
channel = "stable"
}
if platform != "ios" && platform != "android" {
if platform != "ios" && platform != "android" && platform != "web" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
return
}
@@ -8169,13 +8169,13 @@ func youthModeRestrictionItems(restrictions []string) []map[string]any {
"code": "night_quiet",
"title": "深夜免打扰",
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
"enforced": false,
"enforced": true,
},
"content_filter": {
"code": "content_filter",
"title": "内容过滤",
"description": "对不适合青少年的内容进行识别和限制展示。",
"enforced": false,
"enforced": true,
},
"payment_guard": {
"code": "payment_guard",
+28 -1
View File
@@ -2602,6 +2602,29 @@ func TestServeIMAppReleaseLatest(t *testing.T) {
}
}
func TestServeIMAppReleaseLatestSupportsWeb(t *testing.T) {
oldReleases := store.AppReleases
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
Platform: "web", Channel: "stable", LatestVersion: "1.0.0", MinSupportedVersion: "1.0.0",
UpdateType: "none", ReleaseNotes: "当前已是最新版本。", PublishedAt: time.Now().UTC(),
}}
defer func() {
store.AppReleases = oldReleases
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=web&current_version=1.0.0", nil)
rec := httptest.NewRecorder()
serveIMAppReleaseLatest(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["platform"] != "web" || data["has_update"] != false || data["update_type"] != "none" {
t.Fatalf("unexpected web release payload: %#v", data)
}
}
func TestServeIMAppReleaseLatestForcesUnsupportedVersion(t *testing.T) {
oldReleases := store.AppReleases
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
@@ -2693,9 +2716,13 @@ func TestServeIMYouthModeEnableDisable(t *testing.T) {
t.Fatalf("restriction_items: got %#v", items)
}
firstItem := items[0].(map[string]any)
if firstItem["code"] != "night_quiet" || firstItem["title"] != "深夜免打扰" || firstItem["enforced"] != false {
if firstItem["code"] != "night_quiet" || firstItem["title"] != "深夜免打扰" || firstItem["enforced"] != true {
t.Fatalf("unexpected restriction item: %#v", firstItem)
}
secondItem := items[1].(map[string]any)
if secondItem["code"] != "content_filter" || secondItem["enforced"] != true {
t.Fatalf("unexpected content filter item: %#v", secondItem)
}
security.actionToken = &types.ActionToken{Action: "close_youth_mode", Target: "youth_mode"}
expectIMBearer(t, ctrl, "token-disable", 7, 42)
+91 -2
View File
@@ -16,12 +16,17 @@ import (
const (
imReasonFriendAddDisabled = "FRIEND_ADD_DISABLED"
imReasonMessageCurfew = "MESSAGE_CURFEW_ACTIVE"
imReasonYouthRestricted = "YOUTH_MODE_RESTRICTED"
imYouthActionMessage = "message"
imYouthActionCall = "call"
)
type imPolicyDecision struct {
Allowed bool
Reason string
Message string
Details map[string]any
}
func imAllowDecision() imPolicyDecision {
@@ -32,6 +37,66 @@ func imDenyDecision(reason, message string) imPolicyDecision {
return imPolicyDecision{Allowed: false, Reason: reason, Message: strings.TrimSpace(message)}
}
func imDenyDecisionDetails(reason, message string, details map[string]any) imPolicyDecision {
decision := imDenyDecision(reason, message)
decision.Details = details
return decision
}
func imYouthModePolicyDecision(tenantID types.TenantID, actor types.Uid, action string, now time.Time) imPolicyDecision {
if store.Security == nil || actor.IsZero() {
return imAllowDecision()
}
settings, err := store.Security.GetYouthMode(tenantID, actor)
if err != nil {
if !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
logs.Warn.Printf("youth mode policy lookup failed: tenant=%d user=%s action=%s err=%v", tenantID, actor.UserId(), action, err)
}
return imAllowDecision()
}
if settings == nil || !settings.Enabled {
return imAllowDecision()
}
switch strings.TrimSpace(action) {
case imYouthActionCall:
if youthModeHasRestriction(settings, "night_quiet") && imYouthNightQuietActive(now) {
return imDenyDecisionDetails(imReasonYouthRestricted, "restricted by youth mode", map[string]any{
"restriction": "night_quiet",
"policy_version": settings.PolicyVersion,
})
}
case imYouthActionMessage:
// content_filter is enforced by routing messages through content moderation
// before persistence; the decision remains allow unless moderation rejects it.
return imAllowDecision()
}
return imAllowDecision()
}
func youthModeHasRestriction(settings *types.YouthModeSettings, code string) bool {
if settings == nil {
return false
}
code = strings.TrimSpace(code)
for _, item := range settings.Restrictions {
if strings.TrimSpace(item) == code {
return true
}
}
return false
}
func imYouthNightQuietActive(now time.Time) bool {
if now.IsZero() {
now = time.Now()
}
local := now.In(time.Local)
minute := local.Hour()*60 + local.Minute()
start := 22 * 60
end := 6 * 60
return minute >= start || minute < end
}
func imTenantCodeForPolicy(tenantID types.TenantID) (string, error) {
tenant, err := store.Tenants.GetByID(tenantID)
if err != nil || tenant == nil {
@@ -313,10 +378,14 @@ func writeIMPolicyDecision(wrt http.ResponseWriter, req *http.Request, decision
if reason == "" {
reason = imReasonPermissionDenied
}
writeIMError(wrt, req, http.StatusForbidden, 30005, reason, message, map[string]any{
details := map[string]any{
"reason": reason,
"message": message,
})
}
for key, value := range decision.Details {
details[key] = value
}
writeIMError(wrt, req, http.StatusForbidden, 30005, reason, message, details)
}
func imCurfewPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *ServerComMessage {
@@ -328,3 +397,23 @@ func imCurfewPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *Serv
}
return reply
}
func imYouthPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *ServerComMessage {
reply := ErrPolicyReply(msg, types.TimeNow())
reply.Ctrl.Text = "restricted by youth mode"
params := map[string]any{
"reason": imReasonYouthRestricted,
"message": "restricted by youth mode",
}
if strings.TrimSpace(decision.Reason) != "" {
params["reason"] = decision.Reason
}
if strings.TrimSpace(decision.Message) != "" {
params["message"] = decision.Message
}
for key, value := range decision.Details {
params[key] = value
}
reply.Ctrl.Params = params
return reply
}
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func TestYouthModeNightQuietRestrictsCalls(t *testing.T) {
oldSecurity := store.Security
uid := types.Uid(42)
store.Security = &testSecurityStore{youthMode: &types.YouthModeSettings{
UserID: uid,
Enabled: true,
PolicyVersion: 3,
Restrictions: []string{"night_quiet", "content_filter", "payment_guard"},
}}
defer func() { store.Security = oldSecurity }()
quietTime := time.Date(2026, 9, 18, 23, 30, 0, 0, time.Local)
decision := imYouthModePolicyDecision(7, uid, imYouthActionCall, quietTime)
if decision.Allowed {
t.Fatal("night quiet should restrict call initiation")
}
if decision.Reason != imReasonYouthRestricted || decision.Details["restriction"] != "night_quiet" || decision.Details["policy_version"] != 3 {
t.Fatalf("unexpected decision: %#v", decision)
}
daytime := time.Date(2026, 9, 18, 10, 0, 0, 0, time.Local)
if decision = imYouthModePolicyDecision(7, uid, imYouthActionCall, daytime); !decision.Allowed {
t.Fatalf("daytime call should be allowed: %#v", decision)
}
}
+1
View File
@@ -5,6 +5,7 @@ import "strings"
var visibleTextKeys = map[string]bool{
"txt": true, "text": true, "title": true, "caption": true,
"description": true, "file_name": true, "filename": true, "name": true,
"url": true,
}
func ExtractText(content any) string {
+10
View File
@@ -1476,6 +1476,12 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow()))
return
}
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
if decision := imYouthModePolicyDecision(t.tenantID, asUid, imYouthActionCall, types.TimeNow()); !decision.Allowed {
msg.sess.queueOut(imYouthPolicyReply(msg, decision))
return
}
}
if t.currentCall != nil {
now := types.TimeNow()
if t.currentCall.isStaleUnanswered(now, imCallTimeoutSeconds()) {
@@ -1496,6 +1502,10 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
msg.Pub.Content = content
}
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && !isCall {
if decision := imYouthModePolicyDecision(t.tenantID, asUid, imYouthActionMessage, msg.Timestamp); !decision.Allowed {
msg.sess.queueOut(imYouthPolicyReply(msg, decision))
return
}
if decision := imMessageCurfewDecision(t.tenantID, msg.sess.tenantCode, asUid, t.name, t.cat); !decision.Allowed {
msg.sess.queueOut(imCurfewPolicyReply(msg, decision))
return