package main import ( "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math/big" "mime" "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/media" "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" imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED" imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION" imReasonAuthRequired = "AUTH_REQUIRED" imReasonRealNameRequired = "REALNAME_REQUIRED" imReasonActionToken = "ACTION_TOKEN_INVALID" imReasonPermissionDenied = "PERMISSION_DENIED" imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED" imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED" imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT" imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS" imReasonInternal = "INTERNAL_ERROR" imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE" imReasonFileUnavailable = "FILE_UNAVAILABLE" imReasonRateLimited = "RATE_LIMITED" ) const ( imVerificationCodeLength = 6 imVerificationExpiresInSeconds = 300 imVerificationResendAfterSeconds = 60 imVerificationMaxAttempts = 5 imVerificationCreateRateWindow = time.Hour imActionTokenExpiresInSeconds = 300 imIndependentPasswordMaxAttempts = 5 imIndependentPasswordLockSeconds = 300 imQRCodeDefaultExpiresInSeconds = 86400 imQRCodeMaxExpiresInSeconds = 7 * 86400 imPublicUploadMaxBytes = 10 << 20 groupMaxAdminCount = 10 reportPreviewMaxRunes = 120 ) 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 imHTTPContextKey int const imHTTPProtocolValidated imHTTPContextKey = iota func imReliableHTTPEventsEnabled(req *http.Request) bool { enabled, _ := req.Context().Value(imHTTPProtocolValidated).(bool) return enabled } func withIMHTTPProtocol(prefix string, next http.Handler) http.Handler { return http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) { if !strings.HasPrefix(req.URL.Path, prefix) { next.ServeHTTP(wrt, req) return } requestID := strings.TrimSpace(req.Header.Get("X-Request-ID")) if requestID == "" { if token, err := randomIMToken(18); err == nil { requestID = "req_" + token } else { requestID = fmt.Sprintf("req_%d", time.Now().UTC().UnixNano()) } req.Header.Set("X-Request-ID", requestID) } wrt.Header().Set("X-Request-ID", requestID) wrt.Header().Set("X-IM-Schema", "1") if valid, _ := checkAPIKey(strings.TrimSpace(req.Header.Get("X-Tinode-APIKey"))); !valid { writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil) return } if schema := strings.TrimSpace(req.Header.Get("X-IM-Schema")); schema != "1" { writeIMError(wrt, req, http.StatusBadRequest, 30006, imReasonSchemaUnsupported, "unsupported IM schema", map[string]any{"supported": []int{1}}) return } req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true)) if req.Method == http.MethodGet || req.Method == http.MethodHead { if principal, err := resolveIMHTTPBearer(req); err == nil { dispatchIMBusinessEvents(principal.TenantID) } } serveIMHTTPIdempotent(wrt, req, next) }) } type imBufferedResponseWriter struct { header http.Header status int wroteHeader bool body bytes.Buffer } func newIMBufferedResponseWriter() *imBufferedResponseWriter { return &imBufferedResponseWriter{header: make(http.Header), status: http.StatusOK} } func (w *imBufferedResponseWriter) Header() http.Header { return w.header } func (w *imBufferedResponseWriter) WriteHeader(status int) { if w.wroteHeader { return } w.status = status w.wroteHeader = true } func (w *imBufferedResponseWriter) Write(data []byte) (int, error) { if !w.wroteHeader { w.WriteHeader(http.StatusOK) } return w.body.Write(data) } func (w *imBufferedResponseWriter) flushTo(wrt http.ResponseWriter) { for key, values := range w.header { for _, value := range values { wrt.Header().Add(key, value) } } wrt.WriteHeader(w.status) _, _ = wrt.Write(w.body.Bytes()) } func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http.Handler) { if req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodPatch && req.Method != http.MethodDelete { next.ServeHTTP(wrt, req) return } if req.Method == http.MethodPost && strings.TrimSpace(req.URL.Path) == "/v1/im/tenants/resolve" { next.ServeHTTP(wrt, req) return } if !strings.HasPrefix(strings.TrimSpace(req.Header.Get("Authorization")), "Bearer ") { next.ServeHTTP(wrt, req) return } principal, err := resolveIMHTTPBearer(req) if err != nil { next.ServeHTTP(wrt, req) return } dispatchIMBusinessEvents(principal.TenantID) key := strings.TrimSpace(req.Header.Get("Idempotency-Key")) if key == "" || len(key) > 128 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"}) return } body, err := io.ReadAll(io.LimitReader(req.Body, 4<<20+1)) if err != nil || len(body) > 4<<20 { writeIMError(wrt, req, http.StatusRequestEntityTooLarge, 30000, imReasonMalformedRequest, "request body too large", nil) return } req.Body = io.NopCloser(bytes.NewReader(body)) hashInput := append([]byte(req.URL.RawQuery+"\x00"), body...) bodyHash := sha256.Sum256(hashInput) record := &types.HTTPIdempotencyRecord{ UserID: principal.Uid, Method: req.Method, Route: req.URL.Path, Key: key, BodyHash: fmt.Sprintf("%x", bodyHash[:]), ExpiresAt: time.Now().UTC().Add(24 * time.Hour), } existing, acquired, err := store.HTTPInfrastructure.BeginIdempotency(principal.TenantID, record) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if !acquired { if existing == nil || existing.BodyHash != record.BodyHash { writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil) return } if existing.State == types.HTTPIdempotencyCompleted { wrt.Header().Set("Content-Type", "application/json; charset=utf-8") wrt.Header().Set("X-Idempotent-Replay", "true") wrt.WriteHeader(existing.ResponseStatus) _, _ = wrt.Write(existing.ResponseBody) return } writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil) return } buffered := newIMBufferedResponseWriter() next.ServeHTTP(buffered, req) record.ResponseStatus = buffered.status record.ResponseBody = append([]byte(nil), buffered.body.Bytes()...) if buffered.status >= http.StatusInternalServerError { _ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record) buffered.flushTo(wrt) return } if err = store.HTTPInfrastructure.CompleteIdempotency(principal.TenantID, record); err != nil { _ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } buffered.flushTo(wrt) } type imTenantResolveRequest struct { TenantCode string `json:"tenant_code"` Platform string `json:"platform,omitempty"` AppVersion string `json:"app_version,omitempty"` } type imTenantApplicationRequest struct { CreditCode string `json:"credit_code"` EnterpriseEmail string `json:"enterprise_email,omitempty"` LicenseFileID string `json:"license_file_id,omitempty"` TenantCodeHint string `json:"tenant_code_hint,omitempty"` ClientContext map[string]any `json:"client_context,omitempty"` } type imTenantEntryFeedbackRequest struct { Description string `json:"description"` Contact string `json:"contact,omitempty"` TenantCodeHint string `json:"tenant_code_hint,omitempty"` ScreenshotFileIDs []string `json:"screenshot_file_ids,omitempty"` ClientContext map[string]any `json:"client_context,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 imRealNameAppealRequest struct { Reason string `json:"reason"` Attachments []string `json:"attachments,omitempty"` } 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 imYouthModeEnableRequest struct { PolicyVersion int `json:"policy_version"` ActionToken string `json:"action_token"` } type imDevicePushTokenRequest struct { Provider string `json:"provider"` Token string `json:"token"` AppVersion string `json:"app_version,omitempty"` Platform string `json:"platform,omitempty"` } type imP2PConversationRequest struct { PeerUserID string `json:"peer_user_id"` } type imConversationSettingsRequest struct { Pinned *bool `json:"pinned,omitempty"` Muted *bool `json:"muted,omitempty"` MuteNotifications *bool `json:"mute_notifications,omitempty"` Remark *string `json:"remark,omitempty"` MessageRetention *string `json:"message_retention,omitempty"` Version int `json:"version"` } type imTopicDraftRequest struct { Content string `json:"content"` Entities any `json:"entities,omitempty"` Version int `json:"version"` } type imRiskChallengeConfirmRequest struct { Decision string `json:"decision"` } type imAbuseReportRequest struct { Target types.AbuseTarget `json:"target"` ReasonCode string `json:"reason_code"` Description string `json:"description,omitempty"` EvidenceFileIDs []string `json:"evidence_file_ids,omitempty"` } type imFeedbackRequest struct { Category string `json:"category"` Content string `json:"content"` Contact string `json:"contact,omitempty"` AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"` ClientContext map[string]any `json:"client_context,omitempty"` IncludeDiagnostics bool `json:"include_diagnostics,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 int64 `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 imMessagesForwardRequest struct { Mode string `json:"mode"` Sources []struct { Topic string `json:"topic"` Seq int64 `json:"seq,string"` } `json:"sources"` 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"` Description string `json:"description,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 imGroupFileCreateRequest struct { FileID string `json:"file_id"` FileName string `json:"file_name,omitempty"` MIMEType string `json:"mime_type,omitempty"` FileType string `json:"file_type,omitempty"` FileSize int64 `json:"file_size,omitempty"` SourceTopic string `json:"source_topic,omitempty"` SourceSeq int64 `json:"source_seq,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 } endpoints := imPublicEndpoints(req) 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": endpoints, "auth_methods": []string{"password", "sms"}, "password_policy": map[string]any{ "min_length": 8, "max_length": 64, }, "app_update": map[string]any{"type": "none"}, }) } func serveIMPublicUploads(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if store.Store == nil || store.Store.GetMediaHandler() == nil { writeIMError(wrt, req, http.StatusServiceUnavailable, 90000, imReasonInternal, "media handler is not configured", nil) return } uploader, ok := store.Store.GetMediaHandler().(media.PublicUploader) if !ok { writeIMError(wrt, req, http.StatusServiceUnavailable, 30000, imReasonUnsupportedMedia, "public upload is not supported", nil) return } req.Body = http.MaxBytesReader(wrt, req.Body, imPublicUploadMaxBytes) if err := req.ParseMultipartForm(imPublicUploadMaxBytes); err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed multipart form", nil) return } purpose := strings.TrimSpace(req.FormValue("purpose")) if !isAllowedPublicUploadPurpose(purpose) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"}) return } file, header, err := req.FormFile("file") if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "file is required", map[string]any{"field": "file"}) return } defer file.Close() mimeType, err := detectIMUploadMime(file, header.Header.Get("Content-Type")) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed file", nil) return } if !isAllowedPublicUploadMime(purpose, mimeType) { writeIMError(wrt, req, http.StatusUnsupportedMediaType, 30000, imReasonUnsupportedMedia, "unsupported media type", map[string]any{"mime_type": mimeType}) return } token, err := randomIMToken(18) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } now := time.Now().UTC().Round(time.Millisecond) publicFile := &media.PublicFileDef{ ID: "file_" + token, Purpose: purpose, MimeType: mimeType, CreatedAt: now, } location, size, err := uploader.UploadPublic(publicFile, file) if err != nil { writeIMRelationError(wrt, req, err) return } if location != "" { publicFile.Location = location } upload, err := store.TenantEntry.CreatePublicUpload(&types.PublicUpload{ FileID: publicFile.ID, Purpose: purpose, MimeType: mimeType, Size: size, ETag: publicFile.ETag, Location: publicFile.Location, ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now, UploadedAt: now, }) if err != nil { _ = store.Store.GetMediaHandler().Delete([]string{publicFile.Location}) writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, publicUploadPayload(upload)) } func serveIMTenantApplications(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } var body imTenantApplicationRequest if !decodeIMJSONBody(wrt, req, &body) { return } idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) creditCode := strings.ToUpper(strings.TrimSpace(body.CreditCode)) email := strings.TrimSpace(body.EnterpriseEmail) if idempotencyKey == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"}) return } if !isValidCreditCode(creditCode) { writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_CREDIT_CODE", "invalid credit code", map[string]any{"field": "credit_code"}) return } if email != "" && !isValidIMEmail(email) { writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_EMAIL", "invalid email", map[string]any{"field": "enterprise_email"}) return } token, err := randomIMToken(18) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } app, err := store.TenantEntry.CreateTenantApplication(&types.TenantApplication{ ApplicationID: "entapp_" + token, CreditCode: creditCode, EnterpriseEmail: email, LicenseFileID: strings.TrimSpace(body.LicenseFileID), TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint), ClientContext: body.ClientContext, IdempotencyKey: idempotencyKey, Status: types.TenantApplicationStatusReceived, EstimatedReviewHour: 24, }) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, tenantApplicationPayload(app)) } func serveIMTenantEntryFeedback(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } var body imTenantEntryFeedbackRequest if !decodeIMJSONBody(wrt, req, &body) { return } idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) description := strings.TrimSpace(body.Description) if idempotencyKey == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"}) return } if len([]rune(description)) < 10 { writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_REQUIRED", "description is required", map[string]any{"field": "description"}) return } if len([]rune(description)) > 1000 { writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_TOO_LONG", "description is too long", map[string]any{"field": "description"}) return } if len(body.ScreenshotFileIDs) > 9 { writeIMError(wrt, req, http.StatusBadRequest, 30000, "TOO_MANY_FILES", "too many files", map[string]any{"field": "screenshot_file_ids"}) return } token, err := randomIMToken(18) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } feedback, err := store.TenantEntry.CreateTenantEntryFeedback(&types.TenantEntryFeedback{ FeedbackID: "entryfb_" + token, Description: description, Contact: strings.TrimSpace(body.Contact), TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint), ScreenshotFileIDs: cleanStringList(body.ScreenshotFileIDs, 9), ClientContext: body.ClientContext, IdempotencyKey: idempotencyKey, Status: types.TenantEntryFeedbackReceived, }) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, tenantEntryFeedbackPayload(feedback)) } func imPublicEndpoints(req *http.Request) map[string]any { base := imPublicHTTPBase(req) wsBase := base if strings.HasPrefix(wsBase, "https://") { wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://") } else if strings.HasPrefix(wsBase, "http://") { wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://") } return map[string]any{ "websocket": strings.TrimRight(wsBase, "/") + "/v0/channels", "api": strings.TrimRight(base, "/") + "/v1/im", "file": strings.TrimRight(base, "/") + "/v0/file", } } func imPublicHTTPBase(req *http.Request) string { configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/") parsed, _ := url.Parse(configured) basePath := "" if parsed != nil { basePath = strings.TrimRight(parsed.Path, "/") if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) { return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/") } } if basePath == "" && req != nil && req.URL != nil { if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 { basePath = strings.TrimRight(req.URL.Path[:idx], "/") } } scheme := "http" if req != nil && req.TLS != nil { scheme = "https" } if req != nil { if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" { scheme = forwarded } } host := "" if req != nil { host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host")) if host == "" { host = strings.TrimSpace(req.Host) } } if host == "" && parsed != nil { host = parsed.Host } if host == "" { return configured } return strings.TrimRight(scheme+"://"+host+basePath, "/") } func isPublicEndpointHost(host string) bool { host = strings.TrimSpace(host) return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:") } func firstForwardedHeader(value string) string { if idx := strings.Index(value, ","); idx >= 0 { value = value[:idx] } return strings.TrimSpace(strings.ToLower(value)) } 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") if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil { logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err) } else if !allowed { retryAfter := int(ttl.Seconds()) if retryAfter < 1 { retryAfter = int(imVerificationCreateRateWindow.Seconds()) } writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ "retry_after_seconds": retryAfter, }) return } 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 := "123456" deliveryEnabled := scene != "register" && registrationSMSEnabled(now) 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.GetByPhone(tenant.ID, phone); err != nil { logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err) } 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 } if deliveryEnabled { if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil { logs.Warn.Println("im verification delivery failed; keeping fixed test code active:", tenant.Code, scene, vc.PhoneMask, err) } } else { logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask) } logs.Info.Println("im verification challenge created:", 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) 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 account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" { if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{ ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now}, User: vc.TargetUser.String(), Method: "tel", Value: account.Phone, Done: true, }); credErr != nil { logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr) } } else if getErr != nil { logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr) } 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 } principal, ok := authenticateIMHTTPBearer(wrt, req) if !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" { challengeID := "risk_" + shortTokenHash(fmt.Sprintf("%d:%s:%d", principal.Uid, strings.ToLower(strings.TrimSpace(body.URL)), time.Now().UnixNano())) expiresAt := time.Now().UTC().Round(time.Millisecond).Add(10 * time.Minute) if _, err := store.Safety.CreateRiskChallenge(principal.TenantID, &types.RiskChallenge{ ChallengeID: challengeID, UserID: principal.Uid, Action: "open_url", Level: riskLevel, NoticeCode: "url_" + riskLevel, NoticeParams: map[string]any{"source": strings.TrimSpace(body.Source)}, State: types.RiskChallengeStatePending, ExpiresAt: expiresAt, }); err != nil { writeIMRelationError(wrt, req, err) return } payload["risk_challenge_id"] = challengeID payload["expires_at"] = expiresAt.Format(time.RFC3339Nano) } writeIMSuccess(wrt, req, payload) } func serveIMRiskChallengeConfirm(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 := riskChallengeIDFromPath(req.URL.Path) if challengeID == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "risk_challenge_id"}) return } var body imRiskChallengeConfirmRequest if !decodeIMJSONBody(wrt, req, &body) { return } decision := strings.ToLower(strings.TrimSpace(body.Decision)) var tokenID string var token string var expiresAt time.Time var err error if decision == "continue" { token, expiresAt, err = issueIMActionToken(principal.TenantID, principal.Uid, "risk_continue", challengeID) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } tokenID, _, _ = strings.Cut(token, ".") } challenge, err := store.Safety.ConfirmRiskChallenge(principal.TenantID, principal.Uid, challengeID, decision, tokenID, time.Now().UTC().Round(time.Millisecond)) if err != nil { writeIMRelationError(wrt, req, err) return } payload := riskChallengePayload(challenge) if decision == "continue" { payload["risk_action_token"] = token payload["token_action"] = "risk_continue" payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano) } if imReliableHTTPEventsEnabled(req) { eventType := "security.risk." + decision eventID := stableIMEventID("risk", challengeID, decision, challenge.State) if err = createIMSystemNotification(principal.TenantID, principal.Uid, stableIMEventID(eventID, "notification"), eventType, "安全操作确认", decision, types.KVMap{"risk_challenge_id": challengeID}); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "user", principal.Uid.UserId(), principal.Uid, eventType, map[string]any{"risk_challenge_id": challengeID, "decision": decision}); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } writeIMSuccess(wrt, req, payload) } func serveIMAbuseReasons(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 } targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type"))) reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType) if err != nil { logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err) reasons = defaultIMAbuseReasons(targetType) } if len(reasons) == 0 { reasons = defaultIMAbuseReasons(targetType) } items := make([]map[string]any, 0, len(reasons)) for _, reason := range reasons { items = append(items, abuseReasonPayload(reason)) } writeIMSuccess(wrt, req, map[string]any{"items": items}) } func serveIMAbuseReports(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 imAbuseReportRequest if !decodeIMJSONBody(wrt, req, &body) { 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 } targetType, targetUser, targetTopic, targetSeq, snapshot, err := normalizeIMAbuseTarget(principal.TenantID, principal.Uid, body.Target) if err != nil { writeIMRelationError(wrt, req, err) return } description := strings.TrimSpace(body.Description) if len([]rune(description)) > 500 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"}) return } if len(body.EvidenceFileIDs) > 9 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "evidence_file_ids"}) return } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } report, err := store.Safety.CreateAbuseReport(principal.TenantID, &types.AbuseReport{ ReportID: "ar_" + token, ReporterID: principal.Uid, TargetType: targetType, TargetUserID: targetUser, TargetTopic: targetTopic, TargetSeq: targetSeq, ReasonCode: strings.TrimSpace(body.ReasonCode), Description: description, EvidenceFileIDs: cleanStringList(body.EvidenceFileIDs, 9), Snapshot: snapshot, }, idempotencyKey) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, abuseReportPayload(report)) } func serveIMFeedbackConfig(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 } config, err := store.Safety.FeedbackConfig(principal.TenantID, principal.Uid) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, feedbackConfigPayload(config)) } func serveIMFeedback(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 imFeedbackRequest if !decodeIMJSONBody(wrt, req, &body) { return } idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) content := strings.TrimSpace(body.Content) if idempotencyKey == "" || strings.TrimSpace(body.Category) == "" || content == "" || len([]rune(content)) > 1000 || len(body.AttachmentFileIDs) > 5 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } feedback, err := store.Safety.CreateFeedback(principal.TenantID, &types.FeedbackSubmission{ FeedbackID: "fb_" + token, UserID: principal.Uid, Category: strings.TrimSpace(body.Category), Content: content, Contact: strings.TrimSpace(body.Contact), AttachmentFileIDs: cleanStringList(body.AttachmentFileIDs, 5), ClientContext: body.ClientContext, IncludeDiagnostics: body.IncludeDiagnostics, }, idempotencyKey) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback)) } 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 serveIMRealNameAppeals(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 imRealNameAppealRequest if !decodeIMJSONBody(wrt, req, &body) { return } reason := strings.TrimSpace(body.Reason) if reason == "" || len([]rune(reason)) > 500 || len(body.Attachments) > 9 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"}) return } attachments := make([]string, 0, len(body.Attachments)) for _, raw := range body.Attachments { fileID := strings.TrimSpace(raw) if fileID == "" { continue } attachments = append(attachments, fileID) } encoded, err := json.Marshal(attachments) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } appeal, err := store.Security.CreateRealNameAppeal(principal.TenantID, &types.RealNameAppeal{ TenantID: principal.TenantID, UserID: principal.Uid, AppealID: "rna_" + token, Reason: reason, Attachments: attachments, AttachmentsJSON: encoded, Status: "pending", }) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, realNameAppealPayload(appeal)) } 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 } var modules []types.WorkbenchModule workbenchVersion := 1 if store.Workbench != nil { var err error modules, workbenchVersion, err = store.Workbench.Get(principal.TenantID, principal.Uid) if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { writeIMRelationError(wrt, req, err) return } } modulePayloads := workbenchModulePayloads(modules) entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid, modulePayloads) if err != nil { writeIMRelationError(wrt, req, err) 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": true, "message_edit": true, }, "permissions": map[string]any{ "create_group": true, "user_search": true, "scan_qr": true, }, "entry_badges": entryBadges, "entries": entries, "modules": modulePayloads, "workbench_version": workbenchVersion, "limits": map[string]any{ "max_file_size": globals.maxFileUploadSize, "max_text_length": globals.maxMessageSize, "max_forward_targets": 9, }, "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", "recall", "edit"}, }, "security": map[string]any{ "realname": map[string]any{ "status": status, "required": false, "allow_skip": true, "remind_after": "24h", }, }, }) } func serveIMWorkbench(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 } modules, layoutVersion, err := store.Workbench.Get(principal.TenantID, principal.Uid) if err != nil { writeIMRelationError(wrt, req, err) return } tenant, err := store.Tenants.GetByID(principal.TenantID) if err != nil || tenant == nil || strings.TrimSpace(tenant.Code) == "" { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{ "layout_version": layoutVersion, "modules": workbenchModulePayloads(modules), "enterprise": map[string]any{ "tenant_code": tenant.Code, "feature_flags": map[string]any{ "organization": true, "meetings": true, "daily_reports": true, "monthly_reports": true, "executive_mailbox": true, }, }, }) } func serveIMOrganizationDepartments(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) parentID := strings.TrimSpace(req.URL.Query().Get("parent_id")) if parentID == "" { parentID = "root" } items, nextCursor, err := store.Organization.ListDepartments(principal.TenantID, principal.Uid, parentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "items": organizationDepartmentPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != "", }) } func serveIMOrganizationDepartmentResource(wrt http.ResponseWriter, req *http.Request) { departmentID, tail, ok := organizationDepartmentResourcePath(req.URL.Path) if !ok || tail != "members" { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "organization route not found", nil) return } 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.Organization.ListMembers(principal.TenantID, principal.Uid, departmentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "department_id": departmentID, "items": organizationMemberPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != "", }) } func serveIMOrganizationMembers(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) query := strings.TrimSpace(req.URL.Query().Get("q")) if query == "" { query = strings.TrimSpace(req.URL.Query().Get("keyword")) } departmentID := strings.TrimSpace(req.URL.Query().Get("department_id")) items, nextCursor, err := store.Organization.ListAllMembers(principal.TenantID, principal.Uid, departmentID, query, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "department_id": departmentID, "items": organizationMemberPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != "", }) } func serveIMOrganizationSearch(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.TrimSpace(req.URL.Query().Get("q")) 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, 100) items, nextCursor, err := store.Organization.Search(principal.TenantID, principal.Uid, query, strings.TrimSpace(req.URL.Query().Get("types")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "items": organizationSearchPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != "", }) } func serveIMMeetings(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, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body struct { Title string `json:"title"` StartAt string `json:"start_at"` EndAt string `json:"end_at"` Location string `json:"location"` AttendeeUserIDs []string `json:"attendee_user_ids"` ReminderMinutes []int `json:"reminder_minutes"` } if !decodeIMJSONBody(wrt, req, &body) { return } startAt, err := parseIMTime(body.StartAt) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"}) return } endAt, err := parseIMTime(body.EndAt) if err != nil || !endAt.After(startAt) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"}) return } meetingID, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } attendeeIDs, ok := parseIMUserIDList(wrt, req, body.AttendeeUserIDs, "attendee_user_ids") if !ok { return } meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{ MeetingID: "mtg_" + meetingID, OrganizerID: principal.Uid, Title: strings.TrimSpace(body.Title), StartAt: startAt, EndAt: endAt, Location: strings.TrimSpace(body.Location), ReminderMinutes: body.ReminderMinutes, IdempotencyKey: idempotencyKey, }, attendeeIDs) if err != nil { writeIMRelationError(wrt, req, err) return } if imReliableHTTPEventsEnabled(req) { if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.created"); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID, meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) { meetingID, tail, ok := meetingResourcePath(req.URL.Path) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting route not found", nil) return } switch tail { case "": if req.Method == http.MethodGet { serveIMMeetingGet(wrt, req, meetingID) return } if req.Method == http.MethodPatch { serveIMMeetingUpdate(wrt, req, meetingID) return } case "cancel": serveIMMeetingCancel(wrt, req, meetingID) return case "reminders": serveIMMeetingReminders(wrt, req, meetingID) return case "my-response": serveIMMeetingMyResponse(wrt, req, meetingID) return } writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } func serveIMMeetingGet(wrt http.ResponseWriter, req *http.Request, meetingID string) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID string) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body struct { Title *string `json:"title"` StartAt *string `json:"start_at"` EndAt *string `json:"end_at"` Location *string `json:"location"` ReminderMinutes []int `json:"reminder_minutes"` Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } patch := types.MeetingPatch{Title: body.Title, Location: body.Location, ReminderMinutes: body.ReminderMinutes, Version: body.Version} if body.StartAt != nil { startAt, err := parseIMTime(*body.StartAt) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"}) return } patch.StartAt = &startAt } if body.EndAt != nil { endAt, err := parseIMTime(*body.EndAt) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"}) return } patch.EndAt = &endAt } meeting, err := store.Meetings.Update(principal.TenantID, principal.Uid, meetingID, patch, idempotencyKey) if err != nil { writeIMRelationError(wrt, req, err) return } if imReliableHTTPEventsEnabled(req) { if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.updated"); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID, meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID string) { 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, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } meeting, err := store.Meetings.Cancel(principal.TenantID, principal.Uid, meetingID, idempotencyKey) if err != nil { writeIMRelationError(wrt, req, err) return } if imReliableHTTPEventsEnabled(req) { if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.canceled"); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID, meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meetingID string) { 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, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } participants, err := store.Meetings.Remind(principal.TenantID, principal.Uid, meetingID, idempotencyKey) if err != nil { writeIMRelationError(wrt, req, err) return } if imReliableHTTPEventsEnabled(req) { meeting, getErr := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) if getErr != nil { writeIMRelationError(wrt, req, getErr) return } meeting.Participants = participants if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.reminded"); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.reminded", meetingID, meetingParticipantUserIDs(participants), "会议提醒", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)}) writeIMSuccess(wrt, req, map[string]any{ "meeting_id": meetingID, "reminded": meetingParticipantPayloads(participants), }) } func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetingID string) { if req.Method != http.MethodPut { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body struct { Response string `json:"response"` Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } meeting, err := store.Meetings.Respond(principal.TenantID, principal.Uid, meetingID, strings.TrimSpace(body.Response), body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } if imReliableHTTPEventsEnabled(req) { if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.response.updated"); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID, meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } switch req.Method { case http.MethodGet: limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) reports, nextCursor, err := store.DailyReports.List(principal.TenantID, principal.Uid, req.URL.Query().Get("scope"), req.URL.Query().Get("from"), req.URL.Query().Get("to"), req.URL.Query().Get("status"), req.URL.Query().Get("cursor"), limit) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "items": dailyReportListPayloads(reports), "next_cursor": nextCursor, "permissions": map[string]any{ "can_create": true, "makeup_allowed_days": 7, "can_view_managed": true, "can_remind_unsubmitted": true, }, }) case http.MethodPost: var body struct { ReportDate string `json:"report_date"` } if !decodeIMJSONBody(wrt, req, &body) { return } reportDate, err := parseIMDate(body.ReportDate) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"}) return } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } report, err := store.DailyReports.CreateDraft(principal.TenantID, principal.Uid, "dr_"+token, reportDate) if err != nil { writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.created", report.ReportID, []types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMDailyReportResource(wrt http.ResponseWriter, req *http.Request) { reportID, tail, ok := dailyReportResourcePath(req.URL.Path) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "daily report route not found", nil) return } if reportID == "statistics" && tail == "" { serveIMDailyReportStatistics(wrt, req) return } if reportID == "reminders" && tail == "" { serveIMDailyReportReminders(wrt, req) return } switch tail { case "": if req.Method == http.MethodGet { serveIMDailyReportGet(wrt, req, reportID) return } case "draft": serveIMDailyReportSaveDraft(wrt, req, reportID) return case "submit": serveIMDailyReportSubmit(wrt, req, reportID) return } writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } func serveIMDailyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } report, err := store.DailyReports.Get(principal.TenantID, principal.Uid, reportID) if err != nil { writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.saved", report.ReportID, []types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) } func serveIMDailyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) { if req.Method != http.MethodPut { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body struct { Content string `json:"content"` Attachments []string `json:"attachments"` Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } report, err := store.DailyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.Content, body.Attachments, body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.submitted", report.ReportID, []types.Uid{principal.Uid}, "日报已提交", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) } func serveIMDailyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) { 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 struct { Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } report, err := store.DailyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) } func serveIMDailyReportStatistics(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 } date, err := parseIMDate(req.URL.Query().Get("date")) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) return } stats, err := store.DailyReports.Stats(principal.TenantID, principal.Uid, date, req.URL.Query().Get("department_id")) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "expected_count": stats.ExpectedCount, "submitted_count": stats.SubmittedCount, "missing_count": stats.MissingCount, "scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))}, }) } func serveIMDailyReportReminders(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, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body struct { Date string `json:"date"` UserIDs []string `json:"user_ids"` } if !decodeIMJSONBody(wrt, req, &body) { return } date, err := parseIMDate(body.Date) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) return } userIDs, ok := parseIMUserIDList(wrt, req, body.UserIDs, "user_ids") if !ok { return } reminded, err := store.DailyReports.Remind(principal.TenantID, principal.Uid, date, userIDs, idempotencyKey) if err != nil { writeIMRelationError(wrt, req, err) return } items := make([]map[string]any, 0, len(reminded)) for _, uid := range reminded { items = append(items, map[string]any{"user_id": uid.UserId(), "status": "queued"}) } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.reminded", date.Format("2006-01-02"), reminded, "日报提醒", date.Format("2006-01-02"), map[string]any{"date": date.Format("2006-01-02"), "reminded_count": len(reminded)}) writeIMSuccess(wrt, req, map[string]any{"date": date.Format("2006-01-02"), "items": items}) } func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } switch req.Method { case http.MethodGet: limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) reports, nextCursor, err := store.MonthlyReports.List(principal.TenantID, principal.Uid, req.URL.Query().Get("scope"), req.URL.Query().Get("from_month"), req.URL.Query().Get("to_month"), req.URL.Query().Get("status"), req.URL.Query().Get("cursor"), limit) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor, "has_more": nextCursor != ""}) case http.MethodPost: var body struct { Month string `json:"month"` } if !decodeIMJSONBody(wrt, req, &body) { return } if !validIMMonth(body.Month) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) return } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } report, err := store.MonthlyReports.CreateDraft(principal.TenantID, principal.Uid, "mr_"+token, strings.TrimSpace(body.Month)) if err != nil { writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.created", report.ReportID, []types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMMonthlyReportResource(wrt http.ResponseWriter, req *http.Request) { reportID, tail, ok := monthlyReportResourcePath(req.URL.Path) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "monthly report route not found", nil) return } if reportID == "statistics" && tail == "" { serveIMMonthlyReportStatistics(wrt, req) return } switch tail { case "": if req.Method == http.MethodGet { serveIMMonthlyReportGet(wrt, req, reportID) return } case "draft": serveIMMonthlyReportSaveDraft(wrt, req, reportID) return case "submit": serveIMMonthlyReportSubmit(wrt, req, reportID) return case "reopen": serveIMMonthlyReportReopen(wrt, req, reportID) return } writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } func serveIMMonthlyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } report, err := store.MonthlyReports.Get(principal.TenantID, principal.Uid, reportID) if err != nil { writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.saved", report.ReportID, []types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) } func serveIMMonthlyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) { if req.Method != http.MethodPut { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body struct { WorkSummary string `json:"work_summary"` NextPlan string `json:"next_plan"` Attachments []string `json:"attachments"` Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } report, err := store.MonthlyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.submitted", report.ReportID, []types.Uid{principal.Uid}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) } func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) { 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 struct { Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } report, err := store.MonthlyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.reopened", report.ReportID, []types.Uid{principal.Uid}, "月报已重开", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) } func serveIMMonthlyReportReopen(wrt http.ResponseWriter, req *http.Request, reportID string) { 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 struct { Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } report, err := store.MonthlyReports.Reopen(principal.TenantID, principal.Uid, reportID, body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) } func serveIMMonthlyReportStatistics(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 } month := strings.TrimSpace(req.URL.Query().Get("month")) if !validIMMonth(month) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) return } stats, err := store.MonthlyReports.Stats(principal.TenantID, principal.Uid, month, req.URL.Query().Get("department_id")) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "expected_count": stats.ExpectedCount, "submitted_count": stats.SubmittedCount, "missing_count": stats.MissingCount, "scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))}, }) } func serveIMMailboxConfig(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 } config, err := store.Mailbox.Config(principal.TenantID, principal.Uid) if err != nil { logs.Warn.Println("im mailbox config: using default config after store error", principal.TenantID, err) config = defaultIMMailboxConfig() } writeIMSuccess(wrt, req, mailboxConfigPayload(config)) } func serveIMMailboxSubmissions(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, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body struct { Subject string `json:"subject"` Content string `json:"content"` Anonymous bool `json:"anonymous"` AttachmentFileIDs []string `json:"attachment_file_ids"` NoticeVersion string `json:"notice_version"` } if !decodeIMJSONBody(wrt, req, &body) { return } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } submission, err := store.Mailbox.Submit(principal.TenantID, principal.Uid, &types.MailboxSubmission{ SubmissionID: "mb_" + token, SenderID: principal.Uid, Subject: strings.TrimSpace(body.Subject), Content: strings.TrimSpace(body.Content), Anonymous: body.Anonymous, AttachmentFileIDs: body.AttachmentFileIDs, NoticeVersion: strings.TrimSpace(body.NoticeVersion), }, idempotencyKey) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, "MAILBOX_UNAVAILABLE", "mailbox unavailable", nil) return } writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "mailbox", "mailbox.submitted", submission.SubmissionID, []types.Uid{principal.Uid}, "总经理信箱", submission.Subject, map[string]any{"submission_id": submission.SubmissionID, "status": submission.Status, "anonymous": submission.Anonymous}) writeIMSuccess(wrt, req, mailboxSubmissionResultPayload(submission)) } func serveIMMe(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 } profile, err := store.Profiles.Get(principal.TenantID, principal.Uid) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "user_id": principal.Uid.UserId(), "profile": userProfilePayload10(profile), "profile_version": profile.Version, "realname": map[string]any{"status": "unknown"}, "youth_mode": map[string]any{"enabled": false}, }) } func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } switch req.Method { case http.MethodGet: profile, err := store.Profiles.Get(principal.TenantID, principal.Uid) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, userProfilePayload10(profile)) case http.MethodPatch: var body struct { AvatarFileID *string `json:"avatar_file_id"` Nickname *string `json:"nickname"` BirthDate *string `json:"birth_date"` Signature *string `json:"signature"` Gender *string `json:"gender"` Region *string `json:"region"` Email *string `json:"email"` Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } if body.BirthDate != nil && strings.TrimSpace(*body.BirthDate) != "" { if date, err := parseIMDate(*body.BirthDate); err != nil || date.After(time.Now().UTC()) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "birth_date"}) return } } profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{ AvatarFileID: body.AvatarFileID, Nickname: body.Nickname, BirthDate: body.BirthDate, Signature: body.Signature, Gender: body.Gender, Region: body.Region, Email: body.Email, Version: body.Version, }) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, userProfilePayload10(profile)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } switch req.Method { case http.MethodGet: settings, err := store.Profiles.GetPrivacy(principal.TenantID, principal.Uid) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, userPrivacyPayload(settings)) case http.MethodPatch: var body struct { AddMeMethods map[string]bool `json:"add_me_methods"` ProfileVisibility *string `json:"profile_visibility"` LastSeenVisibility *string `json:"last_seen_visibility"` ReadReceipts *bool `json:"read_receipts"` Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{ AddMeMethods: body.AddMeMethods, ProfileVisibility: body.ProfileVisibility, LastSeenVisibility: body.LastSeenVisibility, ReadReceipts: body.ReadReceipts, Version: body.Version, }) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, userPrivacyPayload(settings)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } tenant, ok := resolveIMHTTPTenant(wrt, req, "default") if !ok { return } platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform"))) channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel"))) if channel == "" { channel = "stable" } if platform != "ios" && platform != "android" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"}) return } release, err := store.AppReleases.Latest(tenant.ID, platform, channel) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, appReleasePayload(release)) } func serveIMYouthMode(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 } settings, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, youthModePayload(settings)) } func serveIMYouthModeEnable(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 imYouthModeEnableRequest if !decodeIMJSONBody(wrt, req, &body) { return } if body.PolicyVersion <= 0 || strings.TrimSpace(body.ActionToken) == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) return } settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, true, body.PolicyVersion, time.Now().UTC().Round(time.Millisecond)) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, youthModePayload(settings)) } func serveIMYouthModeDisable(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 } actionToken := strings.TrimSpace(req.Header.Get("X-Action-Token")) if actionToken == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "X-Action-Token"}) return } if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, "close_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) return } current, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid) if err != nil { writeIMRelationError(wrt, req, err) return } settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, false, current.PolicyVersion, time.Now().UTC().Round(time.Millisecond)) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, youthModePayload(settings)) } func serveIMDevicePushToken(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPut { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } deviceID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/v1/im/devices/")) deviceID = strings.TrimSuffix(deviceID, "/push-token") if deviceID == "" || strings.Contains(deviceID, "/") { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"}) return } var body imDevicePushTokenRequest if !decodeIMJSONBody(wrt, req, &body) { return } token, err := store.DevicePushTokens.Upsert(principal.TenantID, &types.DevicePushToken{ UserID: principal.Uid, DeviceID: deviceID, Provider: body.Provider, Token: body.Token, AppVersion: body.AppVersion, Platform: body.Platform, }) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, devicePushTokenPayload(token)) } 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 serveIMP2PConversation(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 imP2PConversationRequest if !decodeIMJSONBody(wrt, req, &body) { return } peer := types.ParseUserId(strings.TrimSpace(body.PeerUserID)) if peer.IsZero() || peer == principal.Uid { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "peer_user_id"}) return } topic, err := store.Conversations.EnsureP2P(principal.TenantID, principal.Uid, peer) if err != nil { writeIMRelationError(wrt, req, err) 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 serveIMForwardTargets(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("query"))) if query == "" { 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) if start == 0 && forwardTargetMatches("slf", "文件助手", query) { items = append(items, map[string]any{ "topic": "slf", "type": "file_assistant", "display_name": "文件助手", "avatar_file_id": "", "can_receive_forward": true, }) } nextIndex := start for i := start; i < len(subs); i++ { target := forwardTargetPayload(subs[i]) if target == nil { nextIndex = i + 1 continue } if query != "" && !forwardTargetMatches(target["topic"].(string), target["display_name"].(string), query) && !conversationMatches(subs[i], query) { continue } items = append(items, target) 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, "has_more": 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": serveIMFavoriteBatchCreate(wrt, req) 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 := saveMessageMaybeKafka(req.Context(), 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 serveIMMessageForward(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 imMessagesForwardRequest if !decodeIMJSONBody(wrt, req, &body) { return } mode := strings.TrimSpace(body.Mode) if mode == "" { mode = "separate" } if mode != "separate" { writeIMError(wrt, req, http.StatusNotImplemented, 30004, "NOT_IMPLEMENTED", "forward bundle is not implemented", map[string]any{"field": "mode"}) return } if len(body.Sources) == 0 || len(body.Sources) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } sourceReadable := make(map[string]error) targetWritable := make(map[string]error) items := make([]map[string]any, 0, len(body.TargetTopics)*len(body.Sources)) failed := make([]map[string]any, 0) for _, rawTarget := range body.TargetTopics { target := strings.TrimSpace(rawTarget) if target == "" { for _, source := range body.Sources { failed = append(failed, forwardFailurePayload(source.Topic, source.Seq, target, imReasonMalformedRequest)) } continue } targetErr, ok := targetWritable[target] if !ok { targetErr = requireTopicWriter(principal, target) targetWritable[target] = targetErr } if targetErr != nil { for _, source := range body.Sources { failed = append(failed, forwardFailurePayload(source.Topic, source.Seq, target, relationErrorReason(targetErr))) } continue } for _, source := range body.Sources { sourceTopic := strings.TrimSpace(source.Topic) if sourceTopic == "" || source.Seq <= 0 { failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, imReasonMalformedRequest)) continue } readErr, ok := sourceReadable[sourceTopic] if !ok { readErr = requireTopicReader(principal, sourceTopic) sourceReadable[sourceTopic] = readErr } if readErr != nil { failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(readErr))) continue } msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, source.Seq) if err != nil { failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(err))) continue } if msg == nil { failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, "NOT_FOUND")) continue } if messageAvailability(*msg) != "available" { failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, "MESSAGE_UNAVAILABLE")) continue } clientMid := "fwd_" + store.Store.GetUidString() forwarded := &types.Message{ TenantID: principal.TenantID, Topic: target, From: principal.Uid.String(), Head: forwardedMessageHead(msg, clientMid), Content: forwardedMessageContent(msg, body.Comment), } saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true) if err != nil { failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(err))) continue } items = append(items, map[string]any{ "source": map[string]any{"topic": sourceTopic, "seq": source.Seq}, "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "sent", }) } } writeIMSuccess(wrt, req, map[string]any{"items": items, "failed": failed}) } func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { switch { case strings.HasSuffix(req.URL.Path, "/draft"): serveIMTopicDraft(wrt, req) case strings.HasSuffix(req.URL.Path, "/calls"): serveIMTopicCalls(wrt, req) 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 serveIMTopicDraft(wrt http.ResponseWriter, req *http.Request) { topic := topicFromDraftPath(req.URL.Path) if topic == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } switch req.Method { case http.MethodGet: draft, err := store.Conversations.GetDraft(principal.TenantID, principal.Uid, topic) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMSuccess(wrt, req, map[string]any{"topic": topic, "content": "", "entities": []any{}, "version": 0}) return } writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, topicDraftPayload(draft)) case http.MethodPut: var body imTopicDraftRequest if !decodeIMJSONBody(wrt, req, &body) { return } encoded, err := json.Marshal(body.Entities) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "entities"}) return } if int64(len([]byte(body.Content))) > globals.maxMessageSize { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"}) return } draft, err := store.Conversations.PutDraft(principal.TenantID, principal.Uid, topic, body.Content, encoded, body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, topicDraftPayload(draft)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMCalls(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.Calls.List(principal.TenantID, principal.Uid, "", strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor}) } func serveIMTopicCalls(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, topicFromCallsPath(req.URL.Path)) if !ok { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor}) } 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 - int64(before), Before: seq + int64(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) readReceipts := false memberID := types.ZeroUid if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 { memberID = types.Uid(decoded) } else { memberID = types.ParseUid(member.User) } if !memberID.IsZero() { if privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, memberID); privacyErr == nil { readReceipts = privacy.ReadReceipts } } if readReceipts && 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 } if imReliableHTTPEventsEnabled(req) { eventID := stableIMEventID("message", topic, seq, updated.Version, "recalled") if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) 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 } if imReliableHTTPEventsEnabled(req) { eventID := stableIMEventID("message", topic, seq, updated.Version, "edited") if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited")) } func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } topic, tail := conversationResourceFromPath(req.URL.Path) if tail == "settings" { serveIMConversationSettings(wrt, req, principal, topic) return } if tail == "messages" { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if topic == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false}) return } if tail != "context" { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "conversation route not found", nil) return } if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } topic, peer := normalizeP2PConversationTopic(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 serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) { if topic == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } topic = normalizeConversationSettingsTopic(topic, principal.Uid) switch req.Method { case http.MethodGet: settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, conversationSettingsPayload(settings)) case http.MethodPatch: var body imConversationSettingsRequest if !decodeIMJSONBody(wrt, req, &body) { return } muted := body.Muted if muted == nil { muted = body.MuteNotifications } patch := types.ConversationSettingsPatch{ Pinned: body.Pinned, Muted: muted, Remark: body.Remark, MessageRetention: body.MessageRetention, Version: body.Version, } settings, err := store.Conversations.UpdateSettings(principal.TenantID, principal.Uid, topic, patch) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, conversationSettingsPayload(settings)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } 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 } privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target) if err != nil { writeIMRelationError(wrt, req, err) return } if !imPrivacyAllowsAdd(privacy, body.Source) { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "user does not accept friend requests from this source", 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 } if imReliableHTTPEventsEnabled(req) { if err = publishIMRelationEvent(principal.TenantID, principal.Uid, target, created.RequestID, "friend.request.created", "新的好友申请", message); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) 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 } 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 } 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 } if description != "" { public["description"] = description } 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) settings := &types.GroupSettings{ Topic: topicName, OwnerUserID: principal.Uid, Name: name, AvatarFileID: strings.TrimSpace(body.AvatarFileID), Description: description, GroupStatus: types.GroupStatusActive, MemberUploadFiles: true, MemberPrivateChat: true, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, MemberCount: 1, Version: 1, CreatedBy: principal.Uid, CreatedAt: now, UpdatedAt: now, } memberIDs := make([]types.Uid, 0, len(body.MemberUserIDs)) 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 memberIDs = append(memberIDs, uid) } memberPrivate := groupMemberPrivate("", false, false, 1, now) if err := store.GroupBusiness.CreateGroup(principal.TenantID, topic, principal.Uid, memberPrivate, settings, memberIDs, memberPrivate, now); err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) created, err := store.Topics.Get(principal.TenantID, topicName) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } selfSub, err := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false) if err != nil { writeIMRelationError(wrt, req, err) return } 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 == "files" || strings.HasPrefix(tail, "files/") { serveIMGroupFiles(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 == "" { 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")) cacheKey := groupMemberPageCacheKey(topic, principal.Uid, selfAccess, query, roleFilter, eligibleRole, limit, cursor, mentionableOnly, muteEligible) if cached, ok := store.GetCachedGroupMemberPage(principal.TenantID, cacheKey); ok { writeIMSuccess(wrt, req, cached) 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 } 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(), } payload := map[string]any{ "topic": topic, "items": items, "next_cursor": nextCursor, "permissions": permissions, } store.CacheGroupMemberPage(principal.TenantID, cacheKey, payload) writeIMSuccess(wrt, req, payload) } 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 } payload := groupDetailPayload(group, selfSub) if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { payload = mergeGroupSettingsPayload(payload, settings) } writeIMSuccess(wrt, req, payload) 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 } patch := types.GroupSettingsPatch{Name: body.Name, AvatarFileID: body.AvatarFileID, Description: body.Description, Version: body.Version} if _, err = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch); err != nil { if rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil { logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr) } writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) updated, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } payload := groupDetailPayload(updated, selfSub) if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { payload = mergeGroupSettingsPayload(payload, settings) } writeIMSuccess(wrt, req, payload) 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 } payload := groupSettingsPayload(group, selfSub) if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { payload = mergeGroupSettingsPayload(payload, settings) payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub) } writeIMSuccess(wrt, req, payload) } 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 } if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Pinned: body.Pinned, MuteNotifications: body.MuteNotifications}); err != nil { if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil { logs.Warn.Printf("group[%s]: failed to roll back member settings: %v", topic, rollbackErr) } writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) 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 } if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version}); err != nil { if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil { logs.Warn.Printf("group[%s]: failed to roll back member profile: %v", topic, rollbackErr) } writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) 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.GroupBusiness.Leave(principal.TenantID, topic, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) 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() now := time.Now().UTC().Round(time.Millisecond) joined, err := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, uid, strings.TrimSpace(body.Message), strings.TrimSpace(req.Header.Get("Idempotency-Key")), groupMemberPrivate("", false, false, 1, now), now) if err != nil { item["status"] = "failed" item["reason"] = relationErrorReason(err) results = append(results, item) continue } if !joined { item["status"] = "already_member" results = append(results, item) continue } item["status"] = "joined" results = append(results, item) } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results}) } func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail string) { principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } access := selfSub.ModeGiven & selfSub.ModeWant switch { case tail == "files" && req.Method == http.MethodGet: limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) items, nextCursor, err := store.GroupBusiness.ListFiles(principal.TenantID, topic, 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 } writeIMSuccess(wrt, req, map[string]any{ "topic": topic, "items": groupFilePayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != "", }) case tail == "files" && req.Method == http.MethodPost: idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body imGroupFileCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } fileID := strings.TrimSpace(body.FileID) if fileID == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) return } file, err := store.GroupBusiness.CreateFile(principal.TenantID, &types.GroupFile{ Topic: topic, FileID: fileID, SourceTopic: strings.TrimSpace(body.SourceTopic), SourceSeq: body.SourceSeq, UploaderID: principal.Uid, FileName: strings.TrimSpace(body.FileName), MIMEType: strings.TrimSpace(body.MIMEType), FileType: normalizeIMFileType(body.FileType), FileSize: body.FileSize, Status: "active", IdempotencyKey: idempotencyKey, }) if err != nil { writeIMRelationError(wrt, req, err) return } if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileCreate, file, principal.Uid); err != nil { logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err) } writeIMSuccess(wrt, req, groupFilePayload(file)) case strings.HasPrefix(tail, "files/") && req.Method == http.MethodDelete: groupFileID := strings.TrimSpace(strings.TrimPrefix(tail, "files/")) if groupFileID == "" || strings.Contains(groupFileID, "/") { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) return } canManage := access.IsAdmin() || access.IsOwner() file, err := store.GroupBusiness.DeleteFile(principal.TenantID, topic, groupFileID, principal.Uid, canManage, time.Now().UTC().Round(time.Millisecond)) if err != nil { writeIMRelationError(wrt, req, err) return } if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileDelete, file, principal.Uid); err != nil { logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err) } writeIMSuccess(wrt, req, groupFilePayload(file)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request) { invitationID, tail, ok := groupInvitationResourcePath(req.URL.Path) if !ok || tail != "accept" { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group invitation route not found", nil) return } 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 } now := time.Now().UTC().Round(time.Millisecond) inv, err := store.GroupBusiness.AcceptInvitation(principal.TenantID, invitationID, principal.Uid, groupMemberPrivate("", false, false, 1, now), now) if err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupInvitationPayload(inv)) } func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method == http.MethodPost { 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 } settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) } else { writeIMRelationError(wrt, req, err) } return } if settings == nil || settings.GroupStatus != types.GroupStatusActive { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) return } sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) if err != nil { writeIMRelationError(wrt, req, err) return } if sub != nil && (sub.ModeGiven & sub.ModeWant).IsJoiner() { writeIMError(wrt, req, http.StatusConflict, 30006, "ALREADY_GROUP_MEMBER", "already a group member", nil) return } var body imGroupJoinRequestCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } now := time.Now().UTC().Round(time.Millisecond) requestID := "gjr_" + store.Store.GetUidString() item, err := store.GroupBusiness.CreateJoinRequest(principal.TenantID, &types.GroupJoinRequest{ RequestID: requestID, Topic: topic, ApplicantUserID: principal.Uid, Source: defaultString(strings.TrimSpace(body.Source), "manual"), Message: strings.TrimSpace(body.Message), Status: types.GroupJoinRequestStatusPending, IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")), Version: 1, CreatedAt: now, UpdatedAt: now, }) if err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupJoinRequestPayload(item)) return } 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 req.Method == http.MethodGet { if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } status := strings.TrimSpace(req.URL.Query().Get("status")) if status == "" { status = "pending" } items, nextCursor, err := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, status, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100)) if err != nil { if !errors.Is(err, types.ErrNotFound) { writeIMRelationError(wrt, req, err) return } items = nil } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupJoinRequestPayloads(items), "next_cursor": nextCursor}) } } 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 } now := time.Now().UTC().Round(time.Millisecond) updated, err := store.GroupBusiness.ResolveJoinRequest(principal.TenantID, topic, requestID, principal.Uid, action, "", body.Version, groupMemberPrivate("", false, false, 1, now), now) if err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupJoinRequestPayload(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 } requests, _, _ := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, types.GroupJoinRequestStatusPending, "", 100) announcement, _ := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic) writeIMSuccess(wrt, req, map[string]any{ "topic": topic, "pending_join_request_count": len(requests), "announcement": groupAnnouncementPayload(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 } switch { case tail == "announcements/latest" && req.Method == http.MethodGet: ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic) if err != nil && !errors.Is(err, types.ErrNotFound) { logs.Warn.Println("im group announcement latest: returning empty after store error", principal.TenantID, topic, err) ann = nil } writeIMSuccess(wrt, req, groupAnnouncementPayload(ann)) 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 } now := time.Now().UTC().Round(time.Millisecond) announcement, err := store.GroupBusiness.PutAnnouncement(principal.TenantID, &types.GroupAnnouncement{ AnnouncementID: "ann_" + store.Store.GetUidString(), Topic: topic, AuthorUserID: principal.Uid, Content: content, Status: types.GroupAnnouncementStatusCurrent, PublishedAt: now, }, body.Version) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) return } writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupAnnouncementPayload(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) if err := store.GroupBusiness.ReadAnnouncement(principal.TenantID, topic, announcementID, principal.Uid, now); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)}) case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet: ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic) if err != nil && !errors.Is(err, types.ErrNotFound) { logs.Warn.Println("im group announcement get: returning empty after store error", principal.TenantID, topic, err) ann = nil } writeIMSuccess(wrt, req, groupAnnouncementPayload(ann)) 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 } switch req.Method { case http.MethodGet: settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic) if err != nil && !errors.Is(err, types.ErrNotFound) { writeIMRelationError(wrt, req, err) return } if settings != nil { writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub)) return } group, _ := store.Topics.Get(principal.TenantID, topic) 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 } settings, err := store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{ JoinApproval: body.JoinApproval, MuteAll: body.MuteAll, MemberUploadFiles: body.MemberUploadFiles, MemberPrivateChat: body.MemberPrivateChat, MemberInvite: body.MemberInvite, Version: body.Version, }) if err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub)) 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.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid, types.GroupMemberRoleAdmin, types.ModeCAdmin|types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil { item["ok"] = false item["reason"] = relationErrorReason(err) } else { item["ok"] = true } results = append(results, item) } members, err = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) if err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) 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.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid, types.GroupMemberRoleMember, types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) 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.GroupBusiness.TransferOwner(principal.TenantID, topic, principal.Uid, target, body.Version, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) 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 } switch { case tail == "mutes" && req.Method == http.MethodGet: items, nextCursor, err := store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100)) if err != nil && !errors.Is(err, types.ErrNotFound) { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor}) 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 } now := time.Now().UTC().Round(time.Millisecond) mutes := make([]types.GroupMute, 0, len(body.Members)) for _, member := range body.Members { uid := types.ParseUserId(strings.TrimSpace(member.UserID)) if uid.IsZero() { continue } var expiresAt *time.Time if raw := strings.TrimSpace(member.ExpiresAt); raw != "" { if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil { expiresAt = &parsed } } mute, err := store.GroupBusiness.UpsertMute(principal.TenantID, &types.GroupMute{Topic: topic, UserID: uid, MutedBy: principal.Uid, MutedAt: now, ExpiresAt: expiresAt, Reason: strings.TrimSpace(body.Reason), Status: types.GroupMuteStatusActive}) if err != nil { writeIMRelationError(wrt, req, err) return } mutes = append(mutes, *mute) } dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(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 } if err := store.GroupBusiness.DeleteMute(principal.TenantID, topic, uid, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return } dispatchIMGroupEvents(principal.TenantID) 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 } privacy, err := store.Profiles.GetPrivacy(principal.TenantID, peer) if err != nil { writeIMRelationError(wrt, req, err) return } canAdd := ctx.Relationship == types.RelationshipNone && imPrivacyAllowsAdd(privacy, "qr") action := imRelationshipAction(ctx.Relationship) if ctx.Relationship == types.RelationshipNone && !canAdd { action = "none" } 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: canAdd, Action: action}), "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) { identifier, tail, ok := userResourcePath(req.URL.Path) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil) return } switch tail { case "profile": serveIMUserProfile(wrt, req, identifier) case "qrcodes": serveIMUserQRCodes(wrt, req, identifier) default: writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil) } } func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier 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 } userID, err := resolveIMUserIdentifier(principal, identifier) if err != nil { writeIMResolveUserError(wrt, req, err) 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 } privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID) if err != nil { writeIMRelationError(wrt, req, err) return } if !imPrivacyAllowsProfile(privacy, ctx.Relationship) { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil) return } writeIMSuccess(wrt, req, userProfilePayload(user, ctx)) } func serveIMUserQRCodes(wrt http.ResponseWriter, req *http.Request, identifier string) { 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 } userID, err := resolveIMUserIdentifier(principal, identifier) if err != nil { writeIMResolveUserError(wrt, req, err) return } if userID.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) 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 } 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 } if userID != principal.Uid { ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID)) if err != nil { writeIMRelationError(wrt, req, err) return } privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID) if err != nil { writeIMRelationError(wrt, req, err) return } if !imPrivacyAllowsProfile(privacy, ctx.Relationship) || !imPrivacyAllowsAdd(privacy, "qr") { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) 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, userID, purpose, expiresAt) 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=" + url.QueryEscape(purpose), "expires_at": expiresAt.Format(time.RFC3339Nano), "display_profile": userSummaryPayload(user, ""), }) } func resolveIMUserIdentifier(principal *imPrincipal, identifier string) (types.Uid, error) { identifier = strings.TrimSpace(identifier) if identifier == "" { return types.ZeroUid, types.ErrMalformed } if userID := types.ParseUserId(identifier); !userID.IsZero() { return userID, nil } if store.Relations == nil { return types.ZeroUid, types.ErrUnsupported } items, _, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, identifier, "exact_account", "", 2) if err != nil { return types.ZeroUid, err } for _, item := range items { if item.User == nil { continue } if userID := item.User.Uid(); !userID.IsZero() { return userID, nil } } return types.ZeroUid, types.ErrNotFound } func writeIMResolveUserError(wrt http.ResponseWriter, req *http.Request, err error) { switch { case errors.Is(err, types.ErrMalformed): writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) case errors.Is(err, types.ErrNotFound): writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil) case errors.Is(err, types.ErrPermissionDenied): writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) default: writeIMRelationError(wrt, req, err) } } 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 } if imReliableHTTPEventsEnabled(req) { eventType := "friend.request." + status if err = publishIMRelationEvent(principal.TenantID, principal.Uid, updated.ApplicantID, updated.RequestID+":"+status, eventType, "好友申请已处理", status); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if err = publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, updated.RequestID+":"+status+":self", eventType, "", ""); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) 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 } if imReliableHTTPEventsEnabled(req) { if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.blocked", "", ""); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } case http.MethodDelete: if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil { writeIMRelationError(wrt, req, err) return } if imReliableHTTPEventsEnabled(req) { if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.unblocked", "", ""); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) 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 } if imReliableHTTPEventsEnabled(req) { stableID := stableIMEventID(req.Header.Get("Idempotency-Key"), principal.Uid.UserId(), peer.UserId()) if err := publishIMRelationEvent(principal.TenantID, principal.Uid, peer, stableID, "friend.deleted", "好友关系已解除", ""); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableID+":self", "friend.deleted", "", ""); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) 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 } if imReliableHTTPEventsEnabled(req) { if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "friend.remark.updated", "", ""); err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) 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) { principal, err := resolveIMHTTPBearer(req) if err != nil { if errors.Is(err, types.ErrUnsupported) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) } else { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) } return nil, false } return principal, true } func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) { authz := strings.TrimSpace(req.Header.Get("Authorization")) token, ok := strings.CutPrefix(authz, "Bearer ") if !ok || strings.TrimSpace(token) == "" || store.Store == nil { return nil, types.ErrPermissionDenied } handler := store.Store.GetLogicalAuthHandler("token") if handler == nil || !handler.IsInitialized() { return nil, types.ErrUnsupported } secret := []byte(strings.TrimSpace(token)) if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil { secret = decoded } rec, _, err := handler.Authenticate(auth.AuthContext{}, secret) if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() { return nil, types.ErrPermissionDenied } return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, nil } func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { user, err := store.Users.Get(tenantID, uid) if err != nil { return "", err } if user == nil || strings.TrimSpace(user.Phone) == "" { return "", types.ErrNotFound } return strings.TrimSpace(user.Phone), 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") if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil { logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err) } else if !allowed { retryAfter := int(ttl.Seconds()) if retryAfter < 1 { retryAfter = int(imVerificationCreateRateWindow.Seconds()) } writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ "retry_after_seconds": retryAfter, }) return } 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 := "123456" 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 } if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil { logs.Warn.Println("im security verification delivery failed; keeping fixed test code active:", action, vc.PhoneMask, err) } logs.Info.Println("im security verification challenge created:", 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 realNameAppealPayload(appeal *types.RealNameAppeal) map[string]any { if appeal == nil { return map[string]any{} } attachments := appeal.Attachments if len(attachments) == 0 && len(appeal.AttachmentsJSON) > 0 { _ = json.Unmarshal(appeal.AttachmentsJSON, &attachments) } return map[string]any{ "appeal_id": appeal.AppealID, "reason": appeal.Reason, "attachments": attachments, "status": appeal.Status, "created_at": appeal.CreatedAt.Format(time.RFC3339Nano), } } func groupInvitationPayload(inv *types.GroupInvitation) map[string]any { if inv == nil { return map[string]any{} } data := map[string]any{ "invitation_id": inv.InvitationID, "topic": inv.Topic, "inviter_id": inv.InviterUserID.UserId(), "target_user_id": inv.TargetUserID.UserId(), "status": inv.Status, "result": inv.Result, "expires_at": inv.ExpiresAt.Format(time.RFC3339Nano), } if inv.HandledAt != nil { data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano) } return data } func groupFilePayloads(files []types.GroupFile) []map[string]any { items := make([]map[string]any, 0, len(files)) for i := range files { items = append(items, groupFilePayload(&files[i])) } return items } func groupFilePayload(file *types.GroupFile) map[string]any { if file == nil { return map[string]any{} } data := map[string]any{ "group_file_id": file.GroupFileID, "topic": file.Topic, "file_id": file.FileID, "file_name": file.FileName, "mime_type": file.MIMEType, "file_type": file.FileType, "file_size": file.FileSize, "uploader_id": file.UploaderID.UserId(), "status": file.Status, "created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano), } if file.SourceTopic != "" { data["source_topic"] = file.SourceTopic } if file.SourceSeq > 0 { data["source_seq"] = file.SourceSeq } if file.DeletedAt != nil { data["deleted_at"] = file.DeletedAt.UTC().Format(time.RFC3339Nano) data["deleted_by"] = file.DeletedBy.UserId() } return data } func normalizeIMFileType(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "image", "video", "audio", "document", "file": return strings.ToLower(strings.TrimSpace(raw)) default: return "file" } } 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 parseIMTime(raw string) (time.Time, error) { parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw)) if err != nil { return time.Time{}, err } return parsed.UTC().Round(time.Millisecond), nil } func parseIMDate(raw string) (time.Time, error) { parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.UTC) if err != nil { return time.Time{}, err } return parsed, nil } func validIMMonth(raw string) bool { if _, err := time.ParseInLocation("2006-01", strings.TrimSpace(raw), time.UTC); err != nil { return false } return true } func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) { key := strings.TrimSpace(req.Header.Get("Idempotency-Key")) if key == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"}) return "", false } return key, true } func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []string, field string) ([]types.Uid, bool) { uids := make([]types.Uid, 0, len(values)) seen := make(map[types.Uid]bool, len(values)) for _, value := range values { uid := types.ParseUserId(strings.TrimSpace(value)) if uid.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) return nil, false } if seen[uid] { continue } seen[uid] = true uids = append(uids, uid) } return uids, true } 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 conversationSettingsPayload(settings *types.ConversationSettings) map[string]any { if settings == nil { return map[string]any{} } return map[string]any{ "topic": settings.Topic, "pinned": settings.Pinned, "muted": settings.Muted, "mute_notifications": settings.Muted, "remark": settings.Remark, "message_retention": settings.MessageRetention, "version": settings.Version, "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), "permissions": map[string]any{"can_update": true}, } } func topicDraftPayload(draft *types.TopicDraft) map[string]any { if draft == nil { return map[string]any{} } var entities any = []any{} if len(draft.EntitiesJSON) > 0 { _ = json.Unmarshal(draft.EntitiesJSON, &entities) } return map[string]any{ "topic": draft.Topic, "content": draft.Content, "entities": entities, "version": draft.Version, "updated_at": draft.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func forwardTargetPayload(sub types.Subscription) map[string]any { topicType := "" switch types.GetTopicCat(sub.Topic) { case types.TopicCatGrp: topicType = "group" case types.TopicCatP2P: topicType = "user" default: return nil } return map[string]any{ "topic": sub.Topic, "type": topicType, "display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic), "avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()), "can_receive_forward": true, } } func forwardTargetMatches(topic, displayName, query string) bool { if query == "" { return true } return strings.Contains(strings.ToLower(topic), query) || strings.Contains(strings.ToLower(displayName), query) } func imDisplayNameFromPublic(public any, fallback string) string { if value, ok := imPublicString(public, "fn"); ok { return value } if value, ok := imPublicString(public, "name"); ok { return value } return fallback } func imAvatarFileIDFromPublic(public any) string { if value, ok := imPublicString(public, "avatar_file_id"); ok { return value } if value, ok := imPublicString(public, "photo"); ok { return value } return "" } func imPublicString(public any, key string) (string, bool) { values, ok := public.(map[string]any) if !ok { return "", false } value, ok := values[key].(string) if !ok { return "", false } value = strings.TrimSpace(value) return value, value != "" } 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 user := &types.User{ObjHeader: types.ObjHeader{Id: sub.User}, 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 groupGovernancePayloadFromSettings(settings *types.GroupSettings, selfSub *types.Subscription) map[string]any { governance := defaultGroupGovernance() if settings != nil { governance["join_approval"] = settings.JoinApproval governance["mute_all"] = settings.MuteAll governance["member_upload_files"] = settings.MemberUploadFiles governance["member_private_chat"] = settings.MemberPrivateChat governance["member_invite"] = settings.MemberInvite governance["version"] = settings.Version } 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 mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any { if payload == nil { payload = map[string]any{} } if settings == nil { return payload } payload["topic"] = settings.Topic payload["name"] = settings.Name payload["avatar_file_id"] = settings.AvatarFileID payload["description"] = settings.Description payload["owner"] = settings.OwnerUserID.UserId() payload["member_count"] = settings.MemberCount payload["version"] = settings.Version return payload } func groupJoinRequestPayload(req *types.GroupJoinRequest) map[string]any { if req == nil { return map[string]any{} } data := map[string]any{ "request_id": req.RequestID, "user_id": req.ApplicantUserID.UserId(), "inviter_id": req.InviterUserID.UserId(), "source": req.Source, "message": req.Message, "status": req.Status, "version": req.Version, "created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": req.UpdatedAt.UTC().Format(time.RFC3339Nano), } if req.HandledBy != types.ZeroUid { data["handled_by"] = req.HandledBy.UserId() } if req.HandledAt != nil { data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano) } if req.RejectReason != "" { data["reject_reason"] = req.RejectReason } return data } func groupJoinRequestPayloads(items []types.GroupJoinRequest) []map[string]any { out := make([]map[string]any, 0, len(items)) for i := range items { out = append(out, groupJoinRequestPayload(&items[i])) } return out } func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any { if ann == nil { return map[string]any{ "announcement_id": "", "content": "", "author": "", "version": 0, "published_at": "", } } return map[string]any{ "announcement_id": ann.AnnouncementID, "content": ann.Content, "author": ann.AuthorUserID.UserId(), "version": ann.Version, "published_at": ann.PublishedAt.UTC().Format(time.RFC3339Nano), } } func groupMutePayload(mute types.GroupMute) map[string]any { data := map[string]any{ "user_id": mute.UserID.UserId(), "muted_by": mute.MutedBy.UserId(), "muted_at": mute.MutedAt.UTC().Format(time.RFC3339Nano), "reason": mute.Reason, "status": mute.Status, "version": mute.Version, } if mute.ExpiresAt != nil { data["expires_at"] = mute.ExpiresAt.UTC().Format(time.RFC3339Nano) } return data } func groupMutePayloads(items []types.GroupMute) []map[string]any { out := make([]map[string]any, 0, len(items)) for _, item := range items { out = append(out, groupMutePayload(item)) } return out } 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 groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string, limit, cursor int, mentionableOnly, muteEligible bool) string { return strings.Join([]string{ topic, uid.UserId(), access.String(), strings.ToLower(strings.TrimSpace(query)), strings.TrimSpace(roleFilter), strings.TrimSpace(eligibleRole), strconv.Itoa(limit), strconv.Itoa(cursor), strconv.FormatBool(mentionableOnly), strconv.FormatBool(muteEligible), }, "|") } func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, 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 callRecordPayloads(records []types.CallRecord, viewer types.Uid) []map[string]any { items := make([]map[string]any, 0, len(records)) for _, record := range records { items = append(items, callRecordPayload(record, viewer)) } return items } func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any { peer := record.CalleeID direction := "outgoing" if record.CalleeID == viewer { peer = record.CallerID direction = "incoming" } data := map[string]any{ "call_id": record.CallID, "topic": record.Topic, "seq": record.SeqID, "caller_id": record.CallerID.UserId(), "callee_id": record.CalleeID.UserId(), "peer_user_id": peer.UserId(), "direction": direction, "call_type": record.CallType, "status": record.Status, "result": record.Status, "duration_ms": record.DurationMS, "started_at": record.StartedAt.UTC().Format(time.RFC3339Nano), "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), } if record.EndReason != "" { data["end_reason"] = record.EndReason } if record.AcceptedAt != nil { data["accepted_at"] = record.AcceptedAt.UTC().Format(time.RFC3339Nano) } if record.FinishedAt != nil { data["finished_at"] = record.FinishedAt.UTC().Format(time.RFC3339Nano) } return data } func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any { items := make([]map[string]any, 0, len(modules)) for _, module := range modules { if !isAllowedIMWorkbenchRoute(module.Route) { continue } items = append(items, map[string]any{ "code": module.Code, "name": module.Name, "icon_file_id": module.IconFileID, "route": module.Route, "unread_count": module.UnreadCount, "enabled": module.Enabled, "permission": module.Permission, }) } return items } func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid, modules []map[string]any) (map[string]any, map[string]any, error) { newFriendsPending := 0 if store.Relations != nil { count, err := store.Relations.CountPendingFriendRequests(tenantID, uid) if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { return nil, nil, err } if err == nil { newFriendsPending = count } } systemUnread := 0 if store.Security != nil { count, err := store.Security.CountUnreadSystemNotifications(tenantID, uid) if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { return nil, nil, err } if err == nil { systemUnread = count } } workbenchTotalUnread := 0 for _, module := range modules { enabled, _ := module["enabled"].(bool) if !enabled { continue } workbenchTotalUnread += intFromAny(module["unread_count"]) } entryBadges := map[string]any{ "contacts_total_unread": newFriendsPending, "new_friends_pending_count": newFriendsPending, "groups_unread_count": 0, "file_assistant_unread_count": 0, "system_unread_count": systemUnread, "workbench_total_unread": workbenchTotalUnread, } entries := map[string]any{ "new_friends": map[string]any{ "enabled": true, "route": "/contacts/new-friends", "unread_count": newFriendsPending, "unread_source": "business", }, "groups": map[string]any{ "enabled": true, "route": "/contacts/groups", "unread_count": 0, "unread_source": "business", }, "file_assistant": map[string]any{ "enabled": true, "route": "/chat/file-assistant", "topic": "slf", "unread_count": 0, "unread_source": "topic", }, "system_messages": map[string]any{ "enabled": true, "route": "/chat/system", "topic": "sys", "unread_count": systemUnread, "unread_source": "business", }, } return entryBadges, entries, nil } func organizationDepartmentPayloads(departments []types.OrganizationDepartment) []map[string]any { items := make([]map[string]any, 0, len(departments)) for _, department := range departments { items = append(items, organizationDepartmentPayload(department)) } return items } func organizationDepartmentPayload(department types.OrganizationDepartment) map[string]any { return map[string]any{ "department_id": department.DepartmentID, "name": department.Name, "parent_id": department.ParentID, "child_count": department.ChildCount, "member_count": department.MemberCount, "version": department.Version, } } func organizationMemberPayloads(members []types.OrganizationMember) []map[string]any { items := make([]map[string]any, 0, len(members)) for _, member := range members { items = append(items, organizationMemberPayload(member)) } return items } func organizationMemberPayload(member types.OrganizationMember) map[string]any { payload := map[string]any{ "user_id": member.UserID.UserId(), "department_id": member.DepartmentID, "display_name": member.DisplayName, "position": member.Position, "avatar_file_id": member.AvatarFileID, "status": member.Status, "contactable": member.Contactable, "version": member.Version, } if member.Mobile != "" { payload["mobile"] = member.Mobile } if member.Email != "" { payload["email"] = member.Email } return payload } func organizationSearchPayloads(items []types.OrganizationSearchItem) []map[string]any { payload := make([]map[string]any, 0, len(items)) for _, item := range items { switch item.Type { case "department": if item.Department != nil { payload = append(payload, map[string]any{ "type": "department", "department": organizationDepartmentPayload(*item.Department), }) } case "user": if item.Member != nil { payload = append(payload, map[string]any{ "type": "user", "user": organizationMemberPayload(*item.Member), }) } } } return payload } func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { participants := meetingParticipantPayloads(meeting.Participants) data := map[string]any{ "meeting_id": meeting.MeetingID, "title": meeting.Title, "organizer_id": meeting.OrganizerID.UserId(), "start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano), "end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano), "location": meeting.Location, "state": meeting.State, "reminder_minutes": meeting.ReminderMinutes, "version": meeting.Version, "participants": participants, "permissions": meetingPermissionsPayload(meeting, viewer), "created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano), } if meeting.CanceledAt != nil { data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano) } for _, participant := range meeting.Participants { if participant.UserID == viewer { data["my_response"] = participant.Response break } } return data } func meetingParticipantPayloads(participants []types.MeetingParticipant) []map[string]any { items := make([]map[string]any, 0, len(participants)) for _, participant := range participants { item := map[string]any{ "user_id": participant.UserID.UserId(), "response": participant.Response, } if participant.ResponseAt != nil { item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano) } items = append(items, item) } return items } func meetingParticipantUIDs(meeting *types.Meeting) []types.Uid { if meeting == nil { return nil } return meetingParticipantUserIDs(meeting.Participants) } func meetingParticipantUserIDs(participants []types.MeetingParticipant) []types.Uid { items := make([]types.Uid, 0, len(participants)) for _, participant := range participants { if !participant.UserID.IsZero() { items = append(items, participant.UserID) } } return items } func publishIMWorkbenchAndAudit(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, targets []types.Uid, title, summary string, data map[string]any) { if err := enqueueKafkaWorkbenchEvent(ctx, tenantID, actor, module, action, resourceID, targets, title, summary, data); err != nil { logs.Warn.Printf("im http: failed to enqueue workbench event action=%s resource=%s: %v", action, resourceID, err) } if err := enqueueKafkaAuditEvent(ctx, tenantID, "user", actor, action, module, resourceID, "succeeded", "", data); err != nil { logs.Warn.Printf("im http: failed to enqueue audit event action=%s resource=%s: %v", action, resourceID, err) } } func meetingPermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { isOrganizer := meeting.OrganizerID == viewer isScheduled := meeting.State == types.MeetingStateScheduled canRespond := false for _, participant := range meeting.Participants { if participant.UserID == viewer && !isOrganizer { canRespond = true break } } if time.Now().UTC().After(meeting.EndAt) { canRespond = false } return map[string]any{ "can_update": isOrganizer && isScheduled, "can_cancel": isOrganizer && isScheduled, "can_remind": isOrganizer && isScheduled, "can_respond": canRespond && isScheduled, } } func dailyReportListPayloads(reports []types.DailyReport) []map[string]any { items := make([]map[string]any, 0, len(reports)) for _, report := range reports { items = append(items, dailyReportListPayload(report)) } return items } func dailyReportListPayload(report types.DailyReport) map[string]any { data := map[string]any{ "report_id": report.ReportID, "author_id": report.AuthorID.UserId(), "report_date": report.ReportDate.Format("2006-01-02"), "content_short": reportPreview(report.Content), "status": report.Status, "draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), "read_state": report.ReadState, "version": report.Version, } if report.SubmittedAt != nil { data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) } return data } func dailyReportDetailPayload(report *types.DailyReport, viewer types.Uid) map[string]any { data := dailyReportListPayload(*report) data["content"] = report.Content data["attachments"] = report.Attachments data["updated_at"] = report.UpdatedAt.UTC().Format(time.RFC3339Nano) data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano) data["permissions"] = map[string]any{ "can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, "can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, "can_view": report.AuthorID == viewer, } return data } func monthlyReportListPayloads(reports []types.MonthlyReport) []map[string]any { items := make([]map[string]any, 0, len(reports)) for _, report := range reports { items = append(items, monthlyReportListPayload(report)) } return items } func monthlyReportListPayload(report types.MonthlyReport) map[string]any { action := "view" if report.Status == types.DailyReportStatusDraft { action = "edit" } data := map[string]any{ "report_id": report.ReportID, "author_id": report.AuthorID.UserId(), "month": report.Month, "work_summary_short": reportPreview(report.WorkSummary), "next_plan_short": reportPreview(report.NextPlan), "status": report.Status, "read_state": report.ReadState, "version": report.Version, "action": action, "updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), } if report.SubmittedAt != nil { data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) } return data } func reportPreview(text string) string { text = strings.Join(strings.Fields(text), " ") runes := []rune(text) if len(runes) <= reportPreviewMaxRunes { return text } return string(runes[:reportPreviewMaxRunes]) + "..." } func monthlyReportDetailPayload(report *types.MonthlyReport, viewer types.Uid) map[string]any { data := monthlyReportListPayload(*report) data["work_summary"] = report.WorkSummary data["next_plan"] = report.NextPlan data["attachments"] = report.Attachments data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano) data["permissions"] = map[string]any{ "can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, "can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, "can_view": report.AuthorID == viewer, } data["validation"] = map[string]any{ "work_summary_required": true, "next_plan_required": true, "max_length": 10000, } return data } func mailboxConfigPayload(config *types.MailboxConfig) map[string]any { return map[string]any{ "enabled": config.Enabled, "allow_anonymous": config.AllowAnonymous, "required_fields": config.RequiredFields, "max_length": config.MaxLength, "attachment_policy": config.AttachmentPolicy, "notice": map[string]any{ "title": config.Notice.Title, "content": config.Notice.Content, "updated_at": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano), "version": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano), }, } } func defaultIMMailboxConfig() *types.MailboxConfig { now := time.Now().UTC().Round(time.Millisecond) return &types.MailboxConfig{ Enabled: false, AllowAnonymous: false, RequiredFields: []string{"subject", "content"}, MaxLength: 1000, AttachmentPolicy: map[string]any{ "max_count": 0, "enabled": false, }, Notice: types.MailboxNotice{ Title: "总经理信箱", Content: "当前暂未开放。", UpdatedAt: now, }, UpdatedAt: now, } } func mailboxSubmissionResultPayload(submission *types.MailboxSubmission) map[string]any { return map[string]any{ "submission_id": submission.SubmissionID, "status": submission.Status, "submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano), } } func userProfilePayload10(profile *types.UserProfile) map[string]any { data := map[string]any{ "avatar_file_id": profile.AvatarFileID, "nickname": profile.Nickname, "birth_date": profile.BirthDate, "signature": profile.Signature, "gender": profile.Gender, "region": profile.Region, "email": profile.Email, "public_account": profile.PublicAccount, "version": profile.Version, "updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano), "field_rules": map[string]any{ "nickname": map[string]any{"min_length": 1, "max_length": 32}, "signature": map[string]any{"max_length": 120}, "birth_date": map[string]any{"format": "YYYY-MM-DD"}, "gender": []string{"male", "female", "other", ""}, "email": map[string]any{"max_length": 128}, }, } return data } func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any { return map[string]any{ "add_me_methods": settings.AddMeMethods, "profile_visibility": settings.ProfileVisibility, "last_seen_visibility": settings.LastSeenVisibility, "read_receipts": settings.ReadReceipts, "version": settings.Version, "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), "editable": map[string]any{ "add_me_methods": true, "profile_visibility": true, "last_seen_visibility": true, "read_receipts": true, }, } } func imPrivacyAllowsAdd(settings *types.UserPrivacySettings, source string) bool { if settings == nil { return false } switch strings.ToLower(strings.TrimSpace(source)) { case "phone", "qr", "group": source = strings.ToLower(strings.TrimSpace(source)) default: source = "account" } allowed, exists := settings.AddMeMethods[source] return !exists || allowed } func imPrivacyAllowsProfile(settings *types.UserPrivacySettings, relationship string) bool { if settings == nil { return false } switch strings.ToLower(strings.TrimSpace(settings.ProfileVisibility)) { case "everyone": return true case "friends", "": return relationship == types.RelationshipFriend default: return false } } func imCanViewLastSeen(tenantID types.TenantID, viewer, target types.Uid) bool { if viewer.IsZero() || target.IsZero() { return false } if viewer == target { return true } // Legacy deployments without the product privacy stores retain Tinode's // original presence behavior. if store.Profiles == nil || store.Relations == nil { return true } settings, err := store.Profiles.GetPrivacy(tenantID, target) if err != nil || settings == nil { return false } switch strings.ToLower(strings.TrimSpace(settings.LastSeenVisibility)) { case "everyone": return true case "friends", "": ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, target, viewer.P2PName(target)) return err == nil && ctx != nil && ctx.Relationship == types.RelationshipFriend default: return false } } func appReleasePayload(release *types.AppRelease) map[string]any { return map[string]any{ "platform": release.Platform, "channel": release.Channel, "latest_version": release.LatestVersion, "min_supported_version": release.MinSupportedVersion, "update_type": release.UpdateType, "release_notes": release.ReleaseNotes, "store_url": release.StoreURL, "package_url": release.PackageURL, "checksum": release.Checksum, "remind_after_seconds": release.RemindAfterSeconds, "published_at": release.PublishedAt.UTC().Format(time.RFC3339Nano), } } func youthModePayload(settings *types.YouthModeSettings) map[string]any { data := map[string]any{ "enabled": settings.Enabled, "policy_version": settings.PolicyVersion, "restrictions": settings.Restrictions, "password_set": settings.PasswordSet, "can_enable": settings.CanEnable, "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), } if settings.EnabledAt != nil { data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano) } return data } func devicePushTokenPayload(token *types.DevicePushToken) map[string]any { return map[string]any{ "device_id": token.DeviceID, "provider": token.Provider, "app_version": token.AppVersion, "platform": token.Platform, "updated_at": token.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func riskChallengePayload(challenge *types.RiskChallenge) map[string]any { return map[string]any{ "risk_challenge_id": challenge.ChallengeID, "action": challenge.Action, "level": challenge.Level, "notice_code": challenge.NoticeCode, "notice_params": challenge.NoticeParams, "state": challenge.State, "decision": challenge.Decision, "expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339Nano), "updated_at": challenge.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func abuseReasonPayload(reason types.AbuseReason) map[string]any { return map[string]any{ "target_type": reason.TargetType, "reason_code": reason.ReasonCode, "label": reason.Label, "requires_description": reason.RequiresDescription, "allows_evidence": reason.AllowsEvidence, } } func defaultIMAbuseReasons(targetType string) []types.AbuseReason { targetType = strings.ToLower(strings.TrimSpace(targetType)) if targetType == "" { targetType = "message" } return []types.AbuseReason{ {TargetType: targetType, ReasonCode: "spam", Label: "垃圾广告", RequiresDescription: false, AllowsEvidence: true, SortOrder: 10}, {TargetType: targetType, ReasonCode: "fraud", Label: "欺诈风险", RequiresDescription: false, AllowsEvidence: true, SortOrder: 20}, {TargetType: targetType, ReasonCode: "harassment", Label: "骚扰辱骂", RequiresDescription: false, AllowsEvidence: true, SortOrder: 30}, {TargetType: targetType, ReasonCode: "other", Label: "其他", RequiresDescription: true, AllowsEvidence: true, SortOrder: 99}, } } func abuseReportPayload(report *types.AbuseReport) map[string]any { return map[string]any{ "report_id": report.ReportID, "status": report.Status, "created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano), } } func feedbackConfigPayload(config *types.FeedbackConfig) map[string]any { return map[string]any{ "categories": config.Categories, "max_length": config.MaxLength, "attachment_policy": config.AttachmentPolicy, "allow_contact": config.AllowContact, "diagnostics_notice": config.DiagnosticsNotice, "updated_at": config.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]any { return map[string]any{ "feedback_id": feedback.FeedbackID, "status": feedback.Status, "created_at": feedback.CreatedAt.UTC().Format(time.RFC3339Nano), } } func publicUploadPayload(upload *types.PublicUpload) map[string]any { return map[string]any{ "file_id": upload.FileID, "purpose": upload.Purpose, "mime_type": upload.MimeType, "size": upload.Size, "expires_at": upload.ExpiresAt.UTC().Format(time.RFC3339Nano), } } func tenantApplicationPayload(application *types.TenantApplication) map[string]any { return map[string]any{ "application_id": application.ApplicationID, "status": application.Status, "estimated_review_hours": application.EstimatedReviewHour, "submitted_at": application.SubmittedAt.UTC().Format(time.RFC3339Nano), } } func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]any { return map[string]any{ "feedback_id": feedback.FeedbackID, "status": feedback.Status, "submitted_at": feedback.SubmittedAt.UTC().Format(time.RFC3339Nano), } } func isAllowedIMWorkbenchRoute(route string) bool { switch route { case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": return true default: return false } } 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, "") if public, ok := item.User.Public.(map[string]any); ok { copyOptionalProfileFields(data, public) } else if public, ok := item.User.Public.(types.KVMap); ok { copyOptionalProfileFields(data, map[string]any(public)) } 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.ErrInvalidResponse): writeIMError(wrt, req, http.StatusConflict, 20009, "VERSION_CONFLICT", "version conflict", 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 } dispatchIMGroupEvents(principal.TenantID) 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 { writeIMRelationError(wrt, req, err) 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 requireTopicReader(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).IsReader() { 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 forwardFailurePayload(topic string, seq int64, target, reason string) map[string]any { return map[string]any{ "source": map[string]any{"topic": strings.TrimSpace(topic), "seq": seq}, "target_topic": strings.TrimSpace(target), "status": "failed", "reason": reason, } } func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap { head := types.KVMap{} if msg != nil { for key, value := range msg.Head { head[key] = value } msgType := imMessageType(msg) if strings.TrimSpace(fmt.Sprint(head["type"])) == "" || fmt.Sprint(head["type"]) == "" { head["type"] = msgType } if strings.TrimSpace(fmt.Sprint(head["x-im-type"])) == "" || fmt.Sprint(head["x-im-type"]) == "" { head["x-im-type"] = msgType } head["x-im-forward-source"] = map[string]any{"topic": msg.Topic, "seq": msg.SeqId, "from": msg.From} } head["x-im-forwarded"] = true head["x-im-client-mid"] = clientMid return head } func forwardedMessageContent(msg *types.Message, comment string) any { if strings.TrimSpace(comment) == "" { if msg == nil { return nil } return msg.Content } return map[string]any{ "forwarded": true, "comment": strings.TrimSpace(comment), "snapshot": favoriteSnapshot(msg), } } 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 riskChallengeIDFromPath(path string) string { const marker = "/v1/im/security/risk-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 != "confirm" { return "" } return strings.TrimSpace(challengeID) } func cleanStringList(values []string, limit int) []string { if limit <= 0 { limit = len(values) } capacity := len(values) if capacity > limit { capacity = limit } cleaned := make([]string, 0, capacity) seen := make(map[string]struct{}, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} cleaned = append(cleaned, value) if len(cleaned) >= limit { break } } return cleaned } func isAllowedPublicUploadPurpose(purpose string) bool { switch strings.TrimSpace(purpose) { case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback: return true default: return false } } func isAllowedPublicUploadMime(purpose, mimeType string) bool { mimeType = strings.ToLower(strings.TrimSpace(mimeType)) switch purpose { case types.PublicUploadPurposeEnterpriseLicense: return strings.HasPrefix(mimeType, "image/jpeg") || strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/webp") || mimeType == "application/pdf" case types.PublicUploadPurposeTenantFeedback: return strings.HasPrefix(mimeType, "image/jpeg") || strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/webp") default: return false } } func detectIMUploadMime(file interface { io.Reader io.Seeker }, contentType string) (string, error) { buff := make([]byte, 512) n, err := file.Read(buff) if err != nil && err != io.EOF { return "", err } if _, err = file.Seek(0, io.SeekStart); err != nil { return "", err } mimeType := http.DetectContentType(buff[:n]) if mimeType == "application/octet-stream" { if parsed, _, err := mime.ParseMediaType(contentType); err == nil && parsed != "" { mimeType = parsed } } return strings.ToLower(strings.TrimSpace(mimeType)), nil } func isValidCreditCode(code string) bool { if len(code) != 18 { return false } for _, r := range code { if !(r >= '0' && r <= '9' || r >= 'A' && r <= 'Z') { return false } } return true } func isValidIMEmail(email string) bool { if len(email) > 255 || strings.ContainsAny(email, "\r\n\t ") { return false } return regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`).MatchString(email) } func cleanTenantCodeHint(code string) string { code = strings.TrimSpace(code) if len(code) > 64 { return code[:64] } return code } func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) { targetType := strings.ToLower(strings.TrimSpace(target.Type)) snapshot := map[string]any{"type": targetType} switch targetType { case "user": uid := types.ParseUserId(strings.TrimSpace(target.UserID)) if uid.IsZero() || uid == reporter { return "", 0, "", 0, nil, types.ErrMalformed } if store.Users != nil { if _, err := store.Users.Get(tenantID, uid); err != nil { return "", 0, "", 0, nil, err } } snapshot["user_id"] = uid.UserId() return targetType, uid, "", 0, snapshot, nil case "group": topic := strings.TrimSpace(target.Topic) if types.GetTopicCat(topic) != types.TopicCatGrp { return "", 0, "", 0, nil, types.ErrMalformed } if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil { return "", 0, "", 0, nil, err } snapshot["topic"] = topic return targetType, 0, topic, 0, snapshot, nil case "message": topic := strings.TrimSpace(target.Topic) seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64) if topic == "" || seq <= 0 || err != nil { return "", 0, "", 0, nil, types.ErrMalformed } if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil { return "", 0, "", 0, nil, err } msg, err := store.Messages.GetOne(tenantID, topic, seq) if err != nil { return "", 0, "", 0, nil, err } snapshot["topic"] = topic snapshot["seq"] = seq snapshot["from"] = msg.From snapshot["ts"] = msg.CreatedAt.UTC().Format(time.RFC3339Nano) return targetType, 0, topic, seq, snapshot, nil default: return "", 0, "", 0, nil, types.ErrMalformed } } func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string) error { if store.Subs == nil { return types.ErrPermissionDenied } sub, err := store.Subs.Get(tenantID, topic, uid, false) if err != nil { return err } if sub == nil || !sub.ModeGiven.IsReader() { return types.ErrPermissionDenied } return nil } 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 groupInvitationResourcePath(path string) (string, string, bool) { const marker = "/v1/im/group-invitations/" idx := strings.Index(path, marker) if idx < 0 { return "", "", false } rest := strings.TrimPrefix(path[idx:], marker) invitationID, tail, ok := strings.Cut(rest, "/") if !ok || strings.TrimSpace(invitationID) == "" { return "", "", false } return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true } func messageReceiptsPath(path string) (string, int64, 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.ParseInt(seqValue, 10, 64) 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 topicFromCallsPath(path string) string { const prefix = "/v1/im/topics/" const suffix = "/calls" return topicFromPathWithSuffix(path, prefix, suffix) } func topicFromDraftPath(path string) string { const prefix = "/v1/im/topics/" const suffix = "/draft" 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, int64, 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.ParseInt(parts[2], 10, 64) if err != nil || seq <= 0 { return "", 0, false } return parts[0], seq, true } func messageActionPath(path, action string) (string, int64, 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.ParseInt(seqValue, 10, 64) 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 conversationResourceFromPath(path string) (string, string) { const prefix = "/v1/im/conversations/" idx := strings.Index(path, prefix) if idx < 0 { return "", "" } rest := strings.Trim(strings.TrimPrefix(path[idx:], prefix), "/") topic, tail, ok := strings.Cut(rest, "/") if !ok { return topic, "" } return strings.TrimSpace(topic), strings.Trim(strings.TrimSpace(tail), "/") } 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) (string, string, bool) { const prefix = "/v1/im/users/" idx := strings.Index(path, prefix) if idx < 0 { return "", "", false } rest := strings.TrimPrefix(path[idx:], prefix) identifier, tail, ok := strings.Cut(rest, "/") identifier = strings.TrimSpace(identifier) if !ok || identifier == "" || tail == "" { return "", "", false } return identifier, strings.Trim(tail, "/"), true } func organizationDepartmentResourcePath(path string) (string, string, bool) { const prefix = "/v1/im/organization/departments/" idx := strings.Index(path, prefix) if idx < 0 { return "", "", false } rest := strings.TrimPrefix(path[idx:], prefix) departmentID, tail, ok := strings.Cut(rest, "/") if !ok || departmentID == "" || tail == "" { return "", "", false } return strings.TrimSpace(departmentID), strings.Trim(tail, "/"), true } func meetingResourcePath(path string) (string, string, bool) { const prefix = "/v1/im/meetings/" idx := strings.Index(path, prefix) if idx < 0 { return "", "", false } rest := strings.TrimPrefix(path[idx:], prefix) meetingID, tail, ok := strings.Cut(rest, "/") if meetingID == "" { return "", "", false } if !ok { return strings.TrimSpace(meetingID), "", true } return strings.TrimSpace(meetingID), strings.Trim(tail, "/"), true } func dailyReportResourcePath(path string) (string, string, bool) { const prefix = "/v1/im/reports/daily/" idx := strings.Index(path, prefix) if idx < 0 { return "", "", false } rest := strings.TrimPrefix(path[idx:], prefix) reportID, tail, ok := strings.Cut(rest, "/") if reportID == "" { return "", "", false } if !ok { return strings.TrimSpace(reportID), "", true } return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true } func monthlyReportResourcePath(path string) (string, string, bool) { const prefix = "/v1/im/reports/monthly/" idx := strings.Index(path, prefix) if idx < 0 { return "", "", false } rest := strings.TrimPrefix(path[idx:], prefix) reportID, tail, ok := strings.Cut(rest, "/") if reportID == "" { return "", "", false } if !ok { return strings.TrimSpace(reportID), "", true } return strings.TrimSpace(reportID), 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 } left, right, err := types.ParseP2P(topic) if err != nil { return types.ZeroUid } if left == uid { return right } if right == uid { return left } return types.ZeroUid } func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) { topic = strings.TrimSpace(topic) if len(topic) < 3 || uid.IsZero() { return "", types.ZeroUid } if types.GetTopicCat(topic) == types.TopicCatMe { peer := types.ParseUserId(topic) if peer.IsZero() || peer == uid { return "", types.ZeroUid } return uid.P2PName(peer), peer } peer := peerFromP2PTopic(topic, uid) if peer.IsZero() { return "", types.ZeroUid } return topic, peer } func normalizeConversationSettingsTopic(topic string, uid types.Uid) string { if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() { return normalized } return strings.TrimSpace(topic) }