From 3a92d81fcac8af39452767099c129aedc28ea82e Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 18 Sep 2026 10:55:33 +0800 Subject: [PATCH] feat: enforce friend add and curfew policies --- server/admin_internal_config.go | 15 ++ server/db/mysql/adapter.go | 3 + server/db/mysql/tenant_adapter.go | 56 ++-- server/db/mysql/user_identity_schema.go | 27 +- server/im_http.go | 48 +++- server/im_security_policy_runtime.go | 330 +++++++++++++++++++++++ server/internal_admin_query_resources.go | 2 +- server/kafka_config_events.go | 2 + server/moderation/moderation_test.go | 5 +- server/store/config_cache.go | 9 + server/store/store.go | 17 ++ server/store/types/profile.go | 27 +- server/store/types/security_policy.go | 28 ++ server/topic.go | 4 + 14 files changed, 531 insertions(+), 42 deletions(-) create mode 100644 server/im_security_policy_runtime.go create mode 100644 server/store/types/security_policy.go diff --git a/server/admin_internal_config.go b/server/admin_internal_config.go index 2e784c2..c1ec9cb 100644 --- a/server/admin_internal_config.go +++ b/server/admin_internal_config.go @@ -196,6 +196,21 @@ func (c *adminInternalClient) SensitiveWords(tenantCode string) (*types.Sensitiv return &result, nil } +func (c *adminInternalClient) SecurityPolicy(tenantCode string) (*types.SecurityPolicy, error) { + var result types.SecurityPolicy + path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/security-policy" + if err := c.getJSON(path, nil, &result); err != nil { + return nil, err + } + if result.FriendDisabledPolicy == nil { + result.FriendDisabledPolicy = map[string]any{} + } + if result.CurfewRules == nil { + result.CurfewRules = []*types.MessageCurfewRule{} + } + return &result, nil +} + func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) { if !id.IsValid() { return nil, types.ErrMalformed diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index abe01e2..a7e9a4f 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -857,6 +857,9 @@ func createIMUserProductTables(tx *sql.Tx) error { region VARCHAR(128) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', public_account VARCHAR(64) NOT NULL DEFAULT '', + friend_add_enabled TINYINT NOT NULL DEFAULT 1, + friend_add_disabled_reason VARCHAR(255) NOT NULL DEFAULT '', + friend_add_policy_version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index dc6622c..2e159a9 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -3192,7 +3192,7 @@ func (a *adapter) ensureUserProfile(ctx context.Context, uid t.Uid) error { if err != nil { return err } - if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); err != nil { + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account,friend_add_enabled,friend_add_policy_version) VALUES(?,?,?,?,?)`, profileID, decodeUID(uid), uid.UserId(), true, 1); err != nil { return err } return nil @@ -3885,35 +3885,41 @@ func (a *adapter) feedbackByIdempotency(ctx context.Context, uid t.Uid, idempote func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfile, error) { row := struct { - UserID int64 `db:"user_id"` - AvatarFileID string `db:"avatar_file_id"` - Nickname string - BirthDate sql.NullTime `db:"birth_date"` - Signature string - Gender string - Region string - Email string - PublicAccount string `db:"public_account"` - Version int - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + UserID int64 `db:"user_id"` + AvatarFileID string `db:"avatar_file_id"` + Nickname string + BirthDate sql.NullTime `db:"birth_date"` + Signature string + Gender string + Region string + Email string + PublicAccount string `db:"public_account"` + FriendAddEnabled bool `db:"friend_add_enabled"` + FriendAddDisabledReason string `db:"friend_add_disabled_reason"` + FriendAddPolicyVersion int `db:"friend_add_policy_version"` + Version int + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` }{} if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,avatar_file_id,nickname,birth_date,signature,gender,region,email, - public_account,version,created_at,updated_at FROM im_user_profiles WHERE user_id=?`, decodeUID(uid)); err != nil { + public_account,friend_add_enabled,friend_add_disabled_reason,friend_add_policy_version,version,created_at,updated_at FROM im_user_profiles WHERE user_id=?`, decodeUID(uid)); err != nil { return nil, err } profile := &t.UserProfile{ - UserID: store.EncodeUid(row.UserID), - AvatarFileID: row.AvatarFileID, - Nickname: row.Nickname, - Signature: row.Signature, - Gender: row.Gender, - Region: row.Region, - Email: row.Email, - PublicAccount: row.PublicAccount, - Version: row.Version, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + UserID: store.EncodeUid(row.UserID), + AvatarFileID: row.AvatarFileID, + Nickname: row.Nickname, + Signature: row.Signature, + Gender: row.Gender, + Region: row.Region, + Email: row.Email, + PublicAccount: row.PublicAccount, + FriendAddEnabled: row.FriendAddEnabled, + FriendAddDisabledReason: row.FriendAddDisabledReason, + FriendAddPolicyVersion: row.FriendAddPolicyVersion, + Version: row.Version, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } if row.BirthDate.Valid { profile.BirthDate = row.BirthDate.Time.Format("2006-01-02") diff --git a/server/db/mysql/user_identity_schema.go b/server/db/mysql/user_identity_schema.go index 1a2ce9d..6aca717 100644 --- a/server/db/mysql/user_identity_schema.go +++ b/server/db/mysql/user_identity_schema.go @@ -65,7 +65,11 @@ func ensureUserIdentitySchema(db *sqlx.DB) error { avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',nickname VARCHAR(64) NOT NULL DEFAULT '', birth_date DATE,signature VARCHAR(256) NOT NULL DEFAULT '',gender VARCHAR(16) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',email VARCHAR(128) NOT NULL DEFAULT '', - public_account VARCHAR(64) NOT NULL DEFAULT '',version INT NOT NULL DEFAULT 1, + public_account VARCHAR(64) NOT NULL DEFAULT '', + friend_add_enabled TINYINT NOT NULL DEFAULT 1, + friend_add_disabled_reason VARCHAR(255) NOT NULL DEFAULT '', + friend_add_policy_version INT NOT NULL DEFAULT 1, + version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id),UNIQUE INDEX uk_im_user_profiles_user(`+profileUniqueKey+`))`); err != nil { @@ -99,6 +103,27 @@ func ensureUserIdentitySchema(db *sqlx.DB) error { return err } } + if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "friend_add_enabled"); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN friend_add_enabled TINYINT NOT NULL DEFAULT 1 AFTER public_account"); err != nil { + return err + } + } + if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "friend_add_disabled_reason"); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN friend_add_disabled_reason VARCHAR(255) NOT NULL DEFAULT '' AFTER friend_add_enabled"); err != nil { + return err + } + } + if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "friend_add_policy_version"); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN friend_add_policy_version INT NOT NULL DEFAULT 1 AFTER friend_add_disabled_reason"); err != nil { + return err + } + } } if err = backfillUserPhones(ctx, conn, usersScoped); err != nil { diff --git a/server/im_http.go b/server/im_http.go index 572d516..05f51f8 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -3635,6 +3635,10 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil) return } + if decision := imFriendAddPolicyDecision(principal.TenantID, "", principal.Uid, source); !decision.Allowed { + writeIMPolicyDecision(wrt, req, decision) + return + } privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target) if err != nil { writeIMRelationError(wrt, req, err) @@ -4048,10 +4052,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin return } remarks := groupMemberRemarkMap(principal.TenantID, principal.Uid, members) + hideFriendEntrypoint := !imFriendGlobalDisabledDecision(principal.TenantID, "", "group").Allowed items := make([]map[string]any, 0, limit) for _, member := range members { access := groupMemberAccess(member) item := groupBusinessMemberPayload(member, remarks[member.UserID]) + if hideFriendEntrypoint && member.UserID != principal.Uid { + maskIMGroupMemberProfileEntrypoint(item) + } if eligibleRole != "" { eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole) item["eligible"] = eligible @@ -5467,6 +5475,12 @@ func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) return } + if memberID != principal.Uid { + if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "group"); !decision.Allowed { + writeIMPolicyDecision(wrt, req, decision) + return + } + } selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) @@ -5519,6 +5533,10 @@ func serveIMMeQRCodes(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"}) return } + if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed { + writeIMPolicyDecision(wrt, req, decision) + return + } expiresIn := body.ExpiresInSeconds if expiresIn <= 0 { expiresIn = imQRCodeDefaultExpiresInSeconds @@ -5624,6 +5642,10 @@ func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed { + writeIMPolicyDecision(wrt, req, decision) + return + } canAdd := ctx.Relationship == types.RelationshipNone && imPrivacyAllowsAdd(privacy, "qr") action := imRelationshipAction(ctx.Relationship) if ctx.Relationship == types.RelationshipNone && !canAdd { @@ -5665,8 +5687,14 @@ func serveIMUserSearch(wrt http.ResponseWriter, req *http.Request) { return } payload := make([]map[string]any, 0, len(items)) + friendSearchAllowed := imFriendGlobalDisabledDecision(principal.TenantID, "", "search").Allowed for _, item := range items { - payload = append(payload, userSearchItemPayload(item)) + data := userSearchItemPayload(item) + if !friendSearchAllowed && item.Relationship == types.RelationshipNone { + data["can_add"] = false + data["action"] = "none" + } + payload = append(payload, data) } writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } @@ -5738,6 +5766,12 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier s writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil) return } + if ctx.Relationship == types.RelationshipNone { + if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "profile"); !decision.Allowed { + writeIMPolicyDecision(wrt, req, decision) + return + } + } writeIMSuccess(wrt, req, userProfilePayload(user, ctx)) } @@ -5795,6 +5829,10 @@ func serveIMUserQRCodes(wrt http.ResponseWriter, req *http.Request, identifier s writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } + if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed { + writeIMPolicyDecision(wrt, req, decision) + return + } } expiresIn := body.ExpiresInSeconds if expiresIn <= 0 { @@ -6932,6 +6970,14 @@ func groupBusinessMemberPayload(member types.GroupMember, remark string) map[str return data } +func maskIMGroupMemberProfileEntrypoint(data map[string]any) { + for _, key := range []string{"avatar_file_id", "public_account", "signature", "gender", "region"} { + delete(data, key) + } + data["can_add"] = false + data["action"] = "none" +} + func copyUserNickname(data map[string]any, public any) { var fields map[string]any if value, ok := public.(map[string]any); ok { diff --git a/server/im_security_policy_runtime.go b/server/im_security_policy_runtime.go new file mode 100644 index 0000000..55b1098 --- /dev/null +++ b/server/im_security_policy_runtime.go @@ -0,0 +1,330 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +const ( + imReasonFriendAddDisabled = "FRIEND_ADD_DISABLED" + imReasonMessageCurfew = "MESSAGE_CURFEW_ACTIVE" +) + +type imPolicyDecision struct { + Allowed bool + Reason string + Message string +} + +func imAllowDecision() imPolicyDecision { + return imPolicyDecision{Allowed: true} +} + +func imDenyDecision(reason, message string) imPolicyDecision { + return imPolicyDecision{Allowed: false, Reason: reason, Message: strings.TrimSpace(message)} +} + +func imTenantCodeForPolicy(tenantID types.TenantID) (string, error) { + tenant, err := store.Tenants.GetByID(tenantID) + if err != nil || tenant == nil { + return "", err + } + return strings.TrimSpace(tenant.Code), nil +} + +func imSecurityPolicy(tenantID types.TenantID, tenantCode string) (*types.SecurityPolicy, error) { + tenantCode = strings.TrimSpace(tenantCode) + if tenantCode == "" { + code, err := imTenantCodeForPolicy(tenantID) + if err != nil { + return nil, err + } + tenantCode = code + } + if tenantCode == "" { + return nil, types.ErrNotFound + } + policy, err := store.ModerationConfig.SecurityPolicy(tenantCode) + if err != nil { + return nil, err + } + return policy, nil +} + +func imFriendAddPolicyDecision(tenantID types.TenantID, tenantCode string, actor types.Uid, source string) imPolicyDecision { + policy, err := imSecurityPolicy(tenantID, tenantCode) + if err != nil { + if !errors.Is(err, types.ErrUnsupported) && !errors.Is(err, types.ErrNotFound) { + logs.Warn.Printf("friend add policy lookup failed: tenant=%d code=%s user=%s err=%v", tenantID, tenantCode, actor.UserId(), err) + } + return imAllowDecision() + } + if policy != nil && !policy.GlobalFriendEnabled { + entry := imNormalizeFriendAddEntry(source) + if imFriendEntryBlocked(policy.FriendDisabledPolicy, entry) { + return imDenyDecision(imReasonFriendAddDisabled, imPolicyString(policy.FriendDisabledPolicy, "message", "当前时段暂不允许添加好友")) + } + } + profile, err := store.Profiles.Get(tenantID, actor) + if err != nil { + logs.Warn.Printf("friend add user policy lookup failed: tenant=%d user=%s err=%v", tenantID, actor.UserId(), err) + return imAllowDecision() + } + if profile != nil && !profile.FriendAddEnabled { + message := strings.TrimSpace(profile.FriendAddDisabledReason) + if message == "" { + message = "该用户暂不允许发起加好友" + } + return imDenyDecision(imReasonFriendAddDisabled, message) + } + return imAllowDecision() +} + +func imFriendGlobalDisabledDecision(tenantID types.TenantID, tenantCode, source string) imPolicyDecision { + policy, err := imSecurityPolicy(tenantID, tenantCode) + if err != nil { + if !errors.Is(err, types.ErrUnsupported) && !errors.Is(err, types.ErrNotFound) { + logs.Warn.Printf("friend global policy lookup failed: tenant=%d code=%s source=%s err=%v", tenantID, tenantCode, source, err) + } + return imAllowDecision() + } + if policy != nil && !policy.GlobalFriendEnabled && imFriendEntryBlocked(policy.FriendDisabledPolicy, imNormalizeFriendAddEntry(source)) { + return imDenyDecision(imReasonFriendAddDisabled, imPolicyString(policy.FriendDisabledPolicy, "message", "当前时段暂不允许添加好友")) + } + return imAllowDecision() +} + +func imNormalizeFriendAddEntry(source string) string { + switch strings.TrimSpace(source) { + case "qr": + return "qrcode" + case "group": + return "group_member" + case "profile", "profile_card": + return "profile_card" + case "invite", "invite_auto_add": + return "invite_auto_add" + case "recommend", "recommendation": + return "recommendation" + default: + return "search" + } +} + +func imFriendEntryBlocked(policy map[string]any, entry string) bool { + if len(policy) == 0 { + return true + } + raw, ok := policy["blocked_entries"] + if !ok { + return true + } + entries := imAnyStringSlice(raw) + if len(entries) == 0 { + return true + } + entry = strings.TrimSpace(entry) + for _, item := range entries { + if strings.TrimSpace(item) == entry { + return true + } + } + return false +} + +func imMessageCurfewDecision(tenantID types.TenantID, tenantCode string, actor types.Uid, topic string, cat types.TopicCat) imPolicyDecision { + if cat != types.TopicCatP2P { + return imAllowDecision() + } + policy, err := imSecurityPolicy(tenantID, tenantCode) + if err != nil { + if !errors.Is(err, types.ErrUnsupported) && !errors.Is(err, types.ErrNotFound) { + logs.Warn.Printf("message curfew policy lookup failed: tenant=%d code=%s user=%s topic=%s err=%v", tenantID, tenantCode, actor.UserId(), topic, err) + } + return imAllowDecision() + } + if policy == nil || !policy.MessageCurfewEnabled { + return imAllowDecision() + } + scope := "private_message" + if cat == types.TopicCatGrp { + scope = "group_message" + } + for _, rule := range policy.CurfewRules { + if rule == nil || rule.Status != 1 || !imCurfewRuleScopeMatches(rule.Scope, scope) { + continue + } + if imCurfewWhitelisted(rule, actor, topic, cat) { + continue + } + if imCurfewRuleActive(rule, time.Now()) { + message := strings.TrimSpace(rule.Message) + if message == "" { + message = "当前时段暂不能发送消息" + } + return imDenyDecision(imReasonMessageCurfew, message) + } + } + return imAllowDecision() +} + +func imCurfewRuleScopeMatches(ruleScope, scope string) bool { + ruleScope = strings.TrimSpace(ruleScope) + return ruleScope == "" || ruleScope == "all" || ruleScope == scope +} + +func imCurfewWhitelisted(rule *types.MessageCurfewRule, actor types.Uid, topic string, cat types.TopicCat) bool { + uidValues := map[string]bool{ + actor.UserId(): true, + actor.String(): true, + } + if raw := store.DecodeUid(actor); raw > 0 { + uidValues[strconv.FormatInt(raw, 10)] = true + } + for _, item := range rule.WhitelistUserIds { + if uidValues[strings.TrimSpace(item)] { + return true + } + } + if cat == types.TopicCatGrp { + for _, item := range rule.WhitelistGroupIds { + if strings.TrimSpace(item) == topic { + return true + } + } + } + return false +} + +func imCurfewRuleActive(rule *types.MessageCurfewRule, now time.Time) bool { + loc := time.Local + if tz := strings.TrimSpace(rule.Timezone); tz != "" { + if loaded, err := time.LoadLocation(tz); err == nil { + loc = loaded + } + } + localNow := now.In(loc) + if !imCurfewDateMatches(rule, localNow) { + return false + } + start, okStart := imParseClock(rule.StartTime) + end, okEnd := imParseClock(rule.EndTime) + if !okStart || !okEnd { + return false + } + minute := localNow.Hour()*60 + localNow.Minute() + if start == end { + return true + } + if start < end { + return minute >= start && minute < end + } + return minute >= start || minute < end +} + +func imCurfewDateMatches(rule *types.MessageCurfewRule, now time.Time) bool { + switch strings.TrimSpace(rule.DateType) { + case "", "daily": + return true + case "weekday": + return now.Weekday() >= time.Monday && now.Weekday() <= time.Friday + case "weekend": + return now.Weekday() == time.Saturday || now.Weekday() == time.Sunday + case "custom": + date := now.Format("2006-01-02") + for _, item := range rule.CustomDates { + if strings.TrimSpace(item) == date { + return true + } + } + return false + default: + if len(rule.Weekdays) == 0 { + return false + } + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + for _, item := range rule.Weekdays { + if item == weekday { + return true + } + } + return false + } +} + +func imParseClock(value string) (int, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + parts := strings.Split(value, ":") + if len(parts) < 2 { + return 0, false + } + hour, errHour := strconv.Atoi(parts[0]) + minute, errMinute := strconv.Atoi(parts[1]) + if errHour != nil || errMinute != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, false + } + return hour*60 + minute, true +} + +func imPolicyString(policy map[string]any, key, fallback string) string { + if value := strings.TrimSpace(fmt.Sprint(policy[key])); value != "" && value != "" { + return value + } + return fallback +} + +func imAnyStringSlice(raw any) []string { + switch value := raw.(type) { + case []string: + return value + case []any: + out := make([]string, 0, len(value)) + for _, item := range value { + if text := strings.TrimSpace(fmt.Sprint(item)); text != "" && text != "" { + out = append(out, text) + } + } + return out + default: + return nil + } +} + +func writeIMPolicyDecision(wrt http.ResponseWriter, req *http.Request, decision imPolicyDecision) { + message := strings.TrimSpace(decision.Message) + if message == "" { + message = "permission denied" + } + reason := strings.TrimSpace(decision.Reason) + if reason == "" { + reason = imReasonPermissionDenied + } + writeIMError(wrt, req, http.StatusForbidden, 30005, reason, message, map[string]any{ + "reason": reason, + "message": message, + }) +} + +func imCurfewPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *ServerComMessage { + reply := ErrPolicyReply(msg, types.TimeNow()) + reply.Ctrl.Text = "message curfew active" + reply.Ctrl.Params = map[string]any{ + "reason": decision.Reason, + "message": decision.Message, + } + return reply +} diff --git a/server/internal_admin_query_resources.go b/server/internal_admin_query_resources.go index 3dc3002..e5451b7 100644 --- a/server/internal_admin_query_resources.go +++ b/server/internal_admin_query_resources.go @@ -239,7 +239,7 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) { func usersInternalQuerySpec() *internalQuerySpec { return &internalQuerySpec{ Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.account_no AS display_no,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted, - p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id, + p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,COALESCE(p.friend_add_enabled,1) AS friend_add_enabled, COALESCE( NULLIF(avatar.location,''), NULLIF(CASE WHEN p.avatar_file_id REGEXP '^(https?://|data:)' THEN p.avatar_file_id ELSE '' END,''), diff --git a/server/kafka_config_events.go b/server/kafka_config_events.go index a479ae3..1969c10 100644 --- a/server/kafka_config_events.go +++ b/server/kafka_config_events.go @@ -99,6 +99,7 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error { store.InvalidateAppReleaseConfigCache() case configEventSecurityPolicyUpdated: store.InvalidateModerationConfigCache(tenantCode, false, true, false) + store.InvalidateSecurityPolicyCache(tenantCode) case configEventSensitiveWordsUpdated: store.InvalidateModerationConfigCache(tenantCode, false, false, true) default: @@ -107,6 +108,7 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error { store.InvalidateRegistrationConfigCache() store.InvalidateAppReleaseConfigCache() store.InvalidateModerationConfigCache(tenantCode, true, true, true) + store.InvalidateSecurityPolicyCache(tenantCode) } logs.Info.Printf("kafka config event applied: event=%s type=%s tenant=%s group=%s", envelope.EventID, envelope.EventType, tenantCode, payload.Group) return nil diff --git a/server/moderation/moderation_test.go b/server/moderation/moderation_test.go index c0f3ec2..15a6d99 100644 --- a/server/moderation/moderation_test.go +++ b/server/moderation/moderation_test.go @@ -17,8 +17,9 @@ type testConfig struct { func (c testConfig) Provider() (*types.ModerationProviderConfig, error) { return &types.ModerationProviderConfig{}, nil } -func (c testConfig) Policy(string) (*types.ModerationPolicy, error) { return c.policy, nil } -func (c testConfig) Words(string) (*types.SensitiveWordSet, error) { return c.words, nil } +func (c testConfig) Policy(string) (*types.ModerationPolicy, error) { return c.policy, nil } +func (c testConfig) Words(string) (*types.SensitiveWordSet, error) { return c.words, nil } +func (c testConfig) SecurityPolicy(string) (*types.SecurityPolicy, error) { return nil, nil } type testAudit struct{} diff --git a/server/store/config_cache.go b/server/store/config_cache.go index 423639c..88ab695 100644 --- a/server/store/config_cache.go +++ b/server/store/config_cache.go @@ -64,9 +64,18 @@ var ( moderationProviderCache = newConfigCache[*types.ModerationProviderConfig]() moderationPolicyCache = newConfigCache[*types.ModerationPolicy]() moderationWordsCache = newConfigCache[*types.SensitiveWordSet]() + securityPolicyCache = newConfigCache[*types.SecurityPolicy]() thirdPartyConfigAllKeys = "*" ) +func InvalidateSecurityPolicyCache(tenantCode string) { + if strings.TrimSpace(tenantCode) == "" { + securityPolicyCache.clear() + return + } + securityPolicyCache.delete(strings.TrimSpace(tenantCode)) +} + func InvalidateModerationConfigCache(tenantCode string, provider, policy, words bool) { if provider { moderationProviderCache.clear() diff --git a/server/store/store.go b/server/store/store.go index 8e67259..96dea5c 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -173,6 +173,7 @@ type AdminInternalConfigProvider interface { ModerationProvider() (*types.ModerationProviderConfig, error) ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error) SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error) + SecurityPolicy(tenantCode string) (*types.SecurityPolicy, error) } var adminInternalConfigProvider AdminInternalConfigProvider @@ -185,6 +186,7 @@ type ModerationConfigPersistenceInterface interface { Provider() (*types.ModerationProviderConfig, error) Policy(tenantCode string) (*types.ModerationPolicy, error) Words(tenantCode string) (*types.SensitiveWordSet, error) + SecurityPolicy(tenantCode string) (*types.SecurityPolicy, error) } type moderationConfigMapper struct{} @@ -235,6 +237,21 @@ func (moderationConfigMapper) Words(tenantCode string) (*types.SensitiveWordSet, return value, err } +func (moderationConfigMapper) SecurityPolicy(tenantCode string) (*types.SecurityPolicy, error) { + tenantCode = strings.TrimSpace(tenantCode) + if value, ok := securityPolicyCache.get(tenantCode); ok { + return value, nil + } + if adminInternalConfigProvider == nil { + return nil, types.ErrUnsupported + } + value, err := adminInternalConfigProvider.SecurityPolicy(tenantCode) + if err == nil && value != nil { + securityPolicyCache.set(tenantCode, value) + } + return value, err +} + // RegistrationConfigPersistenceInterface exposes global registration policy. type RegistrationConfigPersistenceInterface interface { SMSEnabled() (bool, error) diff --git a/server/store/types/profile.go b/server/store/types/profile.go index 50f377c..dda22bf 100644 --- a/server/store/types/profile.go +++ b/server/store/types/profile.go @@ -3,18 +3,21 @@ package types import "time" type UserProfile struct { - UserID Uid - AvatarFileID string - Nickname string - BirthDate string - Signature string - Gender string - Region string - Email string - PublicAccount string - Version int - CreatedAt time.Time - UpdatedAt time.Time + UserID Uid + AvatarFileID string + Nickname string + BirthDate string + Signature string + Gender string + Region string + Email string + PublicAccount string + FriendAddEnabled bool + FriendAddDisabledReason string + FriendAddPolicyVersion int + Version int + CreatedAt time.Time + UpdatedAt time.Time } type UserProfilePatch struct { diff --git a/server/store/types/security_policy.go b/server/store/types/security_policy.go new file mode 100644 index 0000000..f019a19 --- /dev/null +++ b/server/store/types/security_policy.go @@ -0,0 +1,28 @@ +package types + +type SecurityPolicy struct { + TenantID TenantID `json:"tenant_id"` + TenantCode string `json:"tenant_code"` + PolicyVersion uint `json:"policy_version"` + GlobalFriendEnabled bool `json:"global_friend_enabled"` + FriendDisabledPolicy map[string]any `json:"friend_disabled_policy"` + MessageCurfewEnabled bool `json:"message_curfew_enabled"` + CurfewRules []*MessageCurfewRule `json:"curfew_rules"` +} + +type MessageCurfewRule struct { + RuleCode string `json:"rule_code"` + RuleName string `json:"rule_name"` + DateType string `json:"date_type"` + CustomDates []string `json:"custom_dates"` + Weekdays []int `json:"weekdays"` + Timezone string `json:"timezone"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Scope string `json:"scope"` + WhitelistUserIds []string `json:"whitelist_user_ids"` + WhitelistGroupIds []string `json:"whitelist_group_ids"` + Message string `json:"message"` + Status int `json:"status"` + Sort int `json:"sort"` +} diff --git a/server/topic.go b/server/topic.go index b7596f6..8c7c1f3 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1496,6 +1496,10 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) { msg.Pub.Content = content } if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && !isCall { + if decision := imMessageCurfewDecision(t.tenantID, msg.sess.tenantCode, asUid, t.name, t.cat); !decision.Allowed { + msg.sess.queueOut(imCurfewPolicyReply(msg, decision)) + return + } scene := moderation.ScenePrivateMessage if t.cat == types.TopicCatGrp { scene = moderation.SceneGroupMessage