Merge branch 'dev' of https://git.118im.com/im_team/im_server into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,''),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user