fix: resolve P1 backend consistency issues

This commit is contained in:
richjohnrambo
2026-09-08 16:23:21 +08:00
parent 06945a4050
commit 0b3c0b1c78
13 changed files with 587 additions and 57 deletions
+4
View File
@@ -167,6 +167,10 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_events_outbox_event(event_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_events_outbox_event(event_id),
INDEX idx_im_group_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_group_events_outbox_topic(topic, id DESC) INDEX idx_im_group_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_group_events_outbox_topic(topic, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`UPDATE im_topics SET public=JSON_SET(public, '$.fn', JSON_UNQUOTE(JSON_EXTRACT(public, '$.name')))
WHERE name LIKE 'grp%' AND JSON_VALID(public)
AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(public, '$.fn')), '') = ''
AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(public, '$.name')), '') <> ''`,
} }
for _, statement := range statements { for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil { if _, err := exec.Exec(statement); err != nil {
+5 -2
View File
@@ -22,8 +22,8 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
if err := ensureIMGroupBusinessTables(exec); err != nil { if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(exec.statements) != 13 { if len(exec.statements) != 14 {
t.Fatalf("created %d group tables, want 13", len(exec.statements)) t.Fatalf("executed %d group schema statements, want 14", len(exec.statements))
} }
for _, statement := range exec.statements { for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") { if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
@@ -40,4 +40,7 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
t.Fatalf("group schema is missing snowflake primary-key definition %q", expected) t.Fatalf("group schema is missing snowflake primary-key definition %q", expected)
} }
} }
if !strings.Contains(joined, "json_set(public, '$.fn'") {
t.Fatal("group schema is missing historical public.fn backfill")
}
} }
+12 -3
View File
@@ -7051,15 +7051,17 @@ func (a *adapter) TenantTopicDraftPut(id t.TenantID, uid t.Uid, topic, content s
return nil, err return nil, err
} }
now := t.TimeNow() now := t.TimeNow()
_, err = a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts res, err := a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts
(id,user_id,topic,content,entities_json,version,created_at,updated_at) (id,user_id,topic,content,entities_json,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE content=VALUES(content),entities_json=VALUES(entities_json), ON DUPLICATE KEY UPDATE id=id`,
version=version+1,updated_at=VALUES(updated_at)`,
draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now) draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic)) return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
} }
res, err := a.db.ExecContext(ctx, `UPDATE im_topic_drafts res, err := a.db.ExecContext(ctx, `UPDATE im_topic_drafts
@@ -9210,6 +9212,13 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
row.HandledAt = &handledAt row.HandledAt = &handledAt
row.UpdatedAt = handledAt row.UpdatedAt = handledAt
} }
if _, err = tx.ExecContext(ctx, `UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=?
WHERE request_id<>? AND status=? AND
((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?))`,
t.FriendRequestExpired, handledAt, handledAt, requestID, t.FriendRequestPending,
decodeUID(applicant), decodeUID(target), decodeUID(target), decodeUID(applicant)); err != nil {
return nil, err
}
for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} { for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} {
friendshipID, idErr := nextSnowflakeID() friendshipID, idErr := nextSnowflakeID()
if idErr != nil { if idErr != nil {
+30 -2
View File
@@ -936,11 +936,16 @@ func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.Gr
} }
a.enrichGroupFileFromUpload(id, file) a.enrichGroupFileFromUpload(id, file)
now := t.TimeNow() now := t.TimeNow()
ctx, cancel := a.getContext() ctx, cancel := a.getContextForTx()
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_files tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_files
(group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,idempotency_key,created_at,updated_at) (group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,idempotency_key,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
@@ -954,6 +959,29 @@ func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.Gr
if err != nil && !isDupe(err) { if err != nil && !isDupe(err) {
return nil, err return nil, err
} }
uploadID := strings.TrimSpace(file.FileID)
if dot := strings.IndexByte(uploadID, '.'); dot > 0 {
uploadID = uploadID[:dot]
}
if fid := t.ParseUid(uploadID); !fid.IsZero() {
if a.tenantScopedDB {
_, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(createdat,fileid,topic)
SELECT ?,?,? WHERE NOT EXISTS (
SELECT 1 FROM im_filemsglinks WHERE fileid=? AND topic=?)`,
now, decodeUID(fid), file.Topic, decodeUID(fid), file.Topic)
} else {
_, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(tenant_id,createdat,fileid,topic)
SELECT ?,?,?,? WHERE NOT EXISTS (
SELECT 1 FROM im_filemsglinks WHERE tenant_id=? AND fileid=? AND topic=?)`,
id, now, decodeUID(fid), file.Topic, id, decodeUID(fid), file.Topic)
}
if err != nil {
return nil, err
}
}
if err = tx.Commit(); err != nil {
return nil, err
}
created, err := a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND file_id=?", file.Topic, file.FileID) created, err := a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND file_id=?", file.Topic, file.FileID)
if err != nil { if err != nil {
return nil, err return nil, err
+118 -9
View File
@@ -62,7 +62,7 @@ const (
imVerificationResendAfterSeconds = 60 imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5 imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour imVerificationCreateRateWindow = time.Hour
imSensitiveChallengeRateLimitEnabled = false imSensitiveChallengeRateLimitEnabled = true
imActionTokenExpiresInSeconds = 300 imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5 imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300 imIndependentPasswordLockSeconds = 300
@@ -1503,7 +1503,7 @@ func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
return return
} }
action := strings.TrimSpace(body.Scene) action := strings.TrimSpace(body.Scene)
if action == "" || len(action) > 64 || strings.TrimSpace(body.Channel) != "sms" { if !validIMSecurityChallengeScene(action) || strings.TrimSpace(body.Channel) != "sms" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return return
} }
@@ -2530,7 +2530,8 @@ func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meeting
meetingParticipantUserIDs(participants), "会议提醒", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)}) meetingParticipantUserIDs(participants), "会议提醒", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)})
writeIMSuccess(wrt, req, map[string]any{ writeIMSuccess(wrt, req, map[string]any{
"meeting_id": meetingID, "meeting_id": meetingID,
"reminded": meetingParticipantPayloads(participants), "reminded": meetingParticipantPayloadsWithSummaries(participants,
meetingUserSummaries(principal.TenantID, []types.Meeting{{Participants: participants}})),
}) })
} }
@@ -3706,9 +3707,10 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs)) results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs))
comment := strings.TrimSpace(body.Comment) comment := strings.TrimSpace(body.Comment)
for _, target := range body.TargetTopics { for _, target := range body.TargetTopics {
target = strings.TrimSpace(target) rawTarget := strings.TrimSpace(target)
if target == "" { target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": imReasonMalformedRequest}) if target == "" || !targetOK {
results = append(results, map[string]any{"target_topic": rawTarget, "status": "failed", "reason": imReasonMalformedRequest})
continue continue
} }
if err := requireTopicWriter(principal, target); err != nil { if err := requireTopicWriter(principal, target); err != nil {
@@ -3733,6 +3735,10 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
}, },
Content: favoriteForwardContent(item, comment), Content: favoriteForwardContent(item, comment),
} }
if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
}
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true) saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true)
if err != nil { if err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
@@ -4402,6 +4408,60 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return return
} }
relation, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, target, principal.Uid.P2PName(target))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if relation == nil || relation.Relationship != types.RelationshipNone {
reason := "FRIEND_REQUEST_CONFLICT"
if relation != nil && (relation.Relationship == types.RelationshipBlockedByMe || relation.Relationship == types.RelationshipBlockedByPeer) {
reason = imReasonPermissionDenied
}
writeIMError(wrt, req, http.StatusConflict, 30006, reason, "friend request is not allowed for current relationship", nil)
return
}
source := strings.TrimSpace(body.Source)
sourceTopic := strings.TrimSpace(body.SourceTopic)
switch source {
case "search", "qr":
if sourceTopic != "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "source_topic is only valid for group source", nil)
return
}
case "group":
if types.GetTopicCat(sourceTopic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "valid group source_topic is required", nil)
return
}
requesterSub, requesterErr := store.Subs.Get(principal.TenantID, sourceTopic, principal.Uid, false)
targetSub, targetErr := store.Subs.Get(principal.TenantID, sourceTopic, target, false)
if requesterErr != nil || targetErr != nil {
if requesterErr != nil {
writeIMRelationError(wrt, req, requesterErr)
} else {
writeIMRelationError(wrt, req, targetErr)
}
return
}
if requesterSub == nil || targetSub == nil ||
!(requesterSub.ModeGiven & requesterSub.ModeWant).IsJoiner() || !(targetSub.ModeGiven & targetSub.ModeWant).IsJoiner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "group membership required", nil)
return
}
settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, sourceTopic)
if settingsErr != nil {
writeIMRelationError(wrt, req, settingsErr)
return
}
if settings == nil || !settings.MemberPrivateChat {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member private chat is disabled", nil)
return
}
default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target) privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target)
if err != nil { if err != nil {
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
@@ -4424,8 +4484,8 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
ApplicantID: principal.Uid, ApplicantID: principal.Uid,
TargetID: target, TargetID: target,
Message: message, Message: message,
Source: strings.TrimSpace(body.Source), Source: source,
SourceTopic: strings.TrimSpace(body.SourceTopic), SourceTopic: sourceTopic,
Status: types.FriendRequestPending, Status: types.FriendRequestPending,
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
ExpiresAt: now.Add(7 * 24 * time.Hour), ExpiresAt: now.Add(7 * 24 * time.Hour),
@@ -5038,7 +5098,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
} }
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version}); err != nil { if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil {
if errors.Is(err, types.ErrNotFound) { if errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId()) logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId())
dispatchIMGroupEvents(principal.TenantID) dispatchIMGroupEvents(principal.TenantID)
@@ -5129,6 +5189,17 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
return return
} }
access := selfSub.ModeGiven & selfSub.ModeWant access := selfSub.ModeGiven & selfSub.ModeWant
if !access.IsAdmin() {
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if settings == nil || !settings.MemberInvite {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member invitation is disabled", nil)
return
}
}
if !access.IsSharer() && !access.IsAdmin() { if !access.IsSharer() && !access.IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return return
@@ -5232,6 +5303,17 @@ func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail s
"has_more": nextCursor != "", "has_more": nextCursor != "",
}) })
case tail == "files" && req.Method == http.MethodPost: case tail == "files" && req.Method == http.MethodPost:
if !access.IsAdmin() {
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if settings == nil || !settings.MemberUploadFiles {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member file upload is disabled", nil)
return
}
}
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok { if !ok {
return return
@@ -5344,6 +5426,18 @@ func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return return
} }
if !settings.JoinApproval {
now := time.Now().UTC().Round(time.Millisecond)
joined, joinErr := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, principal.Uid,
"", strings.TrimSpace(req.Header.Get("Idempotency-Key")), groupMemberPrivate("", false, false, 1, now), now)
if joinErr != nil {
writeIMRelationError(wrt, req, joinErr)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "joined", "joined": joined, "source": "open_group"})
return
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil { if err != nil {
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
@@ -7353,7 +7447,9 @@ func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSett
payload["description"] = settings.Description payload["description"] = settings.Description
payload["owner"] = settings.OwnerUserID.UserId() payload["owner"] = settings.OwnerUserID.UserId()
payload["member_count"] = settings.MemberCount payload["member_count"] = settings.MemberCount
if _, exists := payload["version"]; !exists {
payload["version"] = settings.Version payload["version"] = settings.Version
}
return payload return payload
} }
@@ -8682,6 +8778,7 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any {
"field_rules": map[string]any{ "field_rules": map[string]any{
"nickname": map[string]any{"min_length": 1, "max_length": 32}, "nickname": map[string]any{"min_length": 1, "max_length": 32},
"signature": map[string]any{"max_length": 120}, "signature": map[string]any{"max_length": 120},
"region": map[string]any{"max_length": 128},
"birth_date": map[string]any{"format": "YYYY-MM-DD"}, "birth_date": map[string]any{"format": "YYYY-MM-DD"},
"gender": []string{"male", "female", "other", ""}, "gender": []string{"male", "female", "other", ""},
"email": map[string]any{"max_length": 128}, "email": map[string]any{"max_length": 128},
@@ -8690,6 +8787,15 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any {
return data return data
} }
func validIMSecurityChallengeScene(scene string) bool {
switch strings.TrimSpace(scene) {
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password":
return true
default:
return false
}
}
func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any { func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any {
return map[string]any{ return map[string]any{
"add_me_methods": settings.AddMeMethods, "add_me_methods": settings.AddMeMethods,
@@ -9437,6 +9543,9 @@ func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool)
if topic == "" { if topic == "" {
return "", false return "", false
} }
if topic == "slf" {
return viewer.SlfName(), !viewer.IsZero()
}
if len(topic) < 3 { if len(topic) < 3 {
return "", false return "", false
} }
+125 -6
View File
@@ -2364,7 +2364,7 @@ func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) {
} }
} }
func TestServeIMSecurityChallengeCreateSkipsRateLimitWhenDisabled(t *testing.T) { func TestServeIMSecurityChallengeCreateEnforcesResendLimit(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42) expectIMBearer(t, ctrl, "token-1", 7, 42)
@@ -2405,11 +2405,26 @@ func TestServeIMSecurityChallengeCreateSkipsRateLimitWhenDisabled(t *testing.T)
serveIMSecurityChallenges(rec, req) serveIMSecurityChallenges(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String())
} }
if verifications.created == nil || verifications.created.Action != "close_youth_mode" { if verifications.created != nil {
t.Fatalf("expected a new challenge while sensitive challenge rate limit is disabled: %#v", verifications.created) t.Fatalf("rate-limited request created a challenge: %#v", verifications.created)
}
}
func TestServeIMSecurityChallengeCreateRejectsUnknownScene(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges",
bytes.NewBufferString(`{"scene":"made_up_action","channel":"sms"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMSecurityChallenges(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
} }
} }
@@ -4868,6 +4883,71 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
} }
} }
func TestServeIMFavoritesListSupportsRefOnlyAttachment(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC)
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7, FavoriteID: "fav_ref", UserID: 42, ItemType: "file",
SourceTopic: "grpAlpha", SourceSeq: 8, Availability: "available", CollectedAt: now,
}}}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
}()
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha",
ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(8)).Return(&types.Message{
TenantID: 7, Topic: "grpAlpha", SeqId: 8, From: types.Uid(42).String(),
Head: types.KVMap{"type": "file"}, Content: map[string]any{
"txt": " ",
"fmt": []any{map[string]any{"at": 0, "len": 1, "key": 0}},
"ent": []any{map[string]any{"tp": "EX", "data": map[string]any{
"mime": "application/pdf", "name": "large.pdf", "ref": "https://oss.example/large.pdf",
}}},
}, ObjHeader: types.ObjHeader{CreatedAt: now},
}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=all&limit=1", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavorites(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if items := data["items"].([]any); len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_ref" {
t.Fatalf("unexpected ref-only favorite response: %#v", data)
}
}
func TestNormalizeIMUserVisibleTopicSelf(t *testing.T) {
uid := types.Uid(42)
got, ok := normalizeIMUserVisibleTopic(uid, "slf")
if !ok || got != uid.SlfName() {
t.Fatalf("normalize slf: got=%q ok=%v want=%q", got, ok, uid.SlfName())
}
}
func TestMergeGroupSettingsPayloadPreservesTopicVersion(t *testing.T) {
payload := map[string]any{"version": 7}
mergeGroupSettingsPayload(payload, &types.GroupSettings{Version: 2})
if payload["version"] != 7 {
t.Fatalf("topic version was overwritten: %#v", payload)
}
}
func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) { func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -6806,7 +6886,7 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
mockStore.EXPECT().GetUidString().Return("request-1") mockStore.EXPECT().GetUidString().Return("request-1")
oldRelations := store.Relations oldRelations := store.Relations
oldProfiles := store.Profiles oldProfiles := store.Profiles
relations := &testRelationsStore{} relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
store.Relations = relations store.Relations = relations
store.Profiles = &testProfilesStore{} store.Profiles = &testProfilesStore{}
defer func() { defer func() {
@@ -6831,6 +6911,45 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
} }
} }
func TestServeIMFriendRequestCreateHonorsGroupPrivateChatSetting(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
oldProfiles := store.Profiles
oldSubs := store.Subs
oldGroups := store.GroupBusiness
store.Relations = &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
store.Profiles = &testProfilesStore{}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{
Topic: "grpAlpha", MemberPrivateChat: false, GroupStatus: types.GroupStatusActive,
}}
defer func() {
store.Relations = oldRelations
store.Profiles = oldProfiles
store.Subs = oldSubs
store.GroupBusiness = oldGroups
}()
memberMode := types.ModeCPublic
for _, uid := range []types.Uid{1, 2} {
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", uid, false).Return(&types.Subscription{
TenantID: 7, Topic: "grpAlpha", User: uid.String(), ModeWant: memberMode, ModeGiven: memberMode,
}, nil)
}
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests",
bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","source":"group","source_topic":"grpAlpha"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "fr-group-1")
rec := httptest.NewRecorder()
serveIMFriendRequests(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
}
func TestServeIMFriendRequestList(t *testing.T) { func TestServeIMFriendRequestList(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
+29
View File
@@ -71,6 +71,7 @@ type groupMessageRuntime interface {
CacheObject(tenantID types.TenantID, namespace, id string, value any) error CacheObject(tenantID types.TenantID, namespace, id string, value any) error
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
DeleteCachedObject(tenantID types.TenantID, namespace, id string) error DeleteCachedObject(tenantID types.TenantID, namespace, id string) error
DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, idPrefix string) error
AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error)
@@ -226,6 +227,13 @@ func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id) return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
} }
func DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, idPrefix string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || idPrefix == "" {
return nil
}
return groupMsgRuntime.DeleteCachedObjectsByPrefix(tenantID, namespace, idPrefix)
}
func CacheHotMessage(tenantID types.TenantID, msg *types.Message) error { func CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 { if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return nil return nil
@@ -788,6 +796,27 @@ func (r *redisGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, n
return r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err() return r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
} }
func (r *redisGroupMessageRuntime) DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, idPrefix string) error {
ctx := context.Background()
pattern := r.objectCacheKey(tenantID, namespace, idPrefix+"*")
var cursor uint64
for {
keys, next, err := r.client.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return err
}
if len(keys) > 0 {
if err = r.client.Del(ctx, keys...).Err(); err != nil {
return err
}
}
cursor = next
if cursor == 0 {
return nil
}
}
}
func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error { func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id) key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background() ctx := context.Background()
@@ -11,6 +11,7 @@ import (
type testGroupMessageRuntime struct { type testGroupMessageRuntime struct {
claims map[string]*groupMessageUniqueRecord claims map[string]*groupMessageUniqueRecord
seqs map[string]int64 seqs map[string]int64
deletedPrefixes []string
} }
func newTestGroupMessageRuntime() *testGroupMessageRuntime { func newTestGroupMessageRuntime() *testGroupMessageRuntime {
@@ -116,6 +117,11 @@ func (r *testGroupMessageRuntime) DeleteCachedObject(types.TenantID, string, str
return nil return nil
} }
func (r *testGroupMessageRuntime) DeleteCachedObjectsByPrefix(_ types.TenantID, _ string, prefix string) error {
r.deletedPrefixes = append(r.deletedPrefixes, prefix)
return nil
}
func (r *testGroupMessageRuntime) AddCachedSetMember(types.TenantID, string, string, string) error { func (r *testGroupMessageRuntime) AddCachedSetMember(types.TenantID, string, string, string) error {
return nil return nil
} }
@@ -176,3 +182,15 @@ func TestMessageRuntimeSeqAndClientMidIdempotency(t *testing.T) {
t.Fatalf("unexpected second seq: seq=%d ready=%v err=%v", seq, ready, err) t.Fatalf("unexpected second seq: seq=%d ready=%v err=%v", seq, ready, err)
} }
} }
func TestInvalidateGroupMemberPagesUsesTopicKeyPrefix(t *testing.T) {
oldRuntime := groupMsgRuntime
runtime := newTestGroupMessageRuntime()
groupMsgRuntime = runtime
t.Cleanup(func() { groupMsgRuntime = oldRuntime })
invalidateGroupMemberPages(7, "grpAlpha")
if len(runtime.deletedPrefixes) != 1 || runtime.deletedPrefixes[0] != "grpAlpha|" {
t.Fatalf("unexpected deleted prefixes: %#v", runtime.deletedPrefixes)
}
}
+68 -24
View File
@@ -1760,6 +1760,16 @@ func GetCachedGroupMemberPage(tenantID types.TenantID, key string) (map[string]a
return nil, false return nil, false
} }
func invalidateGroupMemberPages(tenantID types.TenantID, topic string) {
topic = strings.TrimSpace(topic)
if topic == "" {
return
}
if err := DeleteCachedObjectsByPrefix(tenantID, cacheNamespaceGroupPage, topic+"|"); err != nil {
logs.Warn.Printf("cache group member pages[%s]: delete failed: %v", topic, err)
}
}
func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) { func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
if strings.TrimSpace(topic) == "" || uid.IsZero() { if strings.TrimSpace(topic) == "" || uid.IsZero() {
return nil, types.ErrMalformed return nil, types.ErrMalformed
@@ -1775,6 +1785,7 @@ func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, u
} else { } else {
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid)) _ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid))
} }
invalidateGroupMemberPages(tenantID, topic)
} }
return member, err return member, err
} }
@@ -1798,7 +1809,11 @@ func (groupBusinessMapper) AcceptInvitation(tenantID types.TenantID, invitationI
if err != nil { if err != nil {
return nil, err return nil, err
} }
return tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now) invitation, err := tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now)
if err == nil && invitation != nil {
invalidateGroupMemberPages(tenantID, invitation.Topic)
}
return invitation, err
} }
func (groupBusinessMapper) CreateJoinRequest(tenantID types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) { func (groupBusinessMapper) CreateJoinRequest(tenantID types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) {
@@ -1834,7 +1849,11 @@ func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, re
if err != nil { if err != nil {
return nil, err return nil, err
} }
return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now) resolved, err := tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now)
if err == nil && resolved != nil && resolved.Status == types.GroupJoinRequestStatusAccepted {
invalidateGroupMemberPages(tenantID, topic)
}
return resolved, err
} }
func (groupBusinessMapper) LatestAnnouncement(tenantID types.TenantID, topic string) (*types.GroupAnnouncement, error) { func (groupBusinessMapper) LatestAnnouncement(tenantID types.TenantID, topic string) (*types.GroupAnnouncement, error) {
@@ -1951,8 +1970,12 @@ func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, i
if err != nil { if err != nil {
return false, err return false, err
} }
return tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target, created, err := tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target,
strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now) strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now)
if err == nil {
invalidateGroupMemberPages(tenantID, topic)
}
return created, err
} }
func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid types.Uid, now time.Time) error { func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid types.Uid, now time.Time) error {
@@ -1969,6 +1992,7 @@ func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid type
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid)) _ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, false)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true))
invalidateGroupMemberPages(tenantID, topic)
return nil return nil
} }
@@ -1987,6 +2011,7 @@ func (groupBusinessMapper) UpdateRole(tenantID types.TenantID, topic string, act
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), target)) _ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), target))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, false)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, true)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, true))
invalidateGroupMemberPages(tenantID, topic)
return nil return nil
} }
@@ -1999,7 +2024,17 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string,
if err != nil { if err != nil {
return err return err
} }
return tenantAdp.TenantGroupOwnerTransfer(tenantID, strings.TrimSpace(topic), previousOwner, newOwner, version, now) topic = strings.TrimSpace(topic)
if err = tenantAdp.TenantGroupOwnerTransfer(tenantID, topic, previousOwner, newOwner, version, now); err != nil {
return err
}
for _, uid := range []types.Uid{previousOwner, newOwner} {
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(topic, uid))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, uid, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, uid, true))
}
invalidateGroupMemberPages(tenantID, topic)
return nil
} }
func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) { func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
@@ -2848,6 +2883,22 @@ func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profi
return return
} }
public := userProfilePublicProjection(profile) public := userProfilePublicProjection(profile)
if user, err := Users.Get(tenantID, uid); err != nil {
logs.Warn.Printf("profile %s: load native user projection failed: %v", uid, err)
} else if user != nil {
merged := make(map[string]any)
if encoded, marshalErr := json.Marshal(user.Public); marshalErr == nil {
_ = json.Unmarshal(encoded, &merged)
}
for key, value := range public {
merged[key] = value
}
if err = Users.Update(tenantID, uid, map[string]any{"Public": merged}); err != nil {
logs.Warn.Printf("profile %s: update native user projection failed: %v", uid, err)
} else {
public = merged
}
}
cursor := "" cursor := ""
for { for {
items, nextCursor, err := Relations.ListFriends(tenantID, uid, "", "", cursor, 100) items, nextCursor, err := Relations.ListFriends(tenantID, uid, "", "", cursor, 100)
@@ -2871,27 +2922,17 @@ func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profi
} }
func userProfilePublicProjection(profile *types.UserProfile) map[string]any { func userProfilePublicProjection(profile *types.UserProfile) map[string]any {
public := make(map[string]any)
nickname := strings.TrimSpace(profile.Nickname) nickname := strings.TrimSpace(profile.Nickname)
if nickname != "" { public := map[string]any{
for _, key := range []string{"display_name", "nickname", "fn", "name"} { "display_name": nickname,
public[key] = nickname "nickname": nickname,
} "fn": nickname,
} "name": nickname,
if value := strings.TrimSpace(profile.AvatarFileID); value != "" { "avatar_file_id": strings.TrimSpace(profile.AvatarFileID),
public["avatar_file_id"] = value "signature": strings.TrimSpace(profile.Signature),
} "gender": strings.TrimSpace(profile.Gender),
if value := strings.TrimSpace(profile.Signature); value != "" { "region": strings.TrimSpace(profile.Region),
public["signature"] = value "public_account": strings.TrimSpace(profile.PublicAccount),
}
if value := strings.TrimSpace(profile.Gender); value != "" {
public["gender"] = value
}
if value := strings.TrimSpace(profile.Region); value != "" {
public["region"] = value
}
if value := strings.TrimSpace(profile.PublicAccount); value != "" {
public["public_account"] = value
} }
return public return public
} }
@@ -2959,6 +3000,9 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.Email != nil && len([]rune(strings.TrimSpace(*patch.Email))) > 128 { if patch.Email != nil && len([]rune(strings.TrimSpace(*patch.Email))) > 128 {
return false return false
} }
if patch.Region != nil && len([]rune(strings.TrimSpace(*patch.Region))) > 128 {
return false
}
return true return true
} }
+84 -4
View File
@@ -12,6 +12,7 @@ import (
"context" "context"
"errors" "errors"
"sort" "sort"
"strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -1215,6 +1216,18 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return return
} }
if err := t.validateMessageReplacement(asUid, msg.Pub.Head, msg.Timestamp); err != nil {
if errors.Is(err, types.ErrMalformed) {
msg.sess.queueOut(ErrMalformedReply(msg, types.TimeNow()))
} else {
msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow()))
}
return
}
if err := t.consumeRiskActionToken(asUid, msg.Pub.Head); err != nil {
msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow()))
return
}
isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil
if isCall { if isCall {
@@ -1262,17 +1275,68 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
} }
} }
func (t *Topic) validateMessageReplacement(asUid types.Uid, head map[string]any, now time.Time) error {
if head == nil || head["replace"] == nil {
return nil
}
replace, ok := head["replace"].(string)
if !ok {
return types.ErrMalformed
}
seq, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(replace), ":"), 10, 64)
if err != nil || seq <= 0 {
return types.ErrMalformed
}
original, err := store.Messages.GetOne(t.tenantID, t.name, seq)
if err != nil {
return err
}
if original == nil || types.ParseUid(original.From) != asUid {
return types.ErrPermissionDenied
}
if now.IsZero() {
now = types.TimeNow()
}
if original.CreatedAt.IsZero() || now.Sub(original.CreatedAt) > 5*time.Minute {
return types.ErrPermissionDenied
}
return nil
}
func (t *Topic) consumeRiskActionToken(asUid types.Uid, head map[string]any) error {
if head == nil {
return nil
}
token, _ := head["risk_action_token"].(string)
challengeID, _ := head["risk_challenge_id"].(string)
token = strings.TrimSpace(token)
challengeID = strings.TrimSpace(challengeID)
if token == "" && challengeID == "" {
return nil
}
if token == "" || challengeID == "" || store.Security == nil {
return types.ErrPermissionDenied
}
if _, err := store.Security.ConsumeActionToken(t.tenantID, asUid, token, "risk_continue", challengeID, types.TimeNow()); err != nil {
return err
}
delete(head, "risk_action_token")
return nil
}
func (t *Topic) validateAndNormalizeMentions(asUid types.Uid, head map[string]any) (map[string]any, error) { func (t *Topic) validateAndNormalizeMentions(asUid types.Uid, head map[string]any) (map[string]any, error) {
mentionAll, _ := head["mention_all"].(bool) mentionAll, _ := head["mention_all"].(bool)
rawMentions, hasMentions := head["mentions"] rawMentions, hasMentions := head["mentions"]
if !mentionAll && !hasMentions { if !mentionAll && !hasMentions {
return head, nil return head, nil
} }
pud := t.perUser[asUid] if mentionAll {
access := pud.modeGiven & pud.modeWant if sub, err := store.Subs.Get(t.tenantID, t.name, asUid, false); err != nil {
if mentionAll && !access.IsAdmin() { return nil, err
} else if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsAdmin() {
return nil, types.ErrPermissionDenied return nil, types.ErrPermissionDenied
} }
}
validMembers := make(map[string]struct{}, len(t.perUser)) validMembers := make(map[string]struct{}, len(t.perUser))
for uid, pud := range t.perUser { for uid, pud := range t.perUser {
mode := pud.modeGiven & pud.modeWant mode := pud.modeGiven & pud.modeWant
@@ -2318,11 +2382,27 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe
desc.Public = t.public desc.Public = t.public
desc.Trusted = t.trusted desc.Trusted = t.trusted
} else if full && t.cat == types.TopicCatP2P { } else if full && t.cat == types.TopicCatP2P {
// FIXME: when a P2P participant updates desc at 'me', these cached values are not updated. uid1, uid2, err := types.ParseP2P(t.name)
if err != nil {
return err
}
peer := uid1
if peer == asUid {
peer = uid2
}
user, err := store.Users.Get(t.tenantID, peer)
if err != nil {
return err
}
if user != nil {
desc.Public = user.Public
desc.Trusted = user.Trusted
} else {
desc.Public = pud.public desc.Public = pud.public
desc.Trusted = pud.trusted desc.Trusted = pud.trusted
} }
} }
}
// Request may come from a subscriber (full == true) or a stranger. // Request may come from a subscriber (full == true) or a stranger.
// Give subscriber a fuller description than to a stranger/channel reader. // Give subscriber a fuller description than to a stranger/channel reader.
+37
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -941,6 +942,9 @@ func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) {
pud.modeWant = types.ModeCPublic pud.modeWant = types.ModeCPublic
pud.modeGiven = types.ModeCPublic pud.modeGiven = types.ModeCPublic
helper.topic.perUser[helper.uids[0]] = pud helper.topic.perUser[helper.uids[0]] = pud
helper.ss.EXPECT().Get(types.TenantID(1), "grp-test", helper.uids[0], false).Return(&types.Subscription{
TenantID: 1, Topic: "grp-test", User: helper.uids[0].String(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
}, nil)
from := helper.uids[0].UserId() from := helper.uids[0].UserId()
msg := &ClientComMessage{ msg := &ClientComMessage{
@@ -963,6 +967,39 @@ func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) {
} }
} }
func TestValidateMessageReplacementChecksAuthorAndWindow(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldMessages := store.Messages
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Messages = messages
t.Cleanup(func() { store.Messages = oldMessages })
now := time.Date(2026, 9, 6, 10, 0, 0, 0, time.UTC)
topic := &Topic{tenantID: 7, name: "grpAlpha"}
author := types.Uid(42)
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(8)).Return(&types.Message{
Topic: "grpAlpha", SeqId: 8, From: author.String(), ObjHeader: types.ObjHeader{CreatedAt: now.Add(-time.Minute)},
}, nil)
if err := topic.validateMessageReplacement(author, map[string]any{"replace": ":8"}, now); err != nil {
t.Fatalf("valid replacement rejected: %v", err)
}
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(9)).Return(&types.Message{
Topic: "grpAlpha", SeqId: 9, From: types.Uid(99).String(), ObjHeader: types.ObjHeader{CreatedAt: now.Add(-time.Minute)},
}, nil)
if err := topic.validateMessageReplacement(author, map[string]any{"replace": ":9"}, now); !errors.Is(err, types.ErrPermissionDenied) {
t.Fatalf("replacement of peer message: got %v", err)
}
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(10)).Return(&types.Message{
Topic: "grpAlpha", SeqId: 10, From: author.String(), ObjHeader: types.ObjHeader{CreatedAt: now.Add(-6 * time.Minute)},
}, nil)
if err := topic.validateMessageReplacement(author, map[string]any{"replace": ":10"}, now); !errors.Is(err, types.ErrPermissionDenied) {
t.Fatalf("expired replacement: got %v", err)
}
}
func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) { func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) {
numUsers := 2 numUsers := 2
helper := TopicTestHelper{} helper := TopicTestHelper{}
+6 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"container/heap" "container/heap"
crand "crypto/rand" crand "crypto/rand"
"errors"
"math/rand" "math/rand"
"strings" "strings"
"time" "time"
@@ -216,7 +217,11 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Create user record in the database. // Create user record in the database.
if _, err := store.Users.Create(s.tenantID, &user, private); err != nil { if _, err := store.Users.Create(s.tenantID, &user, private); err != nil {
logs.Warn.Println("create user: failed to create user", err, "sid=", s.sid) logs.Warn.Println("create user: failed to create user", err, "sid=", s.sid)
s.queueOut(ErrUnknown(msg.Id, "", msg.Timestamp)) params := map[string]any(nil)
if errors.Is(err, types.ErrDuplicate) && phoneRegistration {
params = map[string]any{"what": "phone", "reason": "PHONE_ALREADY_REGISTERED"}
}
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, params))
return return
} }
+45
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -65,6 +66,50 @@ func TestConsumeProductVerificationCred(t *testing.T) {
} }
} }
func TestConsumeProductVerificationCredReturnsDuplicateCredential(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldUsers := store.Users
oldVerifications := store.Verifications
defer func() {
store.Users = oldUsers
store.Verifications = oldVerifications
}()
makeTestAPIKey(t)
token := "verified-token"
tokenHash := hashIMSecret(7, "register", "challenge-duplicate", "phone-hash", token)
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-duplicate",
Scene: "register",
PhoneHash: "phone-hash",
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().UpsertCred(types.TenantID(7), gomock.Any()).Return(true, types.ErrDuplicate)
store.Users = users
_, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
Method: "tel",
Value: "+8613800138000",
Response: token,
Params: map[string]any{"challenge_id": "challenge-duplicate"},
}, func(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
})
if !ok || !errors.Is(err, types.ErrDuplicate) {
t.Fatalf("duplicate phone: ok=%v err=%v", ok, err)
}
reply := decodeStoreError(err, "acc-1", time.Now(), nil)
if reply.Ctrl == nil || reply.Ctrl.Code != http.StatusConflict || reply.Ctrl.Text != "duplicate credential" {
t.Fatalf("unexpected duplicate reply: %#v", reply)
}
}
func TestConsumeProductVerificationCredRejectsBadToken(t *testing.T) { func TestConsumeProductVerificationCredRejectsBadToken(t *testing.T) {
oldUsers := store.Users oldUsers := store.Users
oldVerifications := store.Verifications oldVerifications := store.Verifications