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)