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" imReasonYouthRestricted = "YOUTH_MODE_RESTRICTED" imYouthActionMessage = "message" imYouthActionCall = "call" ) type imPolicyDecision struct { Allowed bool Reason string Message string Details map[string]any } func imAllowDecision() imPolicyDecision { return imPolicyDecision{Allowed: true} } 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 { 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 } 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 { 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 } 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 }