package main import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math/big" "net" "net/http" "net/url" "regexp" "strconv" "strings" "time" "github.com/nyaruka/phonenumbers" "github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" "golang.org/x/crypto/bcrypt" ) const ( imReasonAPIKeyRequired = "API_KEY_REQUIRED" imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED" imReasonMalformedRequest = "MALFORMED_REQUEST" imReasonTenantUnavailable = "TENANT_UNAVAILABLE" imReasonVerificationRate = "VERIFICATION_RATE_LIMITED" imReasonChallengeExpired = "CHALLENGE_EXPIRED" imReasonCodeInvalid = "CODE_INVALID" imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS" imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION" imReasonAuthRequired = "AUTH_REQUIRED" imReasonRealNameRequired = "REALNAME_REQUIRED" imReasonActionToken = "ACTION_TOKEN_INVALID" imReasonPermissionDenied = "PERMISSION_DENIED" imReasonInternal = "INTERNAL_ERROR" ) const ( imVerificationCodeLength = 6 imVerificationExpiresInSeconds = 300 imVerificationResendAfterSeconds = 60 imVerificationMaxAttempts = 5 imActionTokenExpiresInSeconds = 300 imIndependentPasswordMaxAttempts = 5 imIndependentPasswordLockSeconds = 300 imQRCodeDefaultExpiresInSeconds = 86400 imQRCodeMaxExpiresInSeconds = 7 * 86400 groupMaxAdminCount = 10 ) type imHTTPResponse struct { Code int `json:"code"` Data any `json:"data,omitempty"` Reason string `json:"reason,omitempty"` Message string `json:"message,omitempty"` Details any `json:"details,omitempty"` RequestID string `json:"request_id,omitempty"` ServerTime string `json:"server_time"` } type imTenantResolveRequest struct { TenantCode string `json:"tenant_code"` Platform string `json:"platform,omitempty"` AppVersion string `json:"app_version,omitempty"` } type imVerificationCreateRequest struct { TenantCode string `json:"tenant_code,omitempty"` Scene string `json:"scene"` Phone string `json:"phone"` Country string `json:"country,omitempty"` } type imVerificationVerifyRequest struct { Code string `json:"code"` } type imPasswordResetConfirmRequest struct { ResetToken string `json:"reset_token"` NewPassword string `json:"new_password"` } type imPrincipal struct { TenantID types.TenantID Uid types.Uid } type imSecurityChallengeRequest struct { Scene string `json:"scene"` Channel string `json:"channel"` } type imURLCheckRequest struct { URL string `json:"url"` Source string `json:"source,omitempty"` } type imRealNameSubmissionRequest struct { Name string `json:"name"` IDType string `json:"id_type"` IDNumber string `json:"id_number"` } type imIndependentPasswordRequest struct { Password string `json:"password"` Purpose string `json:"purpose"` ActionToken string `json:"action_token,omitempty"` } type imIndependentPasswordVerifyRequest struct { Password string `json:"password"` Purpose string `json:"purpose"` Action string `json:"action,omitempty"` } type imSystemNotificationReadRequest struct { ReadAt string `json:"read_at,omitempty"` } type imMessageRecallRequest struct { Version int `json:"version"` Reason string `json:"reason,omitempty"` } type imMessageRevisionRequest struct { Version int `json:"version"` Content any `json:"content"` ClientMid string `json:"client_mid,omitempty"` Head types.KVMap `json:"head,omitempty"` Reason string `json:"reason,omitempty"` } type imFavoritesBatchCreateRequest struct { Messages []struct { Topic string `json:"topic"` Seq int `json:"seq,string"` } `json:"messages"` } type imFavoritesBatchDeleteRequest struct { FavoriteIDs []string `json:"favorite_ids"` } type imFavoritesForwardRequest struct { FavoriteIDs []string `json:"favorite_ids"` TargetTopics []string `json:"target_topics"` Comment string `json:"comment,omitempty"` } type imFriendRequestCreateRequest struct { TargetUserID string `json:"target_user_id"` Message string `json:"message,omitempty"` Source string `json:"source,omitempty"` SourceTopic string `json:"source_topic,omitempty"` } type imQRCodeCreateRequest struct { Purpose string `json:"purpose"` ExpiresInSeconds int `json:"expires_in_seconds,omitempty"` } type imQRCodeResolveRequest struct { Token string `json:"token"` Purpose string `json:"purpose,omitempty"` } type imQRCodeClaims struct { Version int `json:"v"` TenantID types.TenantID `json:"tid"` UserID string `json:"uid"` Topic string `json:"topic,omitempty"` Purpose string `json:"pur"` Expires int64 `json:"exp"` Nonce string `json:"non"` } type imGroupQRCodeCreateRequest struct { ExpiresInSeconds int `json:"expires_in_seconds,omitempty"` } type imFriendUpdateRequest struct { Remark string `json:"remark"` } type imBlockBatchDeleteRequest struct { UserIDs []string `json:"user_ids"` } type imGroupCreateRequest struct { Name string `json:"name"` MemberUserIDs []string `json:"member_user_ids,omitempty"` AvatarFileID string `json:"avatar_file_id,omitempty"` } type imGroupPatchRequest struct { Name *string `json:"name,omitempty"` AvatarFileID *string `json:"avatar_file_id,omitempty"` Description *string `json:"description,omitempty"` Version int `json:"version,omitempty"` } type imGroupMemberSettingsRequest struct { Pinned *bool `json:"pinned,omitempty"` MuteNotifications *bool `json:"mute_notifications,omitempty"` } type imGroupMemberProfileUpdateRequest struct { Nickname string `json:"nickname"` Version int `json:"version,omitempty"` } type imGroupInvitationCreateRequest struct { UserIDs []string `json:"user_ids"` Message string `json:"message,omitempty"` } type imGroupJoinRequestCreateRequest struct { Message string `json:"message,omitempty"` QRToken string `json:"qr_token,omitempty"` Source string `json:"source,omitempty"` } type imGroupJoinRequestActionRequest struct { Version int `json:"version,omitempty"` } type imGroupAnnouncementPutRequest struct { Content string `json:"content"` Version int `json:"version,omitempty"` } type imGroupGovernancePatchRequest struct { JoinApproval *bool `json:"join_approval,omitempty"` MuteAll *bool `json:"mute_all,omitempty"` MemberUploadFiles *bool `json:"member_upload_files,omitempty"` MemberPrivateChat *bool `json:"member_private_chat,omitempty"` MemberInvite *bool `json:"member_invite,omitempty"` Version int `json:"version,omitempty"` } type imGroupAdminsBatchRequest struct { UserIDs []string `json:"user_ids"` Version int `json:"version,omitempty"` } type imGroupOwnerTransferRequest struct { TargetUserID string `json:"target_user_id"` Version int `json:"version,omitempty"` } type imGroupMuteBatchRequest struct { Members []struct { UserID string `json:"user_id"` ExpiresAt string `json:"expires_at,omitempty"` } `json:"members"` Reason string `json:"reason,omitempty"` } func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid { writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil) return } var body imTenantResolveRequest if req.Body != nil { dec := json.NewDecoder(req.Body) dec.DisallowUnknownFields() err := dec.Decode(&body) if err != nil { if errors.Is(err, io.EOF) { err = nil } } if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } var extra any if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } else if err == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } } tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code")) if tenantCode == "" { tenantCode = strings.TrimSpace(body.TenantCode) } if tenantCode == "" || len(tenantCode) > 64 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{ "field": "tenant_code", }) return } if store.Tenants == nil { logs.Err.Println("im tenant resolve: tenant store is not initialized") writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } tenant, err := store.Tenants.GetByCode(tenantCode) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil) return } logs.Warn.Println("im tenant resolve: failed to resolve tenant", tenantCode, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if !tenant.IsActive() { writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil) return } writeIMSuccess(wrt, req, map[string]any{ "tenant": map[string]any{ "code": tenant.Code, "name": tenant.Name, "tenant_desc": tenant.TenantDesc, "branding": map[string]any{}, }, "endpoints": map[string]any{ "websocket": strings.TrimRight(globals.servingAt, "/") + "/v0/channels", "api": strings.TrimRight(globals.servingAt, "/") + "/v1/im", "file": strings.TrimRight(globals.servingAt, "/") + "/v0/file", }, "auth_methods": []string{"password", "sms"}, "password_policy": map[string]any{ "min_length": 8, "max_length": 64, }, "app_update": map[string]any{"type": "none"}, }) } func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid { writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil) return } var body imVerificationCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } scene := strings.TrimSpace(body.Scene) if !isAllowedVerificationScene(scene) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "scene"}) return } phone, err := normalizeIMPhone(body.Phone, body.Country) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"}) return } tenant, ok := resolveIMHTTPTenant(wrt, req, body.TenantCode) if !ok { return } now := time.Now().UTC().Round(time.Millisecond) phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone") active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now) if err != nil && !errors.Is(err, types.ErrNotFound) { logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if active != nil && now.Before(active.ResendAfter) { retryAfter := int(time.Until(active.ResendAfter).Seconds()) if retryAfter < 1 { retryAfter = 1 } writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ "challenge_id": active.ChallengeID, "retry_after_seconds": retryAfter, }) return } challengeID, err := randomIMToken(18) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } code, err := randomIMNumericCode(imVerificationCodeLength) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } vc := &types.VerificationCode{ TenantID: tenant.ID, ChallengeID: challengeID, Scene: scene, PhoneHash: phoneHash, PhoneMask: maskIMPhone(phone), CodeHash: hashIMSecret(tenant.ID, scene, challengeID, phoneHash, code), State: types.VerificationStatePending, MaxAttempts: imVerificationMaxAttempts, ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second), ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second), } if scene == "reset_password" { if uid, err := store.Users.GetByCred(tenant.ID, "tel", phone); err != nil { logs.Warn.Println("im verification create: credential lookup failed", tenant.Code, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } else { vc.TargetUser = uid } } if err := store.Verifications.Create(tenant.ID, vc); err != nil { logs.Warn.Println("im verification create: store failed", tenant.Code, scene, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } logs.Info.Println("im verification code generated:", tenant.Code, scene, vc.PhoneMask) writeIMSuccess(wrt, req, map[string]any{ "challenge_id": vc.ChallengeID, "phone_mask": vc.PhoneMask, "expires_in_seconds": imVerificationExpiresInSeconds, "resend_after_seconds": imVerificationResendAfterSeconds, }) } func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid { writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil) return } challengeID := verificationChallengeIDFromPath(req.URL.Path) if challengeID == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"}) return } var body imVerificationVerifyRequest if !decodeIMJSONBody(wrt, req, &body) { return } code := strings.TrimSpace(body.Code) if len(code) != imVerificationCodeLength { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"}) return } tenant, ok := resolveIMHTTPTenant(wrt, req, "") if !ok { return } vc, err := store.Verifications.Get(tenant.ID, challengeID) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil) return } logs.Warn.Println("im verification verify: lookup failed", tenant.Code, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } now := time.Now().UTC().Round(time.Millisecond) if vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts { writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil) return } if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) { _ = store.Verifications.Fail(tenant.ID, challengeID, types.VerificationStateExpired) writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return } if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) { nextState := types.VerificationStatePending if vc.Attempts+1 >= vc.MaxAttempts { nextState = types.VerificationStateLocked } _ = store.Verifications.Fail(tenant.ID, challengeID, nextState) if nextState == types.VerificationStateLocked { writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil) return } writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil) return } token, err := randomIMToken(32) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if err := store.Verifications.MarkVerified(tenant.ID, challengeID, hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil { logs.Warn.Println("im verification verify: mark verified failed", tenant.Code, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{ "verification_token": token, "expires_in_seconds": imVerificationExpiresInSeconds, }) } func serveIMPasswordResetVerify(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid { writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil) return } challengeID := passwordResetChallengeIDFromPath(req.URL.Path) if challengeID == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"}) return } var body imVerificationVerifyRequest if !decodeIMJSONBody(wrt, req, &body) { return } tenant, ok := resolveIMHTTPTenant(wrt, req, "") if !ok { return } vc, token, ok := verifyIMCodeChallenge(wrt, req, tenant.ID, challengeID, strings.TrimSpace(body.Code), "reset_password") if !ok { return } if vc.TargetUser.IsZero() { writeIMSuccess(wrt, req, map[string]any{ "reset_token": vc.ChallengeID + "." + token, "expires_in_seconds": imVerificationExpiresInSeconds, }) return } writeIMSuccess(wrt, req, map[string]any{ "reset_token": vc.ChallengeID + "." + token, "expires_in_seconds": imVerificationExpiresInSeconds, }) } func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid { writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil) return } var body imPasswordResetConfirmRequest if !decodeIMJSONBody(wrt, req, &body) { return } tenant, ok := resolveIMHTTPTenant(wrt, req, "") if !ok { return } challengeID, token, ok := strings.Cut(strings.TrimSpace(body.ResetToken), ".") if !ok || challengeID == "" || token == "" || strings.TrimSpace(body.NewPassword) == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } vc, err := store.Verifications.Get(tenant.ID, challengeID) if err != nil { writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return } if vc.Scene != "reset_password" || vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil { writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return } tokenHash := hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token) if *vc.VerificationTokenHash != tokenHash || !time.Now().UTC().Before(vc.ExpiresAt) { writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return } if vc.TargetUser.IsZero() { _ = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, time.Now().UTC().Round(time.Millisecond)) writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) return } login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser, "basic") if err != nil || login == "" { writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return } basicAuth := store.Store.GetLogicalAuthHandler("basic") if basicAuth == nil || !basicAuth.IsInitialized() { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if _, err = basicAuth.UpdateRecord(auth.AuthContext{TenantID: tenant.ID}, &auth.Rec{ TenantID: tenant.ID, Uid: vc.TargetUser, AuthLevel: authLevel, }, []byte(":"+body.NewPassword)); err != nil { if err == types.ErrPolicy { writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil) return } writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } now := time.Now().UTC().Round(time.Millisecond) if store.Security == nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if _, err = store.Security.BumpAuthVersion(tenant.ID, vc.TargetUser, now); err != nil { logs.Warn.Println("im password reset confirm: bump auth version failed", err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if err = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, now); err != nil { writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return } if globals.sessionStore != nil { globals.sessionStore.EvictUser(vc.TargetUser, "") } writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) } func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if _, ok := authenticateIMHTTPBearer(wrt, req); !ok { return } var body imURLCheckRequest if !decodeIMJSONBody(wrt, req, &body) { return } decision, riskLevel, reason := classifyIMURLRisk(strings.TrimSpace(body.URL)) payload := map[string]any{ "url": strings.TrimSpace(body.URL), "source": strings.TrimSpace(body.Source), "decision": decision, "risk_level": riskLevel, "reason": reason, } if decision != "allow" { payload["risk_challenge_id"] = "url_" + shortTokenHash(strings.ToLower(strings.TrimSpace(body.URL))) } writeIMSuccess(wrt, req, payload) } func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imSecurityChallengeRequest if !decodeIMJSONBody(wrt, req, &body) { return } action := strings.TrimSpace(body.Scene) if action == "" || len(action) > 64 || strings.TrimSpace(body.Channel) != "sms" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } phone, err := currentUserPhone(principal.TenantID, principal.Uid) if err != nil { writeIMError(wrt, req, http.StatusForbidden, 30041, imReasonRealNameRequired, "verified phone required", nil) return } createSensitiveChallenge(wrt, req, principal, action, phone) } func serveIMSecurityChallengeVerify(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } challengeID := securityChallengeIDFromPath(req.URL.Path) if challengeID == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } var body imVerificationVerifyRequest if !decodeIMJSONBody(wrt, req, &body) { return } vc, _, ok := verifyIMCodeChallenge(wrt, req, principal.TenantID, challengeID, strings.TrimSpace(body.Code), "sensitive_action") if !ok { return } if vc.TargetUser != principal.Uid { writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) return } token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, vc.Action, defaultIMActionTarget(vc.Action, vc.ChallengeID)) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{ "action_token": token, "action": vc.Action, "expires_at": expiresAt.Format(time.RFC3339Nano), }) } func serveIMRealName(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMSuccess(wrt, req, map[string]any{"status": "unverified", "can_submit": true}) return } writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, realNamePayload(rv)) } func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) if idempotencyKey == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"}) return } var body imRealNameSubmissionRequest if !decodeIMJSONBody(wrt, req, &body) { return } name := strings.TrimSpace(body.Name) idType := strings.TrimSpace(body.IDType) idNumber := strings.ToUpper(strings.TrimSpace(body.IDNumber)) if !validIMRealName(name, idType, idNumber) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } current, err := store.Security.GetRealName(principal.TenantID, principal.Uid) if err == nil && (current.Status == "pending" || current.Status == "verified") { writeIMSuccess(wrt, req, realNamePayload(current)) return } if err != nil && !errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32] rv := &types.RealNameVerification{ TenantID: principal.TenantID, UserID: principal.Uid, SubmissionID: submissionID, Status: "pending", NameCipher: protectIMSensitive(principal.TenantID, name), IDType: idType, IDNumberCipher: protectIMSensitive(principal.TenantID, idNumber), IDNumberHash: hashIMSecret(principal.TenantID, idType, idNumber, "realname-id"), MaskedName: maskIMName(name), MaskedIDNumber: maskIMIDNumber(idNumber), } if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil { if errors.Is(err, types.ErrDuplicate) { writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending", "can_submit": false}) return } writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"}) } func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } status := "unverified" if rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid); err == nil { status = rv.Status } else if err != nil && !errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{ "features": map[string]any{ "create_group": true, "user_search": true, "scan_qr": true, "system_notifications": true, "message_recall": false, "message_edit": false, }, "permissions": map[string]any{ "create_group": true, "user_search": true, "scan_qr": true, }, "system_topic": map[string]any{ "topic": "sys", "type": "system", }, "message_policy": map[string]any{ "recall_window_seconds": 120, "edit_window_seconds": 300, "supported_actions": []string{"copy", "quote", "delete"}, }, "security": map[string]any{ "realname": map[string]any{ "status": status, "required": false, "allow_skip": true, "remind_after": "24h", }, }, }) } func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q"))) cursor := strings.TrimSpace(req.URL.Query().Get("cursor")) limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100}) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } start := 0 if cursor != "" { parsed, err := strconv.Atoi(cursor) if err != nil || parsed < 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) return } start = parsed } items := make([]map[string]any, 0, limit) nextIndex := start for i := start; i < len(subs); i++ { if query != "" && !conversationMatches(subs[i], query) { continue } items = append(items, conversationPayload(subs[i])) nextIndex = i + 1 if len(items) == limit { break } } nextCursor := "" if nextIndex < len(subs) { nextCursor = strconv.Itoa(nextIndex) } writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor}) } func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) items, nextCursor, err := store.Security.ListSystemNotifications(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor}) } func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } eventID := systemNotificationIDFromPath(req.URL.Path) if eventID == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "id"}) return } if strings.TrimSpace(req.Header.Get("Idempotency-Key")) == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"}) return } var body imSystemNotificationReadRequest if req.Body != nil && req.ContentLength != 0 { if !decodeIMJSONBody(wrt, req, &body) { return } } readAt := time.Now().UTC().Round(time.Millisecond) if body.ReadAt != "" { parsed, err := time.Parse(time.RFC3339Nano, body.ReadAt) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "read_at"}) return } readAt = parsed.UTC().Round(time.Millisecond) } if err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt); err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil) return } writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)}) } func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: serveIMFavoriteList(wrt, req) case http.MethodPost: serveIMFavoriteBatchCreate(wrt, req) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) items, nextCursor, err := store.Favorites.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } payload := make([]map[string]any, 0, len(items)) for _, item := range items { payload = append(payload, favoritePayload(item)) } writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imFavoritesBatchCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } if len(body.Messages) == 0 || len(body.Messages) > 100 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "messages"}) return } now := time.Now().UTC().Round(time.Millisecond) created, existing := 0, 0 failed := make([]map[string]any, 0) for _, src := range body.Messages { if src.Topic == "" || src.Seq <= 0 { failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest}) continue } msg, err := store.Messages.GetOne(principal.TenantID, src.Topic, src.Seq) if err != nil { failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)}) continue } fav := &types.FavoriteItem{ TenantID: principal.TenantID, FavoriteID: "fav_" + store.Store.GetUidString(), UserID: principal.Uid, ItemType: imMessageType(msg), SourceTopic: src.Topic, SourceSeq: src.Seq, Snapshot: favoriteSnapshot(msg), AttachmentRefs: favoriteAttachmentRefs(msg), Availability: "available", IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")), CollectedAt: now, CreatedAt: now, UpdatedAt: now, } item, err := store.Favorites.Create(principal.TenantID, fav) if err != nil { failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)}) continue } if item.FavoriteID == fav.FavoriteID { created++ } else { existing++ } } writeIMSuccess(wrt, req, map[string]any{"created_count": created, "existing_count": existing, "failed": failed}) } func serveIMFavoriteResource(wrt http.ResponseWriter, req *http.Request) { const prefix = "/v1/im/favorites/" idx := strings.Index(req.URL.Path, prefix) if idx < 0 { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "favorite route not found", nil) return } tail := strings.Trim(strings.TrimPrefix(req.URL.Path[idx:], prefix), "/") switch { case tail == "batch-delete": serveIMFavoriteBatchDelete(wrt, req) case tail == "forward": serveIMFavoriteForward(wrt, req) default: serveIMFavoriteDetail(wrt, req, tail) } } func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteID string) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } switch req.Method { case http.MethodGet: item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, favoritePayload(*item)) case http.MethodDelete: if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"favorite_id": favoriteID, "deleted": true}) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMFavoriteBatchDelete(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imFavoritesBatchDeleteRequest if !decodeIMJSONBody(wrt, req, &body) { return } results := make([]map[string]any, 0, len(body.FavoriteIDs)) for _, favoriteID := range body.FavoriteIDs { favoriteID = strings.TrimSpace(favoriteID) item := map[string]any{"favorite_id": favoriteID} if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil { item["ok"] = false item["reason"] = relationErrorReason(err) } else { item["ok"] = true } results = append(results, item) } writeIMSuccess(wrt, req, map[string]any{"items": results}) } func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imFavoritesForwardRequest if !decodeIMJSONBody(wrt, req, &body) { return } if len(body.FavoriteIDs) == 0 || len(body.FavoriteIDs) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs)) comment := strings.TrimSpace(body.Comment) for _, target := range body.TargetTopics { target = strings.TrimSpace(target) if target == "" { results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": imReasonMalformedRequest}) continue } if err := requireTopicWriter(principal, target); err != nil { results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": relationErrorReason(err)}) continue } for _, favoriteID := range body.FavoriteIDs { favoriteID = strings.TrimSpace(favoriteID) item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID) if err != nil { results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) continue } msg := &types.Message{ TenantID: principal.TenantID, Topic: target, From: principal.Uid.String(), Head: types.KVMap{ "type": item.ItemType, "x-im-type": item.ItemType, "x-im-forwarded": true, }, Content: favoriteForwardContent(item, comment), } saved, err := store.Messages.Save(principal.TenantID, msg, nil, true) if err != nil { results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) continue } results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId}) } } writeIMSuccess(wrt, req, map[string]any{"items": results}) } func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { switch { case strings.HasSuffix(req.URL.Path, "/messages/search"): serveIMMessageSearch(wrt, req) case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"): serveIMMessageContext(wrt, req) case strings.HasSuffix(req.URL.Path, "/media"): serveIMTopicMedia(wrt, req) case strings.HasSuffix(req.URL.Path, "/receipts"): serveIMMessageReceipts(wrt, req) case strings.HasSuffix(req.URL.Path, "/recall"): serveIMMessageRecall(wrt, req) case strings.HasSuffix(req.URL.Path, "/revisions"): serveIMMessageRevision(wrt, req) default: writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message action route not found", nil) } } func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessagesSearchPath(req.URL.Path)) if !ok { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } items := make([]map[string]any, 0, len(msgs)) for _, msg := range msgs { items = append(items, messageSearchPayload(msg, strings.TrimSpace(req.URL.Query().Get("q")))) } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor}) } func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } topic, seq, ok := messageContextPath(req.URL.Path) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil) return } principal, _, ok := authenticateTopicReader(wrt, req, topic) if !ok { return } before := parseIMLimit(req.URL.Query().Get("before"), 20, 100) after := parseIMLimit(req.URL.Query().Get("after"), 20, 100) msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - before, Before: seq + after + 1, Limit: before + after + 1}) if err != nil { writeIMRelationError(wrt, req, err) return } items := make([]map[string]any, 0, len(msgs)) for _, msg := range msgs { items = append(items, messageContextPayload(msg)) } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "anchor_seq": seq, "items": items}) } func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMediaPath(req.URL.Path)) if !ok { return } mediaType := strings.TrimSpace(req.URL.Query().Get("type")) if mediaType == "" { mediaType = "image" } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), mediaType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } items := make([]map[string]any, 0, len(msgs)) for _, msg := range msgs { items = append(items, mediaMessagePayload(msg)) } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor}) } func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } topic, seq, ok := messageReceiptsPath(req.URL.Path) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message receipt route not found", nil) return } if seq <= 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "seq"}) return } sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) return } limit := parseIMLimit(req.URL.Query().Get("limit"), 100, 200) cursor, _ := strconv.Atoi(req.URL.Query().Get("cursor")) members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: limit + 1}) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } readItems := make([]map[string]any, 0, limit) deliveredItems := make([]map[string]any, 0, limit) nextCursor := 0 for i, member := range members { if i < cursor { continue } if len(readItems) >= limit && len(deliveredItems) >= limit { nextCursor = i break } item := receiptMemberPayload(member) if member.ReadSeqId >= seq && len(readItems) < limit { readItems = append(readItems, item) } if member.RecvSeqId >= seq && len(deliveredItems) < limit { deliveredItems = append(deliveredItems, item) } } writeIMSuccess(wrt, req, map[string]any{ "topic": topic, "seq": seq, "read": readItems, "delivered": deliveredItems, "next_cursor": nextCursor, }) } func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } topic, seq, ok := messageActionPath(req.URL.Path, "recall") if !ok || seq <= 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } var body imMessageRecallRequest if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 { if body.Version <= 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"}) } return } msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, true) if err != nil { writeIMMessageMutationError(wrt, req, err) return } if !authorized { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required", nil) return } if time.Since(msg.CreatedAt) > 120*time.Second { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired", nil) return } recalledAt := time.Now().UTC().Round(time.Millisecond) updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, body.Version, recalledAt) if err != nil { writeIMMessageMutationError(wrt, req, err) return } writeIMSuccess(wrt, req, messageMutationPayload(updated, "recalled")) } func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } topic, seq, ok := messageActionPath(req.URL.Path, "revisions") if !ok || seq <= 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } var body imMessageRevisionRequest if !decodeIMJSONBody(wrt, req, &body) { return } if body.Version <= 0 || body.Content == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version/content"}) return } msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, false) if err != nil { writeIMMessageMutationError(wrt, req, err) return } if !authorized { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit permission required", nil) return } if time.Since(msg.CreatedAt) > 300*time.Second { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit window expired", nil) return } if !isIMEditableTextMessage(msg) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "only text messages can be edited", nil) return } head := body.Head if head == nil { head = types.KVMap{} } head["type"] = "text" if body.ClientMid != "" { head["x-im-client-mid"] = body.ClientMid } editedAt := time.Now().UTC().Round(time.Millisecond) updated, err := store.Messages.Edit(principal.TenantID, topic, seq, principal.Uid, body.Version, head, body.Content, body.Reason, editedAt) if err != nil { writeIMMessageMutationError(wrt, req, err) return } writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited")) } func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } topic := conversationContextTopicFromPath(req.URL.Path) peer := peerFromP2PTopic(topic, principal.Uid) if topic == "" || peer.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, relationshipContextPayload(ctx)) } func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: serveIMFriendRequestList(wrt, req) case http.MethodPost: serveIMFriendRequestCreate(wrt, req) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMFriendRequestList(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) items, nextCursor, err := store.Relations.ListFriendRequests(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("direction")), strings.TrimSpace(req.URL.Query().Get("status")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } payload := make([]map[string]any, 0, len(items)) for _, item := range items { payload = append(payload, friendRequestListItemPayload(item)) } writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imFriendRequestCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } target := types.ParseUserId(body.TargetUserID) message := strings.TrimSpace(body.Message) if target.IsZero() || target == principal.Uid || len([]rune(message)) > 50 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) if idempotencyKey == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"}) return } now := time.Now().UTC().Round(time.Millisecond) requestID := "frq_" + store.Store.GetUidString() created, err := store.Relations.CreateFriendRequest(principal.TenantID, &types.FriendRequest{ TenantID: principal.TenantID, RequestID: requestID, ApplicantID: principal.Uid, TargetID: target, Message: message, Source: strings.TrimSpace(body.Source), SourceTopic: strings.TrimSpace(body.SourceTopic), Status: types.FriendRequestPending, IdempotencyKey: idempotencyKey, ExpiresAt: now.Add(7 * 24 * time.Hour), CreatedAt: now, UpdatedAt: now, }) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, friendRequestPayload(created)) } func serveIMFriends(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("query")), strings.TrimSpace(req.URL.Query().Get("initial")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } payload := make([]map[string]any, 0, len(items)) for _, item := range items { payload = append(payload, friendListItemPayload(item)) } writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } func serveIMGroups(wrt http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: serveIMGroupList(wrt, req) case http.MethodPost: serveIMGroupCreate(wrt, req) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } category := strings.TrimSpace(req.URL.Query().Get("category")) switch category { case "", "owned", "managed", "joined": default: writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "category"}) return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor")) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) return } query := strings.TrimSpace(req.URL.Query().Get("query")) subs, err := store.Users.GetSubs(principal.TenantID, principal.Uid) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } items := make([]map[string]any, 0, limit) seen := 0 nextCursor := "" for _, sub := range subs { if types.GetTopicCat(sub.Topic) != types.TopicCatGrp { continue } item := groupListItemPayload(sub) if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category { continue } if query != "" && !groupListItemMatches(item, query) { continue } if seen < cursor { seen++ continue } if len(items) == limit { nextCursor = strconv.Itoa(seen) break } items = append(items, item) seen++ } writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""}) } func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imGroupCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } name := strings.TrimSpace(body.Name) if len([]rune(name)) < 1 || len([]rune(name)) > 32 || len(body.MemberUserIDs) > 99 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } topicName := genTopicName() now := time.Now().UTC().Round(time.Millisecond) public := map[string]any{"name": name, "version": 1} if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" { public["avatar_file_id"] = avatar } topic := &types.Topic{ ObjHeader: types.ObjHeader{Id: topicName}, TenantID: principal.TenantID, State: types.StateOK, Access: types.DefaultAccess{Auth: types.ModeCPublic, Anon: types.ModeNone}, Public: public, } topic.GiveAccess(principal.Uid, types.ModeCFull, types.ModeCFull) if err := store.Topics.Create(principal.TenantID, topic, principal.Uid, groupMemberPrivate("", false, false, 1, now)); err != nil { writeIMRelationError(wrt, req, err) return } var members []*types.Subscription seen := map[types.Uid]bool{principal.Uid: true} for _, rawID := range body.MemberUserIDs { uid := types.ParseUserId(strings.TrimSpace(rawID)) if uid.IsZero() || seen[uid] { continue } seen[uid] = true members = append(members, &types.Subscription{ TenantID: principal.TenantID, User: uid.String(), Topic: topicName, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, Private: groupMemberPrivate("", false, false, 1, now), }) } if len(members) > 0 { if err := store.Subs.Create(principal.TenantID, members...); err != nil { writeIMRelationError(wrt, req, err) return } _ = store.Topics.UpdateSubCnt(principal.TenantID, topicName) } created, err := store.Topics.Get(principal.TenantID, topicName) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } selfSub, _ := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false) writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub)) } func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) { topic, tail, ok := groupResourcePath(req.URL.Path) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil) return } if tail == "settings" { serveIMGroupSettings(wrt, req, topic) return } if tail == "member-settings" { serveIMGroupMemberSettings(wrt, req, topic) return } if tail == "members/me/profile" { serveIMGroupMyProfile(wrt, req, topic) return } if tail == "leave" { serveIMGroupLeave(wrt, req, topic) return } if tail == "qrcodes" { serveIMGroupQRCodes(wrt, req, topic) return } if tail == "invitations" { serveIMGroupInvitations(wrt, req, topic) return } if tail == "join-requests" { serveIMGroupJoinRequests(wrt, req, topic) return } if strings.HasPrefix(tail, "join-requests/") { serveIMGroupJoinRequestAction(wrt, req, topic, tail) return } if tail == "summary" { serveIMGroupSummary(wrt, req, topic) return } if tail == "announcements/latest" || tail == "announcements/current" || strings.HasPrefix(tail, "announcements/") { serveIMGroupAnnouncements(wrt, req, topic, tail) return } if tail == "governance" { serveIMGroupGovernance(wrt, req, topic) return } if tail == "role-limits" { serveIMGroupRoleLimits(wrt, req, topic) return } if tail == "admins/batch" || strings.HasPrefix(tail, "admins/") { serveIMGroupAdmins(wrt, req, topic, tail) return } if tail == "owner-transfer" { serveIMGroupOwnerTransfer(wrt, req, topic) return } if tail == "mutes" || strings.HasPrefix(tail, "mutes/") { serveIMGroupMutes(wrt, req, topic, tail) return } if tail == "members" { serveIMGroupMembers(wrt, req, topic) return } if strings.HasPrefix(tail, "members/") && strings.HasSuffix(tail, "/profile") { memberID, ok := groupMemberProfilePath(tail) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil) return } serveIMGroupMemberProfile(wrt, req, topic, memberID) return } if tail == "" || !strings.Contains(tail, "/") { serveIMGroupDetail(wrt, req, topic) return } writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil) } func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } if types.GetTopicCat(topic) != types.TopicCatGrp { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) 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) return } selfAccess := types.ModeNone if selfSub != nil { selfAccess = selfSub.ModeGiven & selfSub.ModeWant } if selfSub == nil || !selfAccess.IsReader() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) return } limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100) cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor")) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) return } query := strings.TrimSpace(req.URL.Query().Get("query")) mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable")) roleFilter := strings.TrimSpace(req.URL.Query().Get("role")) eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role")) muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible")) members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } items := make([]map[string]any, 0, limit) seen := 0 nextCursor := "" for _, member := range members { access := member.ModeGiven & member.ModeWant mentionable := access.IsJoiner() && access.IsReader() role := groupMemberRole(access) if roleFilter != "" && role != roleFilter { continue } if mentionableOnly && !mentionable { continue } item := groupMemberPayload(member, mentionable) if eligibleRole != "" { eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole) item["eligible"] = eligible if reason != "" { item["ineligible_reason"] = reason } } if muteEligible { eligible, reason := groupMuteEligibility(selfAccess, access) item["eligible"] = eligible if reason != "" { item["ineligible_reason"] = reason } } if query != "" && !groupMemberMatches(item, query) { continue } if seen < cursor { seen++ continue } if len(items) == limit { nextCursor = strconv.Itoa(seen) break } items = append(items, item) seen++ } permissions := map[string]any{ "can_mention_all": selfAccess.IsAdmin(), } writeIMSuccess(wrt, req, map[string]any{ "topic": topic, "items": items, "next_cursor": nextCursor, "permissions": permissions, }) } func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) { principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } switch req.Method { case http.MethodGet: group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if group == nil { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) return } writeIMSuccess(wrt, req, groupDetailPayload(group, selfSub)) case http.MethodPatch: access := selfSub.ModeGiven & selfSub.ModeWant if !access.IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } var body imGroupPatchRequest if !decodeIMJSONBody(wrt, req, &body) { return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if group == nil { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) return } public := groupPublicMap(group.Public) currentVersion := intFromAny(public["version"]) if currentVersion <= 0 { currentVersion = 1 } if body.Version > 0 && body.Version != currentVersion { writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion}) return } if body.Name != nil { name := strings.TrimSpace(*body.Name) if len([]rune(name)) < 1 || len([]rune(name)) > 32 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "name"}) return } public["name"] = name } if body.AvatarFileID != nil { public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID) } if body.Description != nil { description := strings.TrimSpace(*body.Description) if len([]rune(description)) > 200 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"}) return } public["description"] = description } public["version"] = currentVersion + 1 if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": public}); err != nil { writeIMRelationError(wrt, req, err) return } updated, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, groupDetailPayload(updated, selfSub)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, groupSettingsPayload(group, selfSub)) } func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodPatch { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } var body imGroupMemberSettingsRequest if !decodeIMJSONBody(wrt, req, &body) { return } private := groupPrivateMap(selfSub.Private) if body.Pinned != nil { private["pinned"] = *body.Pinned } if body.MuteNotifications != nil { private["mute_notifications"] = *body.MuteNotifications } private["version"] = intFromAny(private["version"]) + 1 private["updated_at"] = time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano) if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil { writeIMRelationError(wrt, req, err) return } selfSub.Private = private writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub)) } func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodPut { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } var body imGroupMemberProfileUpdateRequest if !decodeIMJSONBody(wrt, req, &body) { return } nickname := strings.TrimSpace(body.Nickname) if len([]rune(nickname)) > 32 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"}) return } private := groupPrivateMap(selfSub.Private) currentVersion := intFromAny(private["profile_version"]) if currentVersion <= 0 { currentVersion = 1 } if body.Version > 0 && body.Version != currentVersion { writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion}) return } private["nickname"] = nickname private["profile_version"] = currentVersion + 1 updatedAt := time.Now().UTC().Round(time.Millisecond) private["profile_updated_at"] = updatedAt.Format(time.RFC3339Nano) if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)}) } func serveIMGroupLeave(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } if (selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "owner must transfer or dismiss group before leaving", nil) return } if err := store.Subs.Delete(principal.TenantID, topic, principal.Uid); err != nil { writeIMRelationError(wrt, req, err) return } _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"}) } func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } if !(selfSub.ModeGiven & selfSub.ModeWant).IsSharer() && !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } var body imGroupQRCodeCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } expiresIn := body.ExpiresInSeconds if expiresIn <= 0 { expiresIn = imQRCodeDefaultExpiresInSeconds } if expiresIn > imQRCodeMaxExpiresInSeconds { expiresIn = imQRCodeMaxExpiresInSeconds } expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond) token, err := generateIMQRCodeTokenForTopic(principal.TenantID, principal.Uid, topic, "join_group", expiresAt) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{ "qr_token": token, "qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group", "expires_at": expiresAt.Format(time.RFC3339Nano), "group_snapshot": groupDetailPayload(group, selfSub), }) } func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } access := selfSub.ModeGiven & selfSub.ModeWant if !access.IsSharer() && !access.IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } var body imGroupInvitationCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"}) return } results := make([]map[string]any, 0, len(body.UserIDs)) for _, rawID := range body.UserIDs { uid := types.ParseUserId(strings.TrimSpace(rawID)) item := map[string]any{"user_id": strings.TrimSpace(rawID)} if uid.IsZero() || uid == principal.Uid { item["status"] = "failed" item["reason"] = imReasonMalformedRequest results = append(results, item) continue } item["user_id"] = uid.UserId() if _, err := store.Subs.Get(principal.TenantID, topic, uid, false); err == nil { item["status"] = "already_member" results = append(results, item) continue } if err := store.Subs.Create(principal.TenantID, &types.Subscription{ TenantID: principal.TenantID, User: uid.String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)), }); err != nil { item["status"] = "failed" item["reason"] = relationErrorReason(err) } else { item["status"] = "joined" } results = append(results, item) } _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results}) } func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) { principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, req.Method == http.MethodGet) if !ok { return } switch req.Method { case http.MethodGet: if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } status := strings.TrimSpace(req.URL.Query().Get("status")) if status == "" { status = "pending" } items := filterGroupJoinRequests(groupJoinRequests(group), status) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": ""}) case http.MethodPost: var body imGroupJoinRequestCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } aux := groupAuxMap(group.Aux) requests := groupJoinRequests(group) now := time.Now().UTC().Round(time.Millisecond) requestID := "gjr_" + store.Store.GetUidString() item := map[string]any{ "request_id": requestID, "user_id": principal.Uid.UserId(), "inviter_id": "", "source": defaultString(strings.TrimSpace(body.Source), "manual"), "message": strings.TrimSpace(body.Message), "status": "pending", "version": 1, "created_at": now.Format(time.RFC3339Nano), "updated_at": now.Format(time.RFC3339Nano), "qr_token": strings.TrimSpace(body.QRToken), } requests = append(requests, item) aux["join_requests"] = requests if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, item) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, topic, tail string) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } requestID, action, ok := groupJoinRequestActionPath(tail) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request route not found", nil) return } var body imGroupJoinRequestActionRequest if !decodeIMJSONBody(wrt, req, &body) { return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } aux := groupAuxMap(group.Aux) requests := groupJoinRequests(group) updated, found := resolveGroupJoinRequest(requests, requestID, action, principal.Uid, body.Version) if !found { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request not found", nil) return } aux["join_requests"] = requests if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { writeIMRelationError(wrt, req, err) return } if action == "accept" { uid := types.ParseUserId(stringFromAny(updated["user_id"])) if !uid.IsZero() { _ = store.Subs.Create(principal.TenantID, &types.Subscription{ TenantID: principal.TenantID, User: uid.String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)), }) _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) } } writeIMSuccess(wrt, req, updated) } func serveIMGroupSummary(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } announcement := latestGroupAnnouncement(group) writeIMSuccess(wrt, req, map[string]any{ "topic": topic, "pending_join_request_count": len(filterGroupJoinRequests(groupJoinRequests(group), "pending")), "announcement": announcement, "permissions": groupPermissions(selfSub.ModeGiven & selfSub.ModeWant), }) } func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic, tail string) { principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } switch { case tail == "announcements/latest" && req.Method == http.MethodGet: writeIMSuccess(wrt, req, latestGroupAnnouncement(group)) case tail == "announcements/current" && req.Method == http.MethodPut: if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } var body imGroupAnnouncementPutRequest if !decodeIMJSONBody(wrt, req, &body) { return } content := strings.TrimSpace(body.Content) if content == "" || len([]rune(content)) > 2000 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"}) return } aux := groupAuxMap(group.Aux) current := latestGroupAnnouncement(group) version := intFromAny(current["version"]) if version <= 0 { version = 1 } if body.Version > 0 && body.Version != version { writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": version}) return } now := time.Now().UTC().Round(time.Millisecond) announcement := map[string]any{ "announcement_id": defaultString(stringFromAny(current["announcement_id"]), "ann_"+store.Store.GetUidString()), "content": content, "author": principal.Uid.UserId(), "version": version + 1, "published_at": now.Format(time.RFC3339Nano), } aux["announcement"] = announcement if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, announcement) case strings.HasPrefix(tail, "announcements/") && strings.HasSuffix(tail, "/read") && req.Method == http.MethodPost: announcementID := strings.TrimSuffix(strings.TrimPrefix(tail, "announcements/"), "/read") now := time.Now().UTC().Round(time.Millisecond) writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)}) case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet: writeIMSuccess(wrt, req, latestGroupAnnouncement(group)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic string) { principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } switch req.Method { case http.MethodGet: writeIMSuccess(wrt, req, groupGovernancePayload(group, selfSub)) case http.MethodPatch: if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } var body imGroupGovernancePatchRequest if !decodeIMJSONBody(wrt, req, &body) { return } aux := groupAuxMap(group.Aux) governance := groupGovernanceMap(aux["governance"]) currentVersion := intFromAny(governance["version"]) if currentVersion <= 0 { currentVersion = 1 } if body.Version > 0 && body.Version != currentVersion { writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion}) return } applyBoolPatch(governance, "join_approval", body.JoinApproval) applyBoolPatch(governance, "mute_all", body.MuteAll) applyBoolPatch(governance, "member_upload_files", body.MemberUploadFiles) applyBoolPatch(governance, "member_private_chat", body.MemberPrivateChat) applyBoolPatch(governance, "member_invite", body.MemberInvite) governance["version"] = currentVersion + 1 aux["governance"] = governance if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, governance) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMGroupRoleLimits(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{"max_admin_count": groupMaxAdminCount, "current_admin_count": countGroupAdmins(members)}) } func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail string) { principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } switch { case tail == "admins/batch" && req.Method == http.MethodPost: var body imGroupAdminsBatchRequest if !decodeIMJSONBody(wrt, req, &body) { return } if len(body.UserIDs) == 0 || len(body.UserIDs) > groupMaxAdminCount { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"}) return } members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if countGroupAdmins(members)+len(body.UserIDs) > groupMaxAdminCount { writeIMError(wrt, req, http.StatusConflict, 30006, "GROUP_ADMIN_LIMIT_EXCEEDED", "group admin limit exceeded", map[string]any{"max": groupMaxAdminCount, "current": countGroupAdmins(members), "requested": len(body.UserIDs)}) return } results := make([]map[string]any, 0, len(body.UserIDs)) for _, rawID := range body.UserIDs { uid := types.ParseUserId(strings.TrimSpace(rawID)) item := map[string]any{"user_id": strings.TrimSpace(rawID)} if uid.IsZero() || uid == principal.Uid { item["ok"] = false item["reason"] = imReasonMalformedRequest results = append(results, item) continue } item["user_id"] = uid.UserId() if err := store.Subs.Update(principal.TenantID, topic, uid, map[string]any{"ModeGiven": types.ModeCAdmin | types.ModeCPublic}); err != nil { item["ok"] = false item["reason"] = relationErrorReason(err) } else { item["ok"] = true } results = append(results, item) } members, _ = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) writeIMSuccess(wrt, req, map[string]any{"succeeded": results, "admins": groupAdminItems(members), "version": body.Version + 1, "updated_at": time.Now().UTC().Format(time.RFC3339Nano)}) case strings.HasPrefix(tail, "admins/") && req.Method == http.MethodDelete: userID := strings.TrimPrefix(tail, "admins/") uid := types.ParseUserId(strings.TrimSpace(userID)) if uid.IsZero() || uid == principal.Uid { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) return } if err := store.Subs.Update(principal.TenantID, topic, uid, map[string]any{"ModeGiven": types.ModeCPublic}); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "role": "member", "updated_at": time.Now().UTC().Format(time.RFC3339Nano)}) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMGroupOwnerTransfer(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } var body imGroupOwnerTransferRequest if !decodeIMJSONBody(wrt, req, &body) { return } target := types.ParseUserId(strings.TrimSpace(body.TargetUserID)) if target.IsZero() || target == principal.Uid { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_user_id"}) return } targetSub, err := store.Subs.Get(principal.TenantID, topic, target, false) if err != nil || targetSub == nil { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil) return } if err = store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"ModeGiven": types.ModeCAdmin | types.ModeCPublic}); err != nil { writeIMRelationError(wrt, req, err) return } if err = store.Subs.Update(principal.TenantID, topic, target, map[string]any{"ModeGiven": types.ModeCFull}); err != nil { writeIMRelationError(wrt, req, err) return } _ = store.Topics.OwnerChange(principal.TenantID, topic, target) writeIMSuccess(wrt, req, map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId(), "version": body.Version + 1}) } func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail string) { principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } group, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } aux := groupAuxMap(group.Aux) switch { case tail == "mutes" && req.Method == http.MethodGet: writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMuteItems(aux), "next_cursor": ""}) case tail == "mutes" && req.Method == http.MethodPost: var body imGroupMuteBatchRequest if !decodeIMJSONBody(wrt, req, &body) { return } if len(body.Members) == 0 || len(body.Members) > 100 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "members"}) return } mutes := groupMuteItems(aux) now := time.Now().UTC().Round(time.Millisecond) for _, member := range body.Members { uid := types.ParseUserId(strings.TrimSpace(member.UserID)) if uid.IsZero() { continue } mutes = upsertGroupMute(mutes, map[string]any{"user_id": uid.UserId(), "muted_by": principal.Uid.UserId(), "muted_at": now.Format(time.RFC3339Nano), "expires_at": strings.TrimSpace(member.ExpiresAt), "reason": strings.TrimSpace(body.Reason)}) } aux["mutes"] = mutes if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": mutes}) case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete: uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/"))) if uid.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) return } aux["mutes"] = removeGroupMute(groupMuteItems(aux), uid.UserId()) if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "muted": false}) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } if types.GetTopicCat(topic) != types.TopicCatGrp || memberID.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) 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) return } if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) return } memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil) return } user, err := store.Users.Get(principal.TenantID, memberID) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID)) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx)) } func serveIMMeQRCodes(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imQRCodeCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } purpose := strings.TrimSpace(body.Purpose) if purpose == "" { purpose = "add_friend" } if purpose != "add_friend" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"}) return } expiresIn := body.ExpiresInSeconds if expiresIn <= 0 { expiresIn = imQRCodeDefaultExpiresInSeconds } if expiresIn > imQRCodeMaxExpiresInSeconds { expiresIn = imQRCodeMaxExpiresInSeconds } expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond) token, err := generateIMQRCodeToken(principal.TenantID, principal.Uid, purpose, expiresAt) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } user, err := store.Users.Get(principal.TenantID, principal.Uid) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } data := map[string]any{ "qr_token": token, "qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose), "expires_at": expiresAt.Format(time.RFC3339Nano), } if user != nil { data["display_profile"] = userSummaryPayload(user, "") } writeIMSuccess(wrt, req, data) } func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imQRCodeResolveRequest if !decodeIMJSONBody(wrt, req, &body) { return } claims, err := parseIMQRCodeToken(strings.TrimSpace(body.Token), strings.TrimSpace(body.Purpose), time.Now().UTC()) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "qr code invalid", nil) return } if claims.TenantID != principal.TenantID { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } if claims.Purpose == "join_group" { if types.GetTopicCat(claims.Topic) != types.TopicCatGrp { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"}) return } group, err := store.Topics.Get(principal.TenantID, claims.Topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if group == nil { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) return } selfSub, _ := store.Subs.Get(principal.TenantID, claims.Topic, principal.Uid, false) status := "can_request" if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() { status = "joined" } writeIMSuccess(wrt, req, map[string]any{ "purpose": claims.Purpose, "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), "group": groupDetailPayload(group, selfSub), "status": status, "source": "qr", }) return } peer := types.ParseUserId(claims.UserID) if peer.IsZero() || peer == principal.Uid { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"}) return } user, err := store.Users.Get(principal.TenantID, peer) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if user == nil { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil) return } ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer)) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "purpose": claims.Purpose, "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), "user": userSearchItemPayload(types.UserSearchItem{User: user, Relationship: ctx.Relationship, RequestID: ctx.RequestID, CanAdd: ctx.Relationship == types.RelationshipNone, Action: imRelationshipAction(ctx.Relationship)}), "source": "qr", "source_topic": "", }) } func serveIMUserSearch(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } searchType := strings.TrimSpace(req.URL.Query().Get("type")) query := strings.TrimSpace(req.URL.Query().Get("q")) if searchType == "phone" { if phone, err := normalizeIMPhone(query, "CN"); err == nil { query = phone } } if query == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"}) return } limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 50) items, nextCursor, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, query, searchType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } payload := make([]map[string]any, 0, len(items)) for _, item := range items { payload = append(payload, userSearchItemPayload(item)) } writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) { userID, tail, ok := userResourcePath(req.URL.Path) if !ok || tail != "profile" { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil) return } serveIMUserProfile(wrt, req, userID) } func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID types.Uid) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } if userID.IsZero() || userID == principal.Uid { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) return } user, err := store.Users.Get(principal.TenantID, userID) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if user == nil { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil) return } ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID)) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, userProfilePayload(user, ctx)) } func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } requestID, action := friendRequestActionFromPath(req.URL.Path) status := "" switch action { case "accept": status = types.FriendRequestAccepted case "reject": status = types.FriendRequestRejected default: writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "friend request action not found", nil) return } updated, err := store.Relations.ResolveFriendRequest(principal.TenantID, requestID, principal.Uid, status, time.Now().UTC().Round(time.Millisecond)) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, friendRequestPayload(updated)) } func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) { if strings.TrimSuffix(req.URL.Path, "/") == "/v1/im/blocks/batch-delete" { serveIMBlocksBatchDelete(wrt, req) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } peer := userIDFromTail(req.URL.Path, "/v1/im/blocks/") if peer.IsZero() || peer == principal.Uid { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } switch req.Method { case http.MethodPut: if _, err := store.Relations.BlockUser(principal.TenantID, principal.Uid, peer, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return } case http.MethodDelete: if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil { writeIMRelationError(wrt, req, err) return } default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer)) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, relationshipContextPayload(ctx)) } func serveIMBlocks(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) items, nextCursor, err := store.Relations.ListBlocks(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } payload := make([]map[string]any, 0, len(items)) for _, item := range items { payload = append(payload, blockListItemPayload(item)) } writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } func serveIMBlocksBatchDelete(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imBlockBatchDeleteRequest if !decodeIMJSONBody(wrt, req, &body) { return } if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"}) return } results := make([]map[string]any, 0, len(body.UserIDs)) for _, rawID := range body.UserIDs { peer := types.ParseUserId(strings.TrimSpace(rawID)) item := map[string]any{"user_id": strings.TrimSpace(rawID)} if peer.IsZero() || peer == principal.Uid { item["ok"] = false item["reason"] = imReasonMalformedRequest results = append(results, item) continue } item["user_id"] = peer.UserId() if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil { item["ok"] = false item["reason"] = relationErrorReason(err) } else { item["ok"] = true } results = append(results, item) } writeIMSuccess(wrt, req, map[string]any{"items": results}) } func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } peer := userIDFromTail(req.URL.Path, "/v1/im/friends/") if peer.IsZero() || peer == principal.Uid { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } now := time.Now().UTC().Round(time.Millisecond) switch req.Method { case http.MethodDelete: if err := store.Relations.DeleteFriend(principal.TenantID, principal.Uid, peer, now); err != nil { writeIMRelationError(wrt, req, err) return } case http.MethodPatch: var body imFriendUpdateRequest if !decodeIMJSONBody(wrt, req, &body) { return } if _, err := store.Relations.UpdateFriendRemark(principal.TenantID, principal.Uid, peer, strings.TrimSpace(body.Remark), now); err != nil { writeIMRelationError(wrt, req, err) return } default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer)) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, relationshipContextPayload(ctx)) } func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imIndependentPasswordRequest if !decodeIMJSONBody(wrt, req, &body) { return } purpose := strings.TrimSpace(body.Purpose) if purpose == "" { purpose = "youth_mode" } if !validIMIndependentPassword(body.Password) || purpose != "youth_mode" { writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil) return } if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", purpose, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) return } hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if err := store.Security.UpsertIndependentPassword(principal.TenantID, &types.IndependentPassword{ TenantID: principal.TenantID, UserID: principal.Uid, Purpose: purpose, PasswordHash: hash, }); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{"status": "ok", "purpose": purpose}) } func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imIndependentPasswordVerifyRequest if !decodeIMJSONBody(wrt, req, &body) { return } purpose := strings.TrimSpace(body.Purpose) if purpose == "" { purpose = "youth_mode" } action := strings.TrimSpace(body.Action) if action == "" { action = "close_youth_mode" } pass, err := store.Security.GetIndependentPassword(principal.TenantID, principal.Uid, purpose) if err != nil { writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) return } now := time.Now().UTC().Round(time.Millisecond) if pass.LockedUntil != nil && now.Before(*pass.LockedUntil) { writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{ "retry_after_seconds": int(time.Until(*pass.LockedUntil).Seconds()) + 1, }) return } if bcrypt.CompareHashAndPassword(pass.PasswordHash, []byte(body.Password)) != nil { attempts := pass.FailedAttempts + 1 var lockedUntil *time.Time if attempts >= imIndependentPasswordMaxAttempts { until := now.Add(imIndependentPasswordLockSeconds * time.Second) lockedUntil = &until } _ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, attempts, lockedUntil, now) if lockedUntil != nil { writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{ "retry_after_seconds": imIndependentPasswordLockSeconds, }) return } writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil) return } _ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, 0, nil, now) token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, action, purpose) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{ "action_token": token, "action": action, "expires_at": expiresAt.Format(time.RFC3339Nano), }) } func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, challengeID, code, scene string) (*types.VerificationCode, string, bool) { if len(code) != imVerificationCodeLength { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"}) return nil, "", false } vc, err := store.Verifications.Get(tenantID, challengeID) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil) return nil, "", false } logs.Warn.Println("im verification verify: lookup failed", err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, "", false } now := time.Now().UTC().Round(time.Millisecond) if vc.Scene != scene || vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts { writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil) return nil, "", false } if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) { _ = store.Verifications.Fail(tenantID, challengeID, types.VerificationStateExpired) writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return nil, "", false } if vc.CodeHash != hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) { nextState := types.VerificationStatePending if vc.Attempts+1 >= vc.MaxAttempts { nextState = types.VerificationStateLocked } _ = store.Verifications.Fail(tenantID, challengeID, nextState) if nextState == types.VerificationStateLocked { writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil) return nil, "", false } writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil) return nil, "", false } token, err := randomIMToken(32) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, "", false } if err := store.Verifications.MarkVerified(tenantID, challengeID, hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil { logs.Warn.Println("im verification verify: mark verified failed", err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, "", false } return vc, token, true } func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPrincipal, bool) { authz := strings.TrimSpace(req.Header.Get("Authorization")) token, ok := strings.CutPrefix(authz, "Bearer ") if !ok || strings.TrimSpace(token) == "" || store.Store == nil { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) return nil, false } handler := store.Store.GetLogicalAuthHandler("token") if handler == nil || !handler.IsInitialized() { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, false } rec, _, err := handler.Authenticate(auth.AuthContext{}, []byte(strings.TrimSpace(token))) if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) return nil, false } return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, true } func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { creds, err := store.Users.GetAllCreds(tenantID, uid, "tel", true) if err != nil { return "", err } if len(creds) == 0 || strings.TrimSpace(creds[0].Value) == "" { return "", types.ErrNotFound } return strings.TrimSpace(creds[0].Value), nil } func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) { now := time.Now().UTC().Round(time.Millisecond) phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone") active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now) if err != nil && !errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if active != nil && now.Before(active.ResendAfter) { writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ "challenge_id": active.ChallengeID, "retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1, }) return } challengeID, err := randomIMToken(18) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } code, err := randomIMNumericCode(imVerificationCodeLength) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } vc := &types.VerificationCode{ TenantID: principal.TenantID, ChallengeID: challengeID, Scene: "sensitive_action", Action: action, PhoneHash: phoneHash, PhoneMask: maskIMPhone(phone), TargetUser: principal.Uid, CodeHash: hashIMSecret(principal.TenantID, "sensitive_action", challengeID, phoneHash, code), State: types.VerificationStatePending, MaxAttempts: imVerificationMaxAttempts, ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second), ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second), } if err := store.Verifications.Create(principal.TenantID, vc); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } logs.Info.Println("im security verification code generated:", action, vc.PhoneMask) writeIMSuccess(wrt, req, map[string]any{ "challenge_id": vc.ChallengeID, "phone_mask": vc.PhoneMask, "expires_in_seconds": imVerificationExpiresInSeconds, "resend_after_seconds": imVerificationResendAfterSeconds, }) } func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target string) (string, time.Time, error) { if action == "" { return "", time.Time{}, types.ErrMalformed } tokenID, err := randomIMToken(12) if err != nil { return "", time.Time{}, err } secret, err := randomIMToken(32) if err != nil { return "", time.Time{}, err } expiresAt := time.Now().UTC().Round(time.Millisecond).Add(imActionTokenExpiresInSeconds * time.Second) if err := store.Security.CreateActionToken(tenantID, &types.ActionToken{ TenantID: tenantID, UserID: uid, TokenID: tokenID, Action: action, Target: target, TokenHash: hashIMActionToken(tenantID, uid, action, target, tokenID, secret), State: 0, ExpiresAt: expiresAt, }); err != nil { return "", time.Time{}, err } return tokenID + "." + secret, expiresAt, nil } func defaultIMActionTarget(action, fallback string) string { switch action { case "set_independent_password", "close_youth_mode", "reset_independent_password": return "youth_mode" default: return fallback } } func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string { h := sha256.New() h.Write([]byte(fmt.Sprintf("%d", tenantID))) h.Write([]byte{0}) h.Write([]byte(fmt.Sprintf("%d", store.DecodeUid(uid)))) h.Write([]byte{0}) h.Write([]byte(action)) h.Write([]byte{0}) h.Write([]byte(target)) h.Write([]byte{0}) h.Write([]byte(tokenID)) h.Write([]byte{0}) h.Write([]byte(secret)) return fmt.Sprintf("%x", h.Sum(nil)) } func realNamePayload(rv *types.RealNameVerification) map[string]any { canSubmit := rv.Status == "unverified" || rv.Status == "rejected" data := map[string]any{ "status": rv.Status, "submission_id": rv.SubmissionID, "masked_name": rv.MaskedName, "masked_id_number": rv.MaskedIDNumber, "can_submit": canSubmit, } if rv.VerifiedAt != nil { data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano) } if rv.ReviewReason != nil { data["review_reason"] = *rv.ReviewReason } return data } func parseIMLimit(raw string, fallback, max int) int { limit := fallback if raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { limit = parsed } } if limit > max { limit = max } return limit } func parseIMNonNegativeCursor(raw string) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { return 0, nil } cursor, err := strconv.Atoi(raw) if err != nil || cursor < 0 { return 0, errors.New("invalid cursor") } return cursor, nil } func parseIMBool(raw string) bool { switch strings.ToLower(strings.TrimSpace(raw)) { case "1", "true", "yes", "y": return true default: return false } } func conversationMatches(sub types.Subscription, query string) bool { if strings.Contains(strings.ToLower(sub.Topic), query) { return true } encoded, _ := json.Marshal(sub.GetPublic()) return strings.Contains(strings.ToLower(string(encoded)), query) } func conversationPayload(sub types.Subscription) map[string]any { unread := sub.GetSeqId() - sub.ReadSeqId if unread < 0 { unread = 0 } return map[string]any{ "topic": sub.Topic, "public": sub.GetPublic(), "private": sub.Private, "updated_at": sub.LastModified().UTC().Format(time.RFC3339Nano), "seq": sub.GetSeqId(), "read": sub.ReadSeqId, "recv": sub.RecvSeqId, "unread": unread, "with": sub.GetWith(), } } func receiptMemberPayload(sub types.Subscription) map[string]any { return map[string]any{ "user_id": sub.User, "public": sub.GetPublic(), "read": sub.ReadSeqId, "recv": sub.RecvSeqId, } } func groupListItemPayload(sub types.Subscription) map[string]any { public := groupPublicMap(sub.GetPublic()) access := sub.ModeGiven & sub.ModeWant return map[string]any{ "topic": sub.Topic, "name": groupName(public, sub.Topic), "avatar_file_id": stringFromAny(public["avatar_file_id"]), "member_count": sub.GetSubCnt(), "my_role": groupMemberRole(access), "updated_at": sub.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any { public := map[string]any{} topic := "" memberCount := 0 ownerID := "" if group != nil { public = groupPublicMap(group.Public) topic = group.Id memberCount = group.SubCnt if owner := types.ParseUid(group.Owner); !owner.IsZero() { ownerID = owner.UserId() } } version := intFromAny(public["version"]) if version <= 0 { version = 1 } access := types.ModeNone if selfSub != nil { access = selfSub.ModeGiven & selfSub.ModeWant if topic == "" { topic = selfSub.Topic } } return map[string]any{ "topic": topic, "name": groupName(public, topic), "avatar_file_id": stringFromAny(public["avatar_file_id"]), "description": stringFromAny(public["description"]), "owner": ownerID, "member_count": memberCount, "version": version, "my_role": groupMemberRole(access), "permissions": groupPermissions(access), } } func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any { data := groupDetailPayload(group, selfSub) private := map[string]any{} if selfSub != nil { private = groupPrivateMap(selfSub.Private) } data["pinned"] = boolFromAny(private["pinned"]) data["mute_notifications"] = boolFromAny(private["mute_notifications"]) data["message_retention"] = stringFromAny(private["message_retention"]) if data["message_retention"] == "" { data["message_retention"] = "forever" } data["governance"] = defaultGroupGovernance() return data } func groupMemberSettingsPayload(sub *types.Subscription) map[string]any { private := map[string]any{} if sub != nil { private = groupPrivateMap(sub.Private) } return map[string]any{ "pinned": boolFromAny(private["pinned"]), "mute_notifications": boolFromAny(private["mute_notifications"]), "version": intFromAny(private["version"]), "updated_at": stringFromAny(private["updated_at"]), } } func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any { access := sub.ModeGiven & sub.ModeWant userID := types.ParseUserId(sub.User) user := &types.User{ObjHeader: types.ObjHeader{Id: userID.String()}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()} data := userSummaryPayload(user, "") if private := groupPrivateMap(sub.Private); stringFromAny(private["nickname"]) != "" { data["group_nickname"] = stringFromAny(private["nickname"]) } data["role"] = groupMemberRole(access) data["mentionable"] = mentionable data["acs"] = map[string]any{ "mode": access.String(), "want": sub.ModeWant.String(), "given": sub.ModeGiven.String(), } return data } func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any { data := groupMemberPayload(*sub, true) data["topic"] = topic data["relationship"] = ctx.Relationship data["capabilities"] = map[string]any{ "can_send": ctx.CanSend, "can_call": ctx.CanSend, "can_add_friend": ctx.Relationship == types.RelationshipNone, } if ctx.RequestID != "" { data["request_id"] = ctx.RequestID } if user != nil { for key, value := range userSummaryPayload(user, "") { if key == "avatar_file_id" || key == "display_name" { data[key] = value } } if public, ok := user.Public.(map[string]any); ok { copyOptionalProfileFields(data, public) } else if public, ok := user.Public.(types.KVMap); ok { copyOptionalProfileFields(data, map[string]any(public)) } } return data } func groupMemberRole(access types.AccessMode) string { switch { case access.IsOwner(): return "owner" case access.IsAdmin(): return "admin" default: return "member" } } func groupCategory(role string) string { switch role { case "owner": return "owned" case "admin": return "managed" default: return "joined" } } func groupPermissions(access types.AccessMode) map[string]any { return map[string]any{ "can_edit_profile": access.IsAdmin(), "can_invite_members": access.IsSharer() || access.IsAdmin(), "can_manage_members": access.IsAdmin(), "can_manage_settings": access.IsAdmin(), "can_leave": !access.IsOwner() && access.IsJoiner(), } } func defaultGroupGovernance() map[string]any { return map[string]any{ "join_approval": false, "mute_all": false, "member_upload_files": true, "member_private_chat": true, "member_invite": true, "version": 1, } } func groupGovernanceMap(value any) map[string]any { governance := defaultGroupGovernance() for key, value := range groupPrivateMap(value) { governance[key] = value } return governance } func groupGovernancePayload(group *types.Topic, selfSub *types.Subscription) map[string]any { aux := map[string]any{} if group != nil { aux = groupAuxMap(group.Aux) } governance := groupGovernanceMap(aux["governance"]) editable := false if selfSub != nil { editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() } for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} { governance[key+"_editable"] = editable } return governance } func groupAuxMap(value any) map[string]any { switch aux := value.(type) { case map[string]any: return copyStringAnyMap(aux) case types.KVMap: return copyStringAnyMap(map[string]any(aux)) default: return map[string]any{} } } func groupPublicMap(value any) map[string]any { switch public := value.(type) { case map[string]any: return copyStringAnyMap(public) case types.KVMap: return copyStringAnyMap(map[string]any(public)) default: return map[string]any{} } } func groupJoinRequests(group *types.Topic) []map[string]any { if group == nil { return nil } return sliceOfMaps(groupAuxMap(group.Aux)["join_requests"]) } func filterGroupJoinRequests(items []map[string]any, status string) []map[string]any { out := make([]map[string]any, 0, len(items)) for _, item := range items { if status == "" || stringFromAny(item["status"]) == status { out = append(out, item) } } return out } func resolveGroupJoinRequest(items []map[string]any, requestID, action string, actor types.Uid, version int) (map[string]any, bool) { now := time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano) for i := range items { if stringFromAny(items[i]["request_id"]) != requestID { continue } currentVersion := intFromAny(items[i]["version"]) if version > 0 && currentVersion > 0 && version != currentVersion { items[i]["status"] = "conflict" return items[i], true } switch action { case "accept": items[i]["status"] = "accepted" case "reject": items[i]["status"] = "rejected" default: return nil, false } items[i]["handled_by"] = actor.UserId() items[i]["handled_at"] = now items[i]["updated_at"] = now items[i]["version"] = currentVersion + 1 return items[i], true } return nil, false } func latestGroupAnnouncement(group *types.Topic) map[string]any { if group == nil { return map[string]any{} } return groupPrivateMap(groupAuxMap(group.Aux)["announcement"]) } func groupMuteItems(aux map[string]any) []map[string]any { return sliceOfMaps(aux["mutes"]) } func upsertGroupMute(items []map[string]any, item map[string]any) []map[string]any { userID := stringFromAny(item["user_id"]) for i := range items { if stringFromAny(items[i]["user_id"]) == userID { items[i] = item return items } } return append(items, item) } func removeGroupMute(items []map[string]any, userID string) []map[string]any { out := make([]map[string]any, 0, len(items)) for _, item := range items { if stringFromAny(item["user_id"]) != userID { out = append(out, item) } } return out } func sliceOfMaps(value any) []map[string]any { raw, ok := value.([]any) if !ok { if typed, ok := value.([]map[string]any); ok { return typed } return nil } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { if m, ok := item.(map[string]any); ok { out = append(out, copyStringAnyMap(m)) } } return out } func applyBoolPatch(target map[string]any, key string, value *bool) { if value != nil { target[key] = *value } } func groupRoleEligibility(actorAccess, memberAccess types.AccessMode, role string) (bool, string) { if !actorAccess.IsOwner() { return false, "owner_required" } switch role { case "admin": if memberAccess.IsOwner() { return false, "owner" } if memberAccess.IsAdmin() { return false, "already_admin" } return true, "" case "owner": if memberAccess.IsOwner() { return false, "current_owner" } return memberAccess.IsJoiner(), "" default: return false, "unsupported_role" } } func groupMuteEligibility(actorAccess, memberAccess types.AccessMode) (bool, string) { if !actorAccess.IsAdmin() { return false, "admin_required" } if memberAccess.IsOwner() { return false, "owner" } return memberAccess.IsJoiner(), "" } func countGroupAdmins(members []types.Subscription) int { count := 0 for _, member := range members { access := member.ModeGiven & member.ModeWant if access.IsAdmin() && !access.IsOwner() { count++ } } return count } func groupAdminItems(members []types.Subscription) []map[string]any { items := make([]map[string]any, 0) for _, member := range members { access := member.ModeGiven & member.ModeWant if access.IsAdmin() && !access.IsOwner() { items = append(items, groupMemberPayload(member, true)) } } return items } func groupPrivateMap(value any) map[string]any { switch private := value.(type) { case map[string]any: return copyStringAnyMap(private) case types.KVMap: return copyStringAnyMap(map[string]any(private)) default: return map[string]any{} } } func groupMemberPrivate(nickname string, pinned, mute bool, version int, updatedAt time.Time) map[string]any { return map[string]any{ "nickname": nickname, "pinned": pinned, "mute_notifications": mute, "version": version, "profile_version": 1, "updated_at": updatedAt.UTC().Format(time.RFC3339Nano), } } func groupName(public map[string]any, fallback string) string { for _, key := range []string{"name", "display_name", "fn"} { value := strings.TrimSpace(fmt.Sprint(public[key])) if value != "" && value != "" { return value } } return fallback } func groupListItemMatches(item map[string]any, query string) bool { query = strings.ToLower(strings.TrimSpace(query)) return query == "" || strings.Contains(strings.ToLower(fmt.Sprint(item["topic"])), query) || strings.Contains(strings.ToLower(fmt.Sprint(item["name"])), query) } func groupMemberMatches(item map[string]any, query string) bool { query = strings.ToLower(strings.TrimSpace(query)) if query == "" { return true } for _, key := range []string{"display_name", "user_id"} { if value, ok := item[key]; ok && strings.Contains(strings.ToLower(fmt.Sprint(value)), query) { return true } } return false } func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) { if principal == nil { return nil, false, types.ErrPermissionDenied } sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) if err != nil { return nil, false, err } if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() { return nil, false, types.ErrPermissionDenied } msg, err := store.Messages.GetOne(principal.TenantID, topic, seq) if err != nil { return nil, false, err } if msg == nil { return nil, false, types.ErrNotFound } if msg.RecalledAt != nil || msg.DeletedAt != nil { return msg, false, types.ErrDuplicate } from := types.ParseUid(msg.From) access := sub.ModeGiven & sub.ModeWant authorized := from == principal.Uid || (allowAdmin && access.IsAdmin()) return msg, authorized, nil } func isIMEditableTextMessage(msg *types.Message) bool { if msg == nil { return false } if msg.Head == nil { return true } msgType, _ := msg.Head["type"].(string) return msgType == "" || msgType == "text" } func writeIMMessageMutationError(wrt http.ResponseWriter, req *http.Request, err error) { switch { case errors.Is(err, types.ErrNotFound): writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found", nil) case errors.Is(err, types.ErrDuplicate): writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict", nil) case errors.Is(err, types.ErrPermissionDenied): writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) case errors.Is(err, types.ErrMalformed): writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) default: writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) } } func messageMutationPayload(msg *types.Message, status string) map[string]any { data := map[string]any{ "status": status, "topic": msg.Topic, "seq": msg.SeqId, "version": msg.Version, "updated_at": msg.UpdatedAt.UTC().Format(time.RFC3339Nano), } if msg.RecalledAt != nil { data["recalled_at"] = msg.RecalledAt.UTC().Format(time.RFC3339Nano) data["recalled_by"] = msg.RecalledBy } if status == "edited" { data["head"] = msg.Head data["content"] = msg.Content } return data } func favoritePayload(item types.FavoriteItem) map[string]any { return map[string]any{ "favorite_id": item.FavoriteID, "type": item.ItemType, "snapshot": item.Snapshot, "source": map[string]any{"topic": item.SourceTopic, "seq": item.SourceSeq}, "collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano), "availability": item.Availability, } } func messageSearchPayload(msg types.Message, query string) map[string]any { text := messageText(msg) return map[string]any{ "seq": msg.SeqId, "from": msg.From, "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(&msg), "snippet": textSnippet(text, query), "matches": textMatches(text, query), "snapshot": favoriteSnapshot(&msg), "availability": messageAvailability(msg), } } func messageContextPayload(msg types.Message) map[string]any { return map[string]any{ "seq": msg.SeqId, "from": msg.From, "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(&msg), "head": msg.Head, "content": msg.Content, "availability": messageAvailability(msg), } } func mediaMessagePayload(msg types.Message) map[string]any { snapshot := favoriteSnapshot(&msg) data := map[string]any{ "seq": msg.SeqId, "from": msg.From, "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "availability": messageAvailability(msg), } if content, ok := snapshot["content"].(map[string]any); ok { for key, value := range content { data[key] = value } } return data } func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any { data := map[string]any{ "topic": ctx.Topic, "peer_user_id": ctx.PeerID.UserId(), "relationship": ctx.Relationship, "can_send": ctx.CanSend, "capabilities": map[string]any{ "can_send_text": ctx.CanSend, "can_send_media": ctx.CanSend, "can_send_card": ctx.CanSend, "can_call": ctx.CanSend, "can_add_friend": ctx.Relationship == types.RelationshipNone, "can_handle_request": ctx.Relationship == types.RelationshipPendingInbound, }, } if ctx.RequestID != "" { data["request_id"] = ctx.RequestID } if ctx.Peer != nil { data["peer"] = map[string]any{ "user_id": ctx.PeerID.UserId(), "public": ctx.Peer.Public, "trusted": ctx.Peer.Trusted, } } return data } func friendRequestPayload(req *types.FriendRequest) map[string]any { data := map[string]any{ "request_id": req.RequestID, "applicant_id": req.ApplicantID.UserId(), "target_user_id": req.TargetID.UserId(), "message": req.Message, "source": req.Source, "status": req.Status, "created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano), "expires_at": req.ExpiresAt.UTC().Format(time.RFC3339Nano), } if topic := req.ApplicantID.P2PName(req.TargetID); topic != "" { data["topic"] = topic } if req.SourceTopic != "" { data["source_topic"] = req.SourceTopic } if req.HandledAt != nil { data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano) } return data } func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]any { data := friendRequestPayload(&item.Request) data["direction"] = item.Direction if item.Peer != nil { data["peer"] = userSummaryPayload(item.Peer, "") } return data } func friendListItemPayload(item types.FriendListItem) map[string]any { data := map[string]any{ "user_id": item.Friendship.PeerID.UserId(), "relationship": item.Friendship.Status, "remark": item.Friendship.Remark, "topic": item.Friendship.Topic, "online_visibility": "unknown", "created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano), "capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": item.Friendship.Status == types.RelationshipFriend}, "display_name_source": "profile", } if item.Friendship.Remark != "" { data["display_name"] = item.Friendship.Remark data["display_name_source"] = "remark" } if item.Peer != nil { for key, value := range userSummaryPayload(item.Peer, item.Friendship.Remark) { _, exists := data[key] if !exists || key == "avatar_file_id" || (key == "display_name" && item.Friendship.Remark == "") { data[key] = value } } } data["initial"] = displayInitial(data["display_name"]) return data } func userSearchItemPayload(item types.UserSearchItem) map[string]any { data := userSummaryPayload(item.User, "") data["relationship"] = item.Relationship data["can_add"] = item.CanAdd data["action"] = item.Action if item.RequestID != "" { data["request_id"] = item.RequestID } return data } func imRelationshipAction(relationship string) string { switch relationship { case types.RelationshipFriend: return "chat" case types.RelationshipPendingInbound, types.RelationshipPendingOutbound: return "pending" case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer: return "none" default: return "add" } } func blockListItemPayload(item types.BlockListItem) map[string]any { data := map[string]any{ "user_id": item.Block.BlockedID.UserId(), "blocked_at": item.Block.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": item.Block.UpdatedAt.UTC().Format(time.RFC3339Nano), } if item.User != nil { data["user"] = userSummaryPayload(item.User, "") } return data } func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any { data := userSummaryPayload(user, "") data["relationship"] = ctx.Relationship data["topic"] = ctx.Topic data["capabilities"] = map[string]any{ "can_send": ctx.CanSend, "can_call": ctx.CanSend, "can_add_friend": ctx.Relationship == types.RelationshipNone, } if ctx.RequestID != "" { data["request_id"] = ctx.RequestID } if public, ok := user.Public.(map[string]any); ok { copyOptionalProfileFields(data, public) } else if public, ok := user.Public.(types.KVMap); ok { copyOptionalProfileFields(data, map[string]any(public)) } return data } func copyOptionalProfileFields(data map[string]any, public map[string]any) { for _, key := range []string{"gender", "region", "public_account", "signature"} { if value, ok := public[key]; ok { data[key] = value } } } func userSummaryPayload(user *types.User, remark string) map[string]any { uid := types.ParseUid(user.Id) userID := uid.UserId() if uid.IsZero() { userID = user.Id } data := map[string]any{"user_id": userID} if remark != "" { data["display_name"] = remark } if public, ok := user.Public.(map[string]any); ok { copyUserPublicSummary(data, public) } else if public, ok := user.Public.(types.KVMap); ok { copyUserPublicSummary(data, map[string]any(public)) } if _, ok := data["display_name"]; !ok { data["display_name"] = userID } return data } func copyUserPublicSummary(data map[string]any, public map[string]any) { for _, key := range []string{"display_name", "fn", "name", "nickname"} { if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { data["display_name"] = value break } } for _, key := range []string{"avatar_file_id", "photo", "avatar"} { if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { data["avatar_file_id"] = value break } } } func displayInitial(value any) string { name := strings.TrimSpace(fmt.Sprint(value)) if name == "" { return "#" } for _, r := range name { return strings.ToUpper(string(r)) } return "#" } func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) { switch { case errors.Is(err, types.ErrNotFound): writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonMalformedRequest, "resource not found", nil) case errors.Is(err, types.ErrPermissionDenied): writeIMError(wrt, req, http.StatusForbidden, 20005, imReasonPermissionDenied, "permission denied", nil) case errors.Is(err, types.ErrMalformed): writeIMError(wrt, req, http.StatusBadRequest, 20000, imReasonMalformedRequest, "malformed request", nil) default: writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) } } func relationErrorReason(err error) string { switch { case errors.Is(err, types.ErrNotFound): return "NOT_FOUND" case errors.Is(err, types.ErrPermissionDenied): return imReasonPermissionDenied case errors.Is(err, types.ErrMalformed): return imReasonMalformedRequest default: return imReasonInternal } } func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, needRead bool) (*imPrincipal, *types.Subscription, bool) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return nil, nil, false } if types.GetTopicCat(topic) != types.TopicCatGrp { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return nil, nil, false } sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, nil, false } access := types.ModeNone if sub != nil { access = sub.ModeGiven & sub.ModeWant } if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil) return nil, nil, false } return principal, sub, true } func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return nil, "", false } if topic == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return nil, "", false } sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, "", false } if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) return nil, "", false } return principal, topic, true } func requireTopicWriter(principal *imPrincipal, topic string) error { if principal == nil || topic == "" { return types.ErrMalformed } sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) if err != nil { return err } if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsWriter() { return types.ErrPermissionDenied } return nil } func copyStringAnyMap(in map[string]any) map[string]any { out := make(map[string]any, len(in)) for key, value := range in { out[key] = value } return out } func stringFromAny(value any) string { if value == nil { return "" } return strings.TrimSpace(fmt.Sprint(value)) } func defaultString(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return strings.TrimSpace(value) } func boolFromAny(value any) bool { if got, ok := value.(bool); ok { return got } if got, ok := value.(string); ok { return got == "true" || got == "1" } return false } func intFromAny(value any) int { switch got := value.(type) { case int: return got case int64: return int(got) case float64: return int(got) case json.Number: parsed, _ := got.Int64() return int(parsed) case string: parsed, _ := strconv.Atoi(got) return parsed default: return 0 } } func imMessageType(msg *types.Message) string { if msg == nil { return "text" } for _, key := range []string{"type", "x-im-type"} { if value := strings.TrimSpace(fmt.Sprint(msg.Head[key])); value != "" && value != "" { return value } } if content, ok := msg.Content.(map[string]any); ok { if fileID := stringFromAny(content["file_id"]); fileID != "" { mime := stringFromAny(content["mime"]) switch { case strings.HasPrefix(mime, "image/"): return "image" case strings.HasPrefix(mime, "video/"): return "video" case strings.HasPrefix(mime, "audio/"): return "voice" default: return "file" } } if stringFromAny(content["url"]) != "" { return "link" } } return "text" } func favoriteSnapshot(msg *types.Message) map[string]any { if msg == nil { return map[string]any{} } return map[string]any{ "topic": msg.Topic, "seq": msg.SeqId, "from": msg.From, "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(msg), "head": msg.Head, "content": msg.Content, "text": messageText(*msg), } } func favoriteAttachmentRefs(msg *types.Message) any { if msg == nil { return []string{} } if content, ok := msg.Content.(map[string]any); ok { if fileID := stringFromAny(content["file_id"]); fileID != "" { return []string{fileID} } } return []string{} } func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string]any { payload := map[string]any{ "favorite_id": item.FavoriteID, "source": map[string]any{ "topic": item.SourceTopic, "seq": item.SourceSeq, }, "snapshot": item.Snapshot, "forwarded": true, } if refs, ok := item.AttachmentRefs.([]string); ok && len(refs) > 0 { payload["attachment_refs"] = refs } else if item.AttachmentRefs != nil { payload["attachment_refs"] = item.AttachmentRefs } if comment != "" { payload["comment"] = comment } return payload } func classifyIMURLRisk(rawURL string) (decision, riskLevel, reason string) { if rawURL == "" || len(rawURL) > 2048 { return "block", "high", "invalid_url" } parsed, err := url.Parse(rawURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "block", "high", "invalid_url" } scheme := strings.ToLower(parsed.Scheme) if scheme != "http" && scheme != "https" { return "block", "high", "unsupported_scheme" } host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") { return "block", "high", "private_host" } if ip := net.ParseIP(host); ip != nil { if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { return "block", "high", "private_host" } } normalized := strings.ToLower(rawURL) for _, marker := range []string{"javascript:", "data:", "file:", "password=", "passwd=", "token=", "access_token=", "xn--"} { if strings.Contains(normalized, marker) { return "warn", "medium", "suspicious_url" } } return "allow", "low", "ok" } func shortTokenHash(value string) string { sum := sha256.Sum256([]byte(value)) return base64.RawURLEncoding.EncodeToString(sum[:])[:16] } func messageText(msg types.Message) string { switch content := msg.Content.(type) { case string: return content case map[string]any: for _, key := range []string{"text", "content", "title", "summary", "name", "url"} { if value := stringFromAny(content[key]); value != "" { return value } } } encoded, _ := json.Marshal(msg.Content) return string(encoded) } func messageAvailability(msg types.Message) string { if msg.DeletedAt != nil || msg.RecalledAt != nil { return "removed" } return "available" } func textSnippet(text, query string) string { text = strings.TrimSpace(text) if len([]rune(text)) <= 120 || strings.TrimSpace(query) == "" { return text } lower := strings.ToLower(text) idx := strings.Index(lower, strings.ToLower(strings.TrimSpace(query))) if idx < 0 { idx = 0 } start := idx - 40 if start < 0 { start = 0 } end := start + 120 runes := []rune(text) if end > len(runes) { end = len(runes) } return string(runes[start:end]) } func textMatches(text, query string) []map[string]any { query = strings.TrimSpace(query) if query == "" { return []map[string]any{} } idx := strings.Index(strings.ToLower(text), strings.ToLower(query)) if idx < 0 { return []map[string]any{} } return []map[string]any{{"start": idx, "end": idx + len(query)}} } func validIMRealName(name, idType, idNumber string) bool { if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" { return false } return regexp.MustCompile(`^[0-9]{17}[0-9X]$`).MatchString(idNumber) } func protectIMSensitive(tenantID types.TenantID, value string) string { return base64.RawStdEncoding.EncodeToString([]byte(hashIMSecret(tenantID, value, "sensitive"))) } func maskIMName(name string) string { runes := []rune(name) if len(runes) <= 1 { return name } return string(runes[0]) + "*" } func maskIMIDNumber(id string) string { if len(id) <= 8 { return id } return id[:3] + strings.Repeat("*", len(id)-7) + id[len(id)-4:] } func validIMIndependentPassword(password string) bool { if !regexp.MustCompile(`^[0-9]{4}$`).MatchString(password) { return false } if password[0] == password[1] && password[1] == password[2] && password[2] == password[3] { return false } ascending, descending := true, true for i := 1; i < len(password); i++ { ascending = ascending && password[i] == password[i-1]+1 descending = descending && password[i]+1 == password[i-1] } return !ascending && !descending } func decodeIMJSONBody(wrt http.ResponseWriter, req *http.Request, dst any) bool { if req.Body == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return false } dec := json.NewDecoder(req.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return false } var extra any if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return false } else if err == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return false } return true } func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCode string) (*types.Tenant, bool) { tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code")) if tenantCode == "" { tenantCode = strings.TrimSpace(fallbackCode) } if tenantCode == "" || len(tenantCode) > 64 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"}) return nil, false } if store.Tenants == nil { logs.Err.Println("im http: tenant store is not initialized") writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, false } tenant, err := store.Tenants.GetByCode(tenantCode) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil) return nil, false } logs.Warn.Println("im http: failed to resolve tenant", tenantCode, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, false } if !tenant.IsActive() { writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil) return nil, false } return tenant, true } func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) { writeIMJSON(wrt, http.StatusOK, imHTTPResponse{ Code: 0, Data: data, RequestID: req.Header.Get("X-Request-ID"), ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano), }) } func writeIMError(wrt http.ResponseWriter, req *http.Request, status, code int, reason, message string, details any) { writeIMJSON(wrt, status, imHTTPResponse{ Code: code, Reason: reason, Message: message, Details: details, RequestID: req.Header.Get("X-Request-ID"), ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano), }) } func writeIMJSON(wrt http.ResponseWriter, status int, resp imHTTPResponse) { wrt.Header().Set("Content-Type", "application/json; charset=utf-8") wrt.WriteHeader(status) if err := json.NewEncoder(wrt).Encode(resp); err != nil { logs.Warn.Println("im http: failed to write response", err) } } func isAllowedVerificationScene(scene string) bool { switch scene { case "register", "login", "reset_password", "sensitive_action": return true default: return false } } func normalizeIMPhone(phone, country string) (string, error) { if strings.TrimSpace(country) == "" { country = "CN" } number, err := phonenumbers.Parse(strings.TrimSpace(phone), country) if err != nil || !phonenumbers.IsValidNumber(number) { return "", types.ErrMalformed } return phonenumbers.Format(number, phonenumbers.E164), nil } func maskIMPhone(phone string) string { if len(phone) <= 7 { return phone } return phone[:4] + "****" + phone[len(phone)-3:] } func randomIMNumericCode(length int) (string, error) { max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil) n, err := rand.Int(rand.Reader, max) if err != nil { return "", err } code := fmt.Sprintf("%0*d", length, n.Int64()) return code, nil } func randomIMToken(size int) (string, error) { buf := make([]byte, size) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil } func hashIMSecret(tenantID types.TenantID, parts ...string) string { h := sha256.New() h.Write(globals.apiKeySalt) h.Write([]byte(fmt.Sprintf(":%d:", tenantID))) for _, part := range parts { h.Write([]byte{0}) h.Write([]byte(part)) } return fmt.Sprintf("%x", h.Sum(nil)) } func generateIMQRCodeToken(tenantID types.TenantID, uid types.Uid, purpose string, expiresAt time.Time) (string, error) { return generateIMQRCodeTokenForTopic(tenantID, uid, "", purpose, expiresAt) } func generateIMQRCodeTokenForTopic(tenantID types.TenantID, uid types.Uid, topic, purpose string, expiresAt time.Time) (string, error) { nonce, err := randomIMToken(12) if err != nil { return "", err } claims := imQRCodeClaims{ Version: 1, TenantID: tenantID, UserID: uid.UserId(), Topic: topic, Purpose: purpose, Expires: expiresAt.Unix(), Nonce: nonce, } payload, err := json.Marshal(claims) if err != nil { return "", err } encodedPayload := base64.RawURLEncoding.EncodeToString(payload) mac := signIMQRCodePayload(encodedPayload) return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac), nil } func parseIMQRCodeToken(token, expectedPurpose string, now time.Time) (*imQRCodeClaims, error) { payload, signature, ok := strings.Cut(token, ".") if !ok || payload == "" || signature == "" { return nil, types.ErrMalformed } got, err := base64.RawURLEncoding.DecodeString(signature) if err != nil { return nil, types.ErrMalformed } want := signIMQRCodePayload(payload) if !hmac.Equal(got, want) { return nil, types.ErrMalformed } raw, err := base64.RawURLEncoding.DecodeString(payload) if err != nil { return nil, types.ErrMalformed } var claims imQRCodeClaims if err = json.Unmarshal(raw, &claims); err != nil { return nil, types.ErrMalformed } if claims.Version != 1 || claims.TenantID == 0 || claims.UserID == "" || claims.Purpose == "" || claims.Expires <= now.Unix() { return nil, types.ErrMalformed } if expectedPurpose != "" && claims.Purpose != expectedPurpose { return nil, types.ErrMalformed } return &claims, nil } func signIMQRCodePayload(payload string) []byte { mac := hmac.New(sha256.New, globals.apiKeySalt) mac.Write([]byte("im:qrcode:v1")) mac.Write([]byte{0}) mac.Write([]byte(payload)) return mac.Sum(nil) } func verificationChallengeIDFromPath(path string) string { const marker = "/v1/im/auth/verification-codes/" idx := strings.Index(path, marker) if idx < 0 { return "" } rest := strings.TrimPrefix(path[idx:], marker) challengeID, suffix, ok := strings.Cut(rest, "/") if !ok || suffix != "verify" { return "" } return strings.TrimSpace(challengeID) } func passwordResetChallengeIDFromPath(path string) string { const marker = "/v1/im/auth/password-reset/challenges/" idx := strings.Index(path, marker) if idx < 0 { return "" } rest := strings.TrimPrefix(path[idx:], marker) challengeID, suffix, ok := strings.Cut(rest, "/") if !ok || suffix != "verify" { return "" } return strings.TrimSpace(challengeID) } func securityChallengeIDFromPath(path string) string { const marker = "/v1/im/security/challenges/" idx := strings.Index(path, marker) if idx < 0 { return "" } rest := strings.TrimPrefix(path[idx:], marker) challengeID, suffix, ok := strings.Cut(rest, "/") if !ok || suffix != "verify" { return "" } return strings.TrimSpace(challengeID) } func systemNotificationIDFromPath(path string) string { const marker = "/v1/im/system-notifications/" idx := strings.Index(path, marker) if idx < 0 { return "" } rest := strings.TrimPrefix(path[idx:], marker) eventID, suffix, ok := strings.Cut(rest, "/") if !ok || suffix != "read" { return "" } return strings.TrimSpace(eventID) } func messageReceiptsPath(path string) (string, int, bool) { const marker = "/v1/im/topics/" idx := strings.Index(path, marker) if idx < 0 { return "", 0, false } rest := strings.TrimPrefix(path[idx:], marker) topic, rest, ok := strings.Cut(rest, "/messages/") if !ok || topic == "" { return "", 0, false } seqValue, suffix, ok := strings.Cut(rest, "/") if !ok || suffix != "receipts" { return "", 0, false } seq, err := strconv.Atoi(seqValue) if err != nil { return topic, 0, true } return topic, seq, true } func topicFromMessagesSearchPath(path string) string { const prefix = "/v1/im/topics/" const suffix = "/messages/search" return topicFromPathWithSuffix(path, prefix, suffix) } func topicFromMediaPath(path string) string { const prefix = "/v1/im/topics/" const suffix = "/media" return topicFromPathWithSuffix(path, prefix, suffix) } func topicFromPathWithSuffix(path, prefix, suffix string) string { idx := strings.Index(path, prefix) if idx < 0 || !strings.HasSuffix(path, suffix) { return "" } rest := strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix) return strings.Trim(rest, "/") } func messageContextPath(path string) (string, int, bool) { const prefix = "/v1/im/topics/" idx := strings.Index(path, prefix) if idx < 0 { return "", 0, false } rest := strings.TrimPrefix(path[idx:], prefix) parts := strings.Split(strings.Trim(rest, "/"), "/") if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" { return "", 0, false } seq, err := strconv.Atoi(parts[2]) if err != nil || seq <= 0 { return "", 0, false } return parts[0], seq, true } func messageActionPath(path, action string) (string, int, bool) { const marker = "/v1/im/topics/" idx := strings.Index(path, marker) if idx < 0 { return "", 0, false } rest := strings.TrimPrefix(path[idx:], marker) topic, rest, ok := strings.Cut(rest, "/messages/") if !ok || topic == "" { return "", 0, false } seqValue, suffix, ok := strings.Cut(rest, "/") if !ok || suffix != action { return "", 0, false } seq, err := strconv.Atoi(seqValue) if err != nil { return topic, 0, true } return topic, seq, true } func conversationContextTopicFromPath(path string) string { const prefix = "/v1/im/conversations/" const suffix = "/context" if !strings.Contains(path, prefix) || !strings.HasSuffix(path, suffix) { return "" } idx := strings.Index(path, prefix) return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix) } func groupResourcePath(path string) (string, string, bool) { const prefix = "/v1/im/groups/" idx := strings.Index(path, prefix) if idx < 0 { return "", "", false } rest := strings.TrimPrefix(path[idx:], prefix) topic, tail, ok := strings.Cut(rest, "/") if topic == "" { return "", "", false } if !ok { return topic, "", true } return topic, strings.Trim(tail, "/"), true } func userResourcePath(path string) (types.Uid, string, bool) { const prefix = "/v1/im/users/" idx := strings.Index(path, prefix) if idx < 0 { return types.ZeroUid, "", false } rest := strings.TrimPrefix(path[idx:], prefix) userID, tail, ok := strings.Cut(rest, "/") if !ok || userID == "" || tail == "" { return types.ZeroUid, "", false } return types.ParseUserId(strings.TrimSpace(userID)), strings.Trim(tail, "/"), true } func groupMemberProfilePath(tail string) (types.Uid, bool) { parts := strings.Split(strings.Trim(tail, "/"), "/") if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" { return types.ZeroUid, false } uid := types.ParseUserId(strings.TrimSpace(parts[1])) return uid, !uid.IsZero() } func groupJoinRequestActionPath(tail string) (string, string, bool) { parts := strings.Split(strings.Trim(tail, "/"), "/") if len(parts) != 3 || parts[0] != "join-requests" || parts[1] == "" { return "", "", false } switch parts[2] { case "accept", "reject": return strings.TrimSpace(parts[1]), parts[2], true default: return "", "", false } } func friendRequestActionFromPath(path string) (string, string) { const prefix = "/v1/im/friend-requests/" idx := strings.Index(path, prefix) if idx < 0 { return "", "" } rest := strings.TrimPrefix(path[idx:], prefix) requestID, action, ok := strings.Cut(rest, "/") if !ok { return "", "" } return strings.TrimSpace(requestID), strings.TrimSpace(action) } func userIDFromTail(path, prefix string) types.Uid { idx := strings.Index(path, prefix) if idx < 0 { return types.ZeroUid } return types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(path[idx:], prefix))) } func peerFromP2PTopic(topic string, uid types.Uid) types.Uid { if types.GetTopicCat(topic) != types.TopicCatP2P || uid.IsZero() { return types.ZeroUid } prefix := "p2p" body := strings.TrimPrefix(topic, prefix) uidLen := len(uid.String()) if len(body) < 2*uidLen { return types.ZeroUid } left := types.ParseUid(body[:uidLen]) right := types.ParseUid(body[uidLen:]) if left == uid { return right } if right == uid { return left } return types.ZeroUid }