package main import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math/big" "net/http" "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 ) 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 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"` } 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 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 serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { switch { 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 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 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 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 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 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 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 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 }