feat: enforce youth mode policy and improve search
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user