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)
|
||||
|
||||
+3
-3
@@ -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
@@ -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¤t_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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user