This commit is contained in:
Austin
2026-09-18 10:56:00 +08:00
14 changed files with 531 additions and 42 deletions
+15
View File
@@ -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
+3
View File
@@ -864,6 +864,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),
+31 -25
View File
@@ -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")
+26 -1
View File
@@ -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 {
+47 -1
View File
@@ -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 {
+330
View File
@@ -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 != "<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
}
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
}
+1 -1
View File
@@ -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,''),
+2
View File
@@ -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
+3 -2
View File
@@ -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{}
+9
View File
@@ -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()
+17
View File
@@ -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)
+15 -12
View File
@@ -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 {
+28
View File
@@ -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"`
}
+4
View File
@@ -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