423 lines
12 KiB
Go
423 lines
12 KiB
Go
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()
|
|
}
|
|
// Message content filtering is enforced by moderation below the policy
|
|
// check. Youth mode currently has no message-level deny rule, so avoid a
|
|
// database lookup on every ordinary publish.
|
|
if strings.TrimSpace(action) == imYouthActionMessage {
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
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 {
|
|
// 宵禁只作用于私聊与群聊两类消息话题;me/fnd 等非消息话题直接放行。
|
|
if cat != types.TopicCatP2P && cat != types.TopicCatGrp {
|
|
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 != "<nil>" {
|
|
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 != "<nil>" {
|
|
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
|
|
}
|