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" imReasonForbidden = "FORBIDDEN" 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" imReasonResourceNotFound = "RESOURCE_NOT_FOUND" imReasonGroupDissolved = "GROUP_DISSOLVED" imReasonValidationFailed = "VALIDATION_FAILED" ) const ( imVerificationCodeLength = 6 imVerificationExpiresInSeconds = 300 imVerificationResendAfterSeconds = 60 imVerificationMaxAttempts = 5 imVerificationCreateRateWindow = time.Hour imSensitiveChallengeRateLimitEnabled = true 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(getAPIKey(req))); !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)) 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 } 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"` SDKVersion string `json:"sdk_version,omitempty"` PushEnv string `json:"push_env,omitempty"` NotificationPermission string `json:"notification_permission,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 imAttendanceCheckinRequest struct { Date string `json:"date"` Action string `json:"action"` RuleID string `json:"rule_id,omitempty"` ShiftID string `json:"shift_id,omitempty"` ClientTime string `json:"client_time"` Location types.KVMap `json:"location,omitempty"` Wifi types.KVMap `json:"wifi,omitempty"` Device types.KVMap `json:"device,omitempty"` } type imAttendanceAppealRequest struct { RecordID string `json:"record_id"` AppealType string `json:"appeal_type"` Reason string `json:"reason"` TargetAction string `json:"target_action,omitempty"` TargetTime string `json:"target_time,omitempty"` AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"` } type imSystemNotificationReadRequest struct { ReadAt string `json:"read_at,omitempty"` } type imMessageRecallRequest struct { Version int `json:"version"` Reason string `json:"reason,omitempty"` } type imMessageBatchRecallRequest struct { Items []imMessageRecallItem `json:"items"` Reason string `json:"reason,omitempty"` } type imMessageRecallItem struct { Seq int64 `json:"seq"` Version int `json:"version,omitempty"` 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 imForwardSeq `json:"seq"` } `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 imForwardSeq `json:"seq"` } `json:"sources"` TargetTopics []string `json:"target_topics"` Comment string `json:"comment,omitempty"` } type imForwardSeq int64 func (s *imForwardSeq) UnmarshalJSON(data []byte) error { var number int64 if err := json.Unmarshal(data, &number); err == nil { *s = imForwardSeq(number) return nil } var text string if err := json.Unmarshal(data, &text); err != nil { return err } text = strings.TrimSpace(text) if text == "" { return nil } parsed, err := strconv.ParseInt(text, 10, 64) if err != nil { return err } *s = imForwardSeq(parsed) return nil } 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"` Version int `json:"version,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 imGroupDissolveRequest struct { Reason string `json:"reason,omitempty"` } type imGroupMuteBatchRequest struct { Members []struct { UserID string `json:"user_id"` ExpiresAt string `json:"expires_at,omitempty"` } `json:"members"` UserIDs []string `json:"user_ids,omitempty"` Reason string `json:"reason,omitempty"` Version int `json:"version,omitempty"` } type imSystemMessageActor struct { UserID string `json:"user_id,omitempty"` Name string `json:"name,omitempty"` Click map[string]any `json:"click,omitempty"` } type imSystemMessageAction struct { Type string `json:"type"` Label string `json:"label,omitempty"` TargetID string `json:"target_id,omitempty"` MinRole string `json:"min_role,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) httpBase := strings.TrimRight(base, "/") 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://") } wsEndpoint := strings.TrimRight(wsBase, "/") + "/v0/channels" if apiKey := strings.TrimSpace(getAPIKey(req)); apiKey != "" { wsEndpoint += "?apikey=" + url.QueryEscape(apiKey) } return map[string]any{ "websocket": wsEndpoint, "api": httpBase + "/v1/im", "file": httpBase + "/v0/file", "legal": map[string]any{ "user_terms": httpBase + "/legal/user-terms", "privacy_policy": httpBase + "/legal/privacy-policy", }, } } 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, "") } publishRealtimeEvictUser(tenant.ID, vc.TargetUser, "password_reset", "") 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 { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "risk challenge not found", nil) return } 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"))) if !isAllowedIMAbuseTargetType(targetType) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_type"}) return } 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 !validIMSecurityChallengeScene(action) || 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) if err := populateWorkbenchModuleUnreadCounts(principal.TenantID, principal.Uid, modulePayloads); err != nil { writeIMRelationError(wrt, req, err) return } entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid, modulePayloads) if err != nil { writeIMRelationError(wrt, req, err) return } audioCallEnabled, videoCallEnabled := imCallFeatureFlags() writeIMSuccess(wrt, req, map[string]any{ "features": map[string]any{ "create_group": true, "user_search": true, "scan_qr": true, "system_notifications": true, "attendance": true, "message_recall": true, "message_edit": true, "audio_call": audioCallEnabled, "video_call": videoCallEnabled, }, "permissions": map[string]any{ "create_group": true, "user_search": true, "scan_qr": true, "audio_call": audioCallEnabled, "video_call": videoCallEnabled, }, "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 } modulePayloads := workbenchModulePayloads(modules) if err := populateWorkbenchModuleUnreadCounts(principal.TenantID, principal.Uid, modulePayloads); 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": modulePayloads, "enterprise": map[string]any{ "tenant_code": tenant.Code, "feature_flags": map[string]any{ "organization": true, "attendance": 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 serveIMAttendanceConfig(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 := parseIMOptionalDate(req.URL.Query().Get("date"), time.Now().UTC()) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) return } config, err := store.Attendance.Config(principal.TenantID, principal.Uid, date) if err != nil { logs.Warn.Println("im attendance config failed:", principal.TenantID, principal.Uid.UserId(), err) writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, attendanceConfigPayload(config)) } func serveIMAttendanceRecords(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 } if !validIMOptionalDateQuery(wrt, req, "date") || !validIMOptionalDateQuery(wrt, req, "from") || !validIMOptionalDateQuery(wrt, req, "to") { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) records, nextCursor, stats, err := store.Attendance.ListRecords(principal.TenantID, principal.Uid, types.AttendanceRecordListOptions{ Scope: req.URL.Query().Get("scope"), From: strings.TrimSpace(req.URL.Query().Get("from")), To: strings.TrimSpace(req.URL.Query().Get("to")), Date: strings.TrimSpace(req.URL.Query().Get("date")), Status: strings.TrimSpace(req.URL.Query().Get("status")), DepartmentID: strings.TrimSpace(req.URL.Query().Get("department_id")), UserID: strings.TrimSpace(req.URL.Query().Get("user_id")), Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")), Limit: limit, }) if err != nil { logs.Warn.Println("im attendance records failed:", principal.TenantID, principal.Uid.UserId(), err) writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "items": attendanceRecordPayloads(records), "next_cursor": nextCursor, "has_more": nextCursor != "", "statistics": attendanceStatsPayload(stats), "permissions": map[string]any{ "can_checkin": true, "can_view_managed": true, "can_appeal": true, }, }) } func serveIMAttendanceCheckins(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 imAttendanceCheckinRequest 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 } clientTime, err := parseIMTime(body.ClientTime) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "client_time"}) return } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } record, checkin, err := store.Attendance.Checkin(principal.TenantID, principal.Uid, "attc_"+token, types.AttendanceCheckinRequest{ Date: date, Action: strings.TrimSpace(body.Action), RuleID: strings.TrimSpace(body.RuleID), ShiftID: strings.TrimSpace(body.ShiftID), ClientTime: clientTime, Location: body.Location, Wifi: body.Wifi, Device: body.Device, IdempotencyKey: idempotencyKey, }) if err != nil { logs.Warn.Println("im attendance checkin failed:", principal.TenantID, principal.Uid.UserId(), err) writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.record.updated", record.RecordID, []types.Uid{principal.Uid}, "打卡", record.Date.Format("2006-01-02"), map[string]any{"record_id": record.RecordID, "checkin_id": checkin.CheckinID, "status": record.Status}) writeIMSuccess(wrt, req, map[string]any{ "record": attendanceRecordPayload(record), "checkin": attendanceCheckinPayload(*checkin), }) } func serveIMAttendanceAppeals(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) items, nextCursor, err := store.Attendance.ListAppeals(principal.TenantID, principal.Uid, types.AttendanceAppealListOptions{ Scope: req.URL.Query().Get("scope"), Status: strings.TrimSpace(req.URL.Query().Get("status")), Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")), Limit: limit, }) if err != nil { logs.Warn.Println("im attendance appeals failed:", principal.TenantID, principal.Uid.UserId(), err) writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"items": attendanceAppealPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != ""}) case http.MethodPost: idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body imAttendanceAppealRequest if !decodeIMJSONBody(wrt, req, &body) { return } if strings.TrimSpace(body.Reason) == "" || len([]rune(body.Reason)) > 500 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"}) return } var targetTime *time.Time if strings.TrimSpace(body.TargetTime) != "" { parsed, err := parseIMTime(body.TargetTime) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_time"}) return } targetTime = &parsed } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } appeal, err := store.Attendance.CreateAppeal(principal.TenantID, principal.Uid, &types.AttendanceAppeal{ AppealID: "atta_" + token, RecordID: strings.TrimSpace(body.RecordID), UserID: principal.Uid, AppealType: strings.TrimSpace(body.AppealType), Reason: strings.TrimSpace(body.Reason), TargetAction: strings.TrimSpace(body.TargetAction), TargetTime: targetTime, AttachmentFileIDs: body.AttachmentFileIDs, }, idempotencyKey) if err != nil { logs.Warn.Println("im attendance appeal create failed:", principal.TenantID, principal.Uid.UserId(), err) writeIMRelationError(wrt, req, err) return } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.appeal.created", appeal.AppealID, []types.Uid{principal.Uid}, "考勤申诉", appeal.RecordID, map[string]any{"appeal_id": appeal.AppealID, "record_id": appeal.RecordID, "status": appeal.Status}) writeIMSuccess(wrt, req, attendanceAppealPayload(appeal)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { serveIMMeetingList(wrt, req) 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 } idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body struct { Title string `json:"title"` RecorderUserID string `json:"recorder_user_id"` 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 } recorderID := principal.Uid if value := strings.TrimSpace(body.RecorderUserID); value != "" { recorderID = types.ParseUserId(value) if recorderID.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"}) return } } if !ensureIMMeetingUserExists(principal.TenantID, recorderID) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"}) return } if !uidListContains(attendeeIDs, recorderID) && recorderID != principal.Uid { attendeeIDs = append(attendeeIDs, recorderID) } meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{ MeetingID: "mtg_" + meetingID, OrganizerID: principal.Uid, RecorderID: recorderID, 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 } } publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } func serveIMMeetingList(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) meetings, nextCursor, err := store.Meetings.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } items := make([]map[string]any, 0, len(meetings)) userSummaries := meetingUserSummaries(principal.TenantID, meetings) for i := range meetings { items = append(items, meetingPayloadWithUserSummaries(&meetings[i], principal.Uid, userSummaries)) } writeIMSuccess(wrt, req, map[string]any{ "items": items, "next_cursor": nextCursor, "has_more": nextCursor != "", }) } 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 case "notes": serveIMMeetingNotesDetail(wrt, req, meetingID) return } if strings.HasPrefix(tail, "notes/files") { serveIMMeetingNoteFiles(wrt, req, meetingID, tail) return } writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } func serveIMMeetingNotes(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) meetings, nextCursor, err := store.MeetingNotes.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("query")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) if err != nil { writeIMRelationError(wrt, req, err) return } userSummaries := meetingUserSummaries(principal.TenantID, meetings) items := make([]map[string]any, 0, len(meetings)) for i := range meetings { files, err := store.MeetingNotes.ListFiles(principal.TenantID, principal.Uid, meetings[i].MeetingID) if err != nil { writeIMRelationError(wrt, req, err) return } items = append(items, meetingNoteListItemPayload(&meetings[i], files, principal.Uid, userSummaries)) } writeIMSuccess(wrt, req, map[string]any{ "items": items, "next_cursor": nextCursor, "has_more": nextCursor != "", }) } func serveIMMeetingNotesDetail(wrt http.ResponseWriter, req *http.Request, meetingID 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 } meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) if err != nil { writeIMRelationError(wrt, req, err) return } if !meetingNotesReadable(meeting, principal.Uid) { writeIMRelationError(wrt, req, types.ErrPermissionDenied) return } files, err := store.MeetingNotes.ListFiles(principal.TenantID, principal.Uid, meetingID) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, meetingNotesDetailPayload(principal.TenantID, meeting, files, principal.Uid)) } func serveIMMeetingNoteFiles(wrt http.ResponseWriter, req *http.Request, meetingID, tail string) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } switch { case tail == "notes/files" && req.Method == http.MethodPost: idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body struct { FileID string `json:"file_id"` } if !decodeIMJSONBody(wrt, req, &body) { return } if strings.TrimSpace(body.FileID) == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) return } noteFileID := "mnote_" + store.Store.GetUidString() file, err := store.MeetingNotes.CreateFile(principal.TenantID, principal.Uid, meetingID, noteFileID, body.FileID, idempotencyKey) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil)) return case strings.HasPrefix(tail, "notes/files/"): noteFileID := strings.TrimSpace(strings.TrimPrefix(tail, "notes/files/")) if noteFileID == "" || strings.Contains(noteFileID, "/") { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting note file route not found", nil) return } if req.Method == http.MethodPut { idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body struct { FileID string `json:"file_id"` Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } if strings.TrimSpace(body.FileID) == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) return } file, err := store.MeetingNotes.ReplaceFile(principal.TenantID, principal.Uid, meetingID, noteFileID, body.FileID, idempotencyKey, body.Version) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil)) return } if req.Method == http.MethodDelete { version, err := strconv.Atoi(strings.TrimSpace(req.URL.Query().Get("version"))) if err != nil || version <= 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"}) return } file, err := store.MeetingNotes.DeleteFile(principal.TenantID, principal.Uid, meetingID, noteFileID, strings.TrimSpace(req.Header.Get("Idempotency-Key")), version) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil)) 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, meetingPayloadForTenant(principal.TenantID, 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"` RecorderUserID *string `json:"recorder_user_id"` 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.RecorderUserID != nil { recorderID := types.ParseUserId(strings.TrimSpace(*body.RecorderUserID)) if recorderID.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"}) return } if !ensureIMMeetingUserExists(principal.TenantID, recorderID) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"}) return } patch.RecorderID = &recorderID } 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 } } publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, 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 } } publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, 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 } } publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.reminded", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)}) writeIMSuccess(wrt, req, map[string]any{ "meeting_id": meetingID, "reminded": meetingParticipantPayloadsWithSummaries(participants, meetingUserSummaries(principal.TenantID, []types.Meeting{{Participants: 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 } } publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, 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": false, "can_remind_unsubmitted": false, }, }) 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 } subject := strings.TrimSpace(body.Subject) content := strings.TrimSpace(body.Content) if len([]rune(subject)) > 200 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "subject", "max_length": 200}) return } if len([]rune(content)) > 5000 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "content", "max_length": 5000}) 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: subject, Content: 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 } if errors.Is(err, types.ErrPolicy) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", 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 } } if body.AvatarFileID != nil { if avatar := strings.TrimSpace(*body.AvatarFileID); avatar != "" { if err := store.Files.LinkAttachments(principal.TenantID, principal.Uid.UserId(), types.ZeroUid, []string{avatar}); err != nil { writeIMRelationError(wrt, req, err) 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"` GroupInviteVerification *bool `json:"group_invite_verification"` 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, GroupInviteVerification: body.GroupInviteVerification, 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 } platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform"))) channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel"))) currentVersion := strings.TrimSpace(req.URL.Query().Get("current_version")) if channel == "" { channel = "stable" } if platform != "ios" && platform != "android" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"}) return } currentParts, err := parseNumericAppVersion(currentVersion) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "current_version"}) return } release, err := store.AppReleases.Latest(platform, channel) if err != nil { writeIMRelationError(wrt, req, err) return } latestParts, latestErr := parseNumericAppVersion(release.LatestVersion) minimumParts, minimumErr := parseNumericAppVersion(release.MinSupportedVersion) if latestErr != nil || minimumErr != nil { writeIMError(wrt, req, http.StatusInternalServerError, 50000, imReasonInternal, "invalid app release configuration", nil) return } hasUpdate := compareNumericAppVersion(currentParts, latestParts) < 0 updateType := "none" if hasUpdate { updateType = strings.ToLower(strings.TrimSpace(release.UpdateType)) if compareNumericAppVersion(currentParts, minimumParts) < 0 { updateType = "force" } if updateType != "force" && updateType != "recommended" { updateType = "none" } } writeIMSuccess(wrt, req, appReleasePayload(release, currentVersion, hasUpdate, updateType)) } 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), "enable_youth_mode", "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, SDKVersion: body.SDKVersion, PushEnv: body.PushEnv, NotificationPermission: body.NotificationPermission, }) 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 { itemPayload, ok := favoritePayloadForPrincipal(principal, item) if ok { payload = append(payload, itemPayload) } } 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 { sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic) seq := int64(src.Seq) if !sourceOK || seq <= 0 { failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": imReasonMalformedRequest}) continue } if err := requireTopicReader(principal, sourceTopic); err != nil { failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)}) continue } msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, seq) if err != nil { failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)}) continue } idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, seq) fav := &types.FavoriteItem{ TenantID: principal.TenantID, FavoriteID: "fav_" + store.Store.GetUidString(), UserID: principal.Uid, ItemType: imMessageType(msg), SourceTopic: sourceTopic, SourceSeq: seq, Snapshot: favoriteSnapshot(msg), AttachmentRefs: favoriteAttachmentRefs(msg), Availability: messageAvailability(*msg), IdempotencyKey: idempotencyKey, 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": 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 } payload, ok := favoritePayloadForPrincipal(principal, *item) if !ok { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) return } writeIMSuccess(wrt, req, payload) 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 { rawTarget := strings.TrimSpace(target) target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget) if target == "" || !targetOK { results = append(results, map[string]any{"target_topic": rawTarget, "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 } var commentResult map[string]any commentSaved := false ensureComment := func() map[string]any { if commentSaved || comment == "" { return commentResult } commentSaved = true commentResult = saveForwardCommentMessage(req.Context(), principal, target, comment, forwardClientMid(req.Header.Get("Idempotency-Key"), "favorite-comment", target)) return commentResult } 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 } head, content := favoriteForwardMessageHeadContent(item) msg := &types.Message{ TenantID: principal.TenantID, Topic: target, From: principal.Uid.String(), Head: head, Content: content, } if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil { results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) continue } commentResult = ensureComment() saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true) if err != nil { if saved != nil && saved.Pending { result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"} mergeForwardCommentResult(result, commentResult) results = append(results, result) continue } results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) continue } if saved != nil && saved.Pending { result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"} mergeForwardCommentResult(result, commentResult) results = append(results, result) continue } result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId} mergeForwardCommentResult(result, commentResult) results = append(results, result) } } 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" && mode != "bundle" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", 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, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget) if target == "" { for _, source := range body.Sources { failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), strings.TrimSpace(rawTarget), imReasonMalformedRequest)) } continue } if !targetOK { for _, source := range body.Sources { failed = append(failed, forwardFailurePayload(source.Topic, int64(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, int64(source.Seq), target, relationErrorReason(targetErr))) } continue } comment := strings.TrimSpace(body.Comment) var commentResult map[string]any commentSaved := false ensureComment := func() map[string]any { if commentSaved || comment == "" { return commentResult } commentSaved = true commentResult = saveForwardCommentMessage(req.Context(), principal, target, comment, forwardClientMid(req.Header.Get("Idempotency-Key"), "message-comment", target)) return commentResult } if mode == "bundle" { bundle, sourceFailures := buildForwardBundle(principal, body.Sources, target, sourceReadable, "") if len(sourceFailures) > 0 { failed = append(failed, sourceFailures...) continue } clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "bundle", target, len(body.Sources), body.Comment) forwarded := &types.Message{ TenantID: principal.TenantID, Topic: target, From: principal.Uid.String(), Head: forwardBundleMessageHead(clientMid), Content: bundle, } if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil { failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err))) continue } commentResult = ensureComment() saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true) if err != nil { if saved != nil && saved.Pending { forwarded.SeqId = saved.SeqId item := map[string]any{ "sources": bundle["source_refs"], "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "pending", "reason": "MESSAGE_PERSIST_PENDING", "mode": "bundle", "bundle_id": bundle["bundle_id"], } mergeForwardCommentResult(item, commentResult) items = append(items, item) continue } failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err))) continue } if saved != nil && saved.Pending { item := map[string]any{ "sources": bundle["source_refs"], "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "pending", "reason": "MESSAGE_PERSIST_PENDING", "mode": "bundle", "bundle_id": bundle["bundle_id"], } mergeForwardCommentResult(item, commentResult) items = append(items, item) continue } forwarded.SeqId = saved.SeqId broadcastHTTPForwardMessage(principal.TenantID, forwarded) item := map[string]any{ "sources": bundle["source_refs"], "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "sent", "mode": "bundle", "bundle_id": bundle["bundle_id"], } mergeForwardCommentResult(item, commentResult) items = append(items, item) continue } for _, source := range body.Sources { sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic) if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK { failed = append(failed, forwardFailurePayload(sourceTopic, int64(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, int64(source.Seq), target, relationErrorReason(readErr))) continue } msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, int64(source.Seq)) if err != nil { failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err))) continue } if msg == nil { failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "NOT_FOUND")) continue } if messageAvailability(*msg) != "available" { failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "MESSAGE_UNAVAILABLE")) continue } clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "separate", target, sourceTopic, int64(source.Seq)) forwarded := &types.Message{ TenantID: principal.TenantID, Topic: target, From: principal.Uid.String(), Head: forwardedMessageHead(msg, clientMid), Content: forwardedMessageContent(msg, ""), } if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil { failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err))) continue } commentResult = ensureComment() saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true) if err != nil { if saved != nil && saved.Pending { forwarded.SeqId = saved.SeqId item := map[string]any{ "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "pending", "reason": "MESSAGE_PERSIST_PENDING", } mergeForwardCommentResult(item, commentResult) items = append(items, item) continue } failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err))) continue } if saved != nil && saved.Pending { item := map[string]any{ "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "pending", "reason": "MESSAGE_PERSIST_PENDING", } mergeForwardCommentResult(item, commentResult) items = append(items, item) continue } forwarded.SeqId = saved.SeqId broadcastHTTPForwardMessage(principal.TenantID, forwarded) item := map[string]any{ "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "sent", } mergeForwardCommentResult(item, commentResult) items = append(items, item) } } 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, "/message-mutations"): serveIMMessageMutations(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, "/messages/recall"): serveIMMessageBatchRecall(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 serveIMMessageMutations(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, topicFromMessageMutationsPath(req.URL.Path)) if !ok { return } sinceVersion, err := parseIMMutationSince(req.URL.Query().Get("since_version"), req.URL.Query().Get("since")) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "since_version"}) return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) mutations, nextCursor, hasMore, mutationVersion, err := store.Messages.Mutations(principal.TenantID, topic, principal.Uid, sinceVersion, limit) if err != nil { writeIMRelationError(wrt, req, err) return } items := make([]map[string]any, 0, len(mutations)) for _, mutation := range mutations { items = append(items, messageMutationEventPayload(mutation)) } writeIMSuccess(wrt, req, map[string]any{ "topic": topic, "items": items, "next_cursor": nextCursor, "has_more": hasMore, "mutation_version": mutationVersion, "expired": false, }) } 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, normalizedTopic, 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, normalizedTopic, 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": normalizedTopic, "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 } topic = normalizeConversationSettingsTopic(topic, principal.Uid) var body imMessageRecallRequest if !decodeIMJSONBody(wrt, req, &body) { 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 } version := body.Version if version <= 0 { version = msg.Version } recalledAt := time.Now().UTC().Round(time.Millisecond) updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, 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 serveIMMessageBatchRecall(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, ok := topicMessagesActionPath(req.URL.Path, "recall") if !ok { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } topic = normalizeConversationSettingsTopic(topic, principal.Uid) var body imMessageBatchRecallRequest if !decodeIMJSONBody(wrt, req, &body) { return } if len(body.Items) == 0 || len(body.Items) > 50 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "items must contain 1-50 messages", nil) return } items := make([]map[string]any, 0, len(body.Items)) recalledAt := time.Now().UTC().Round(time.Millisecond) for _, item := range body.Items { if item.Seq <= 0 { items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request")) continue } msg, authorized, err := authorizeIMMessageMutation(principal, topic, item.Seq, true) if err != nil { items = append(items, messageMutationErrorItem(item.Seq, err)) continue } if !authorized { items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required")) continue } if time.Since(msg.CreatedAt) > 120*time.Second { items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired")) continue } version := item.Version if version <= 0 { version = msg.Version } updated, err := store.Messages.Recall(principal.TenantID, topic, item.Seq, principal.Uid, version, recalledAt) if err != nil { items = append(items, messageMutationErrorItem(item.Seq, err)) continue } result := messageMutationPayload(updated, "recalled") if imReliableHTTPEventsEnabled(req) { eventID := stableIMEventID("message", topic, item.Seq, updated.Version, "recalled") if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid, "message.recalled", map[string]any{"topic": topic, "seq": item.Seq, "version": updated.Version}); err != nil { result["event_status"] = "enqueue_failed" } } items = append(items, result) } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items}) } 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 } topic = normalizeConversationSettingsTopic(topic, principal.Uid) 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" { serveIMConversationMessages(wrt, req, principal, topic) 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, err := resolveConversationContextTopic(principal, topic) if err != nil { writeIMRelationError(wrt, req, err) return } 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 serveIMConversationMessages(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: writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false}) case http.MethodDelete: 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).IsReader() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonForbidden, "permission denied", nil) return } top, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMRelationError(wrt, req, err) return } if top == nil { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "conversation not found", nil) return } delID := top.DelId + 1 if delID <= 0 { delID = int(time.Now().Unix()) } var ranges []types.Range if top.SeqId > 0 { ranges = []types.Range{{Low: 1, Hi: top.SeqId + 1}} } if err = store.Messages.DeleteList(principal.TenantID, topic, delID, principal.Uid, 0, ranges); err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "cleared": true, "deleted_id": delID}) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } 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 } relation, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, target, principal.Uid.P2PName(target)) if err != nil { writeIMRelationError(wrt, req, err) return } if relation == nil || relation.Relationship != types.RelationshipNone { reason := "FRIEND_REQUEST_CONFLICT" if relation != nil && (relation.Relationship == types.RelationshipBlockedByMe || relation.Relationship == types.RelationshipBlockedByPeer) { reason = imReasonPermissionDenied } writeIMError(wrt, req, http.StatusConflict, 30006, reason, "friend request is not allowed for current relationship", nil) return } source := strings.TrimSpace(body.Source) sourceTopic := strings.TrimSpace(body.SourceTopic) switch source { case "search", "qr": if sourceTopic != "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "source_topic is only valid for group source", nil) return } case "group": if types.GetTopicCat(sourceTopic) != types.TopicCatGrp { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "valid group source_topic is required", nil) return } requesterSub, requesterErr := store.Subs.Get(principal.TenantID, sourceTopic, principal.Uid, false) targetSub, targetErr := store.Subs.Get(principal.TenantID, sourceTopic, target, false) if requesterErr != nil || targetErr != nil { if requesterErr != nil { writeIMRelationError(wrt, req, requesterErr) } else { writeIMRelationError(wrt, req, targetErr) } return } if requesterSub == nil || targetSub == nil || !(requesterSub.ModeGiven & requesterSub.ModeWant).IsJoiner() || !(targetSub.ModeGiven & targetSub.ModeWant).IsJoiner() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "group membership required", nil) return } settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, sourceTopic) if settingsErr != nil { writeIMRelationError(wrt, req, settingsErr) return } if settings == nil || !settings.MemberPrivateChat { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member private chat is disabled", nil) return } default: writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil) return } privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target) 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: source, SourceTopic: 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 { logs.Warn.Printf("im friend request create: relation event failed request_id=%s friend_request_id=%s target=%s: %v", req.Header.Get("X-Request-ID"), created.RequestID, target.UserId(), err) } } 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 { if item.Friendship.PeerID == principal.Uid { continue } payload = append(payload, friendListItemPayload(principal.TenantID, principal.Uid, 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 settings, err := store.GroupBusiness.GetSettings(principal.TenantID, sub.Topic); err == nil && settings != nil { if settings.GroupStatus == types.GroupStatusDissolved { // Defensive check: hide dissolved groups even if their subscription cleanup lagged. continue } item = mergeGroupListSettingsPayload(item, settings) } 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, "fn": 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, } if settings.AvatarFileID != "" { if err := linkIMGroupAvatarAttachment(principal.TenantID, topicName, settings.AvatarFileID); err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"}) return } } 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 } publishIMGroupRealtimeEvent(principal.TenantID, topicName, principal.Uid, "group.created", map[string]any{"topic": topicName, "member_user_ids": userIDs(memberIDs)}) 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" || tail == "members/me/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" || tail == "mutes/batch" || 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 := strings.TrimSpace(req.URL.Query().Get("cursor")) if cursor != "" { if _, err := strconv.ParseInt(cursor, 10, 64); err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) return } } if parsed, err := parseIMNonNegativeCursor(cursor); err != nil || parsed < 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) return } query := strings.TrimSpace(req.URL.Query().Get("query")) mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable")) roleFilter := strings.TrimSpace(req.URL.Query().Get("role")) eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role")) muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible")) members, nextCursor, err := store.GroupBusiness.ListMembers(principal.TenantID, topic, types.GroupMemberListOptions{ Role: roleFilter, EligibleRole: eligibleRole, Query: query, Cursor: cursor, Limit: limit, Mentionable: mentionableOnly, }) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } remarks := groupMemberRemarkMap(principal.TenantID, principal.Uid, members) items := make([]map[string]any, 0, limit) for _, member := range members { access := groupMemberAccess(member) item := groupBusinessMemberPayload(member, remarks[member.UserID]) 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 } } items = append(items, item) } permissions := map[string]any{ "can_mention_all": selfAccess.IsAdmin(), } payload := map[string]any{ "topic": topic, "items": items, "next_cursor": nextCursor, "permissions": permissions, } 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 { if settings != nil && settings.GroupStatus == types.GroupStatusDissolved { writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic}) return } 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 settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil && settings != nil && settings.Version > 0 { currentVersion = settings.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 public["fn"] = name } if body.AvatarFileID != nil { avatar := strings.TrimSpace(*body.AvatarFileID) if avatar != "" { if err := linkIMGroupAvatarAttachment(principal.TenantID, topic, avatar); err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"}) return } } public["avatar_file_id"] = avatar } 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} settingsUpdated := true if _, err = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch); err != nil { if errors.Is(err, types.ErrNotFound) { settingsUpdated = false logs.Warn.Printf("group[%s]: business settings missing while patching group profile; kept topic public update", topic) } else { 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 } } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.settings.updated", map[string]any{"topic": topic}) 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 settingsUpdated { if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { payload = mergeGroupSettingsPayload(payload, settings) } } writeIMSuccess(wrt, req, payload) case http.MethodDelete: if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } if !consumeIMActionTokenHeader(wrt, req, principal, "group_dissolve") { return } var body imGroupDissolveRequest if req.Body != nil && req.Body != http.NoBody { if !decodeIMJSONBody(wrt, req, &body) { return } } settings, err := store.GroupBusiness.Dissolve(principal.TenantID, topic, principal.Uid, strings.TrimSpace(body.Reason), time.Now().UTC().Round(time.Millisecond)) if err != nil { writeIMRelationError(wrt, req, err) return } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.dissolved", map[string]any{"topic": topic}) payload := map[string]any{"topic": topic, "deleted": true} if settings != nil { payload["status"] = settings.GroupStatus payload["version"] = settings.Version } 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) currentVersion := intFromAny(private["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.Pinned != nil { private["pinned"] = *body.Pinned } if body.MuteNotifications != nil { private["mute_notifications"] = *body.MuteNotifications } private["version"] = currentVersion + 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 } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated", map[string]any{"user_id": principal.Uid.UserId()}) selfSub.Private = private writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub)) } func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodPut && 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 } private := groupPrivateMap(selfSub.Private) currentVersion := intFromAny(private["profile_version"]) if currentVersion <= 0 { currentVersion = 1 } if req.Method == http.MethodGet { writeIMSuccess(wrt, req, map[string]any{ "nickname": stringFromAny(private["nickname"]), "version": currentVersion, "updated_at": stringFromAny(private["profile_updated_at"]), }) 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 } 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}); err != nil { if errors.Is(err, types.ErrNotFound) { logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId()) publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated", map[string]any{"user_id": principal.Uid.UserId()}) writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)}) return } 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 } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated", map[string]any{"user_id": principal.Uid.UserId()}) 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 } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.left", map[string]any{"user_id": principal.Uid.UserId()}) 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 settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic) if err != nil { writeIMRelationError(wrt, req, err) return } if !access.IsAdmin() { if settings == nil || !settings.MemberInvite { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member invitation is disabled", nil) return } } 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)) now := time.Now().UTC().Round(time.Millisecond) 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() targetSub, err := store.Subs.Get(principal.TenantID, topic, uid, false) if err != nil && !errors.Is(err, types.ErrNotFound) { item["status"] = "failed" item["reason"] = relationErrorReason(err) results = append(results, item) continue } if targetSub != nil && targetSub.DeletedAt == nil { item["status"] = "already_member" results = append(results, item) continue } targetRequiresApproval := settings != nil && settings.JoinApproval if !targetRequiresApproval && store.Profiles != nil { privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, uid) if privacyErr != nil { if errors.Is(privacyErr, types.ErrNotFound) || errors.Is(privacyErr, types.ErrUnsupported) { privacyErr = nil } } if privacyErr != nil { item["status"] = "failed" item["reason"] = relationErrorReason(privacyErr) results = append(results, item) continue } targetRequiresApproval = privacy != nil && privacy.GroupInviteVerification } joinRequestID := "" invitationID := "" if targetRequiresApproval { invitationID = "ginv_" + store.Store.GetUidString() joinRequestID = "gjr_" + store.Store.GetUidString() } if !targetRequiresApproval { 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"] = types.GroupInvitationStatusJoined if imReliableHTTPEventsEnabled(req) { publishIMGroupMemberJoinedSystemNotice(principal.TenantID, topic, principal.Uid, uid, "有成员通过邀请加入了群聊。", map[string]any{"source": "direct_invitation"}) } } else { item["status"] = "already_member" } results = append(results, item) continue } idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) if idempotencyKey != "" { idempotencyKey += ":" + uid.UserId() } else { idempotencyKey = invitationID } inv, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{ InvitationID: invitationID, Topic: topic, InviterUserID: principal.Uid, TargetUserID: uid, Message: strings.TrimSpace(body.Message), Status: types.GroupInvitationStatusPending, Result: "", JoinRequestID: joinRequestID, IdempotencyKey: idempotencyKey, ExpiresAt: now.Add(7 * 24 * time.Hour), CreatedAt: now, UpdatedAt: now, }) if err != nil { item["status"] = "failed" item["reason"] = relationErrorReason(err) results = append(results, item) continue } item["invitation_id"] = inv.InvitationID item["status"] = groupInvitationDisplayStatus(inv) item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano) if inv.JoinRequestID != "" { item["join_request_id"] = inv.JoinRequestID } publishIMGroupInvitationNotification(principal.TenantID, inv) results = append(results, item) } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.invitations.updated", map[string]any{"topic": topic, "items": results}) 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: if !access.IsAdmin() { settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic) if err != nil { writeIMRelationError(wrt, req, err) return } if settings == nil || !settings.MemberUploadFiles { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member file upload is disabled", nil) return } } idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) if !ok { return } var body imGroupFileCreateRequest if !decodeIMJSONBody(wrt, req, &body) { return } fileID := normalizeIMUploadedFileID(body.FileID) if fileID == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) return } fileName := strings.TrimSpace(body.FileName) mimeType := strings.TrimSpace(body.MIMEType) fileType := normalizeIMFileType(body.FileType) if fileName == "" || len([]rune(fileName)) > 255 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_name", "max_length": 255}) return } if len([]rune(mimeType)) > 128 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "mime_type", "max_length": 128}) return } if body.FileSize < 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_size"}) return } uploaded, err := store.Files.Get(principal.TenantID, fileID) if err != nil { writeIMRelationError(wrt, req, err) return } if uploaded == nil || uploaded.Status != types.UploadCompleted { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonFileUnavailable, "file unavailable", map[string]any{"field": "file_id"}) return } if body.FileSize > 0 && uploaded.Size > 0 && body.FileSize != uploaded.Size { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_size"}) return } if globals.maxFileUploadSize > 0 && uploaded.Size > globals.maxFileUploadSize { writeIMError(wrt, req, http.StatusBadRequest, 30046, "FILE_TOO_LARGE", "file too large", map[string]any{"field": "file_size", "max_size": globals.maxFileUploadSize}) return } if mimeType == "" { mimeType = uploaded.MimeType } fileSize := body.FileSize if fileSize == 0 { fileSize = uploaded.Size } 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: fileName, MIMEType: mimeType, FileType: fileType, FileSize: 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 } if imReliableHTTPEventsEnabled(req) { publishIMConversationSystemNotice(principal.TenantID, inv.Topic, "system_action", "group_member_joined", "有成员通过邀请加入了群聊。", []imSystemMessageActor{ {UserID: inv.InviterUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.InviterUserID.UserId()}}, {UserID: inv.TargetUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.TargetUserID.UserId()}}, }, []imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: inv.Topic}}, map[string]any{"invitation_id": inv.InvitationID, "source": "invitation"}) } publishIMGroupRealtimeEvent(principal.TenantID, inv.Topic, principal.Uid, "group.member.joined", map[string]any{"user_id": principal.Uid.UserId(), "invitation_id": inv.InvitationID}) 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 } if !settings.JoinApproval { now := time.Now().UTC().Round(time.Millisecond) joined, joinErr := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, principal.Uid, "", strings.TrimSpace(req.Header.Get("Idempotency-Key")), groupMemberPrivate("", false, false, 1, now), now) if joinErr != nil { writeIMRelationError(wrt, req, joinErr) return } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.joined", map[string]any{"user_id": principal.Uid.UserId(), "joined": joined, "source": "open_group"}) if joined && imReliableHTTPEventsEnabled(req) { publishIMGroupMemberJoinedSystemNotice(principal.TenantID, topic, principal.Uid, principal.Uid, "有成员加入了群聊。", map[string]any{"source": "open_group"}) } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "joined", "joined": joined, "source": "open_group"}) return } sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) 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 } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.join_request.created", map[string]any{"request_id": item.RequestID, "user_id": principal.Uid.UserId()}) 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 } items = dedupePendingGroupJoinRequests(items, status) payloads, err := groupJoinRequestPayloadsWithUsers(principal.TenantID, items) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": payloads, "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 } if updated.Status == types.GroupJoinRequestStatusAccepted && imReliableHTTPEventsEnabled(req) { publishIMConversationSystemNotice(principal.TenantID, topic, "system_action", "group_member_joined", "有成员通过审批加入了群聊。", []imSystemMessageActor{ {UserID: updated.ApplicantUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantUserID.UserId()}}, {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, }, []imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}}, map[string]any{"request_id": updated.RequestID, "source": "approval"}) } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.join_request."+updated.Status, map[string]any{"request_id": updated.RequestID, "user_id": updated.ApplicantUserID.UserId(), "status": updated.Status}) 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, 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 } // 已解散的群按文档约定对客户端表现为不存在(RESOURCE_NOT_FOUND),不暴露解散状态。 if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil && settings != nil && settings.GroupStatus == types.GroupStatusDissolved { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "group not found", 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 } access := types.ModeNone if selfSub != nil { access = selfSub.ModeGiven & selfSub.ModeWant } if selfSub == nil || !access.IsJoiner() || !access.IsReader() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil) 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(access), }) } 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, groupAnnouncementPayloadWithPermissions(topic, ann, selfSub)) 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 } if err = publishGroupAnnouncementTextMessage(req.Context(), principal.TenantID, topic, principal.Uid, announcement); err != nil { logs.Warn.Println("im group announcement: failed to publish text message", principal.TenantID, topic, announcement.AnnouncementID, err) writeIMRelationError(wrt, req, err) return } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.announcement.updated", map[string]any{"announcement_id": announcement.AnnouncementID, "topic": topic}) writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, announcement, selfSub)) 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: announcementID := strings.TrimSpace(strings.TrimPrefix(tail, "announcements/")) if announcementID == "" || strings.Contains(announcementID, "/") { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "announcement_id"}) return } ann, err := store.GroupBusiness.GetAnnouncement(principal.TenantID, topic, announcementID) if err != nil { writeIMRelationError(wrt, req, err) return } writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, ann, selfSub)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } func publishGroupAnnouncementTextMessage(ctx context.Context, tenantID types.TenantID, topic string, author types.Uid, ann *types.GroupAnnouncement) error { if ann == nil || strings.TrimSpace(topic) == "" || author.IsZero() { return types.ErrMalformed } content := strings.TrimSpace(ann.Content) if content == "" { return types.ErrMalformed } if store.Messages == nil || store.Topics == nil { return types.ErrMalformed } topicRecord, err := store.Topics.Get(tenantID, topic) if err != nil { return err } if topicRecord == nil { return types.ErrNotFound } now := ann.PublishedAt if now.IsZero() { now = time.Now().UTC().Round(time.Millisecond) } msg := &types.Message{ ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now}, TenantID: tenantID, SeqId: topicRecord.SeqId + 1, Topic: topic, From: author.String(), Head: types.KVMap{ "mime": "application/json", "x-im-schema": 1, "x-im-type": "text", "x-im-client-mid": "group_announcement_" + strings.TrimSpace(ann.AnnouncementID), }, Content: map[string]any{"text": "群公告\n" + content}, } saved, err := saveMessageMaybeKafka(ctx, tenantID, msg, nil, true) if err != nil { return err } if saved != nil && saved.SeqId > 0 { msg.SeqId = saved.SeqId } broadcastHTTPForwardMessage(tenantID, msg) return 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 } if body.JoinApproval != nil && imReliableHTTPEventsEnabled(req) { template := "group_invite_confirm_disabled" text := "群聊邀请确认已关闭,成员邀请好友可直接进群。" if *body.JoinApproval { template = "group_invite_confirm_enabled" text = "群聊邀请确认已启用,群成员邀请好友进群需要群主或管理员确认。" } publishIMConversationSystemNotice(principal.TenantID, topic, "system_action", template, text, []imSystemMessageActor{ {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, }, []imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic, MinRole: "admin"}}, map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version}) } if body.MemberPrivateChat != nil && imReliableHTTPEventsEnabled(req) { template := "group_member_private_chat_disabled" text := "群成员私信权限已关闭,群成员头像和名片不可点击查看。" if *body.MemberPrivateChat { template = "group_member_private_chat_enabled" text = "群成员私信权限已开启,群成员头像和名片可点击查看。" } publishIMConversationSystemNotice(principal.TenantID, topic, "system_notice", template, text, []imSystemMessageActor{ {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, }, nil, map[string]any{"member_private_chat": *body.MemberPrivateChat, "version": settings.Version}) } if body.MuteAll != nil && imReliableHTTPEventsEnabled(req) { template := "group_mute_all_disabled" text := "全体禁言已关闭,普通成员将能发送任何消息。" if *body.MuteAll { template = "group_mute_all_enabled" text = "全体禁言已开启,普通成员将不能发送任何消息。" } publishIMConversationSystemNotice(principal.TenantID, topic, "system_notice", template, text, []imSystemMessageActor{ {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, }, nil, map[string]any{"mute_all": *body.MuteAll, "version": settings.Version}) } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.settings.updated", map[string]any{"topic": topic, "version": settings.Version}) 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.ModeCPublic|types.ModeApprove, 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 } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.role.updated", map[string]any{"topic": topic, "results": results}) 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 } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.role.updated", map[string]any{"user_id": uid.UserId(), "role": "member"}) 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 } if !consumeIMActionTokenHeader(wrt, req, principal, "group_owner_transfer") { 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 { if errors.Is(err, types.ErrInvalidResponse) { currentVersion := 0 if settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, topic); settingsErr == nil && settings != nil { currentVersion = settings.Version } writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion}) return } writeIMRelationError(wrt, req, err) return } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.owner.transferred", map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId()}) 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 } isAdmin := (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() switch { case tail == "mutes" && req.Method == http.MethodGet: var items []types.GroupMute nextCursor := "" if isAdmin { var err error 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 } } else { item, err := store.GroupBusiness.GetMute(principal.TenantID, topic, principal.Uid) if err != nil && !errors.Is(err, types.ErrNotFound) { writeIMRelationError(wrt, req, err) return } if item != nil { items = append(items, *item) } } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor}) case (tail == "mutes" || tail == "mutes/batch") && req.Method == http.MethodPost: if !isAdmin { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } var body imGroupMuteBatchRequest if !decodeIMJSONBody(wrt, req, &body) { return } if len(body.Members) == 0 || len(body.Members) > 100 { for _, userID := range cleanStringList(body.UserIDs, 100) { body.Members = append(body.Members, struct { UserID string `json:"user_id"` ExpiresAt string `json:"expires_at,omitempty"` }{UserID: userID}) } 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) } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.mute.updated", map[string]any{"topic": topic, "items": groupMutePayloads(mutes)}) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)}) case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete: if !isAdmin { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return } 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 } publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.mute.updated", map[string]any{"user_id": uid.UserId(), "muted": false}) 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 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{ "field": "topic", "expected": "group topic, e.g. grp...", "actual": topic, }) return } if memberID.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) 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 { logs.Warn.Printf("im user profile: load user failed request_id=%s viewer=%s target=%s: %v", req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err) 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 { logs.Warn.Printf("im user profile: relationship context failed request_id=%s viewer=%s target=%s: %v", req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err) writeIMRelationError(wrt, req, err) return } privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID) if err != nil { logs.Warn.Printf("im user profile: privacy load failed request_id=%s viewer=%s target=%s: %v", req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err) writeIMRelationError(wrt, req, err) return } if !imPrivacyAllowsProfile(privacy, ctx.Relationship) { if ctx.Relationship == types.RelationshipBlockedByMe { writeIMSuccess(wrt, req, blockedUserProfilePayload(user, ctx)) return } 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 status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) { publishIMConversationSystemNotice(principal.TenantID, updated.ApplicantID.P2PName(updated.TargetID), "system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。", []imSystemMessageActor{ {UserID: updated.ApplicantID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantID.UserId()}}, {UserID: updated.TargetID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.TargetID.UserId()}}, }, nil, map[string]any{"request_id": updated.RequestID}) } 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 } enableToken, enableExpiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, "enable_youth_mode", purpose) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{ "status": "ok", "password_set": true, "purpose": purpose, "enable_action_token": enableToken, "enable_action_expires_at": enableExpiresAt.Format(time.RFC3339Nano), "next_action": "enable_youth_mode", }) } 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 { logs.Warn.Printf("im http bearer auth failed request_id=%s method=%s path=%s err=%v", req.Header.Get("X-Request-ID"), req.Method, req.URL.Path, err) 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) { principal, _, _, err := resolveIMHTTPBearerToken(req) return principal, err } func resolveIMHTTPBearerToken(req *http.Request) (*imPrincipal, []byte, time.Duration, error) { authz := strings.TrimSpace(req.Header.Get("Authorization")) token, ok := strings.CutPrefix(authz, "Bearer ") if !ok || strings.TrimSpace(token) == "" || store.Store == nil { return nil, nil, 0, types.ErrPermissionDenied } handler := store.Store.GetLogicalAuthHandler("token") if handler == nil || !handler.IsInitialized() { return nil, nil, 0, 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, nil, 0, types.ErrPermissionDenied } return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, secret, time.Duration(rec.Lifetime), nil } func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30000, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, token, ttl, err := resolveIMHTTPBearerToken(req) if err != nil { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) return } if ttl < time.Second { ttl = time.Second } enabled, err := store.RevokeLoginToken(principal.TenantID, token, ttl) if err != nil || !enabled { logs.Warn.Println("im logout: token revocation failed", principal.TenantID, principal.Uid, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) } 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 imSensitiveChallengeRateLimitEnabled { 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 } 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 } } 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", "enable_youth_mode", "close_youth_mode", "reset_independent_password": return "youth_mode" case "group_owner_transfer", "group_dissolve": return action default: return fallback } } func consumeIMActionTokenHeader(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action string) bool { 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 false } if store.Security == nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return false } if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, action, defaultIMActionTarget(action, action), time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) return false } return true } 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": groupInvitationDisplayStatus(inv), "result": inv.Result, "expires_at": inv.ExpiresAt.Format(time.RFC3339Nano), } if inv.JoinRequestID != "" { data["join_request_id"] = inv.JoinRequestID } if inv.HandledAt != nil { data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano) } return data } func groupInvitationDisplayStatus(inv *types.GroupInvitation) string { if inv == nil { return "" } if inv.Status == types.GroupInvitationStatusPending && strings.TrimSpace(inv.JoinRequestID) != "" { return types.GroupInvitationStatusPendingApproval } return inv.Status } 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 normalizeIMUploadedFileID(raw string) string { value := strings.TrimSpace(raw) if value == "" { return "" } if cut, _, ok := strings.Cut(value, "?"); ok { value = cut } if cut, _, ok := strings.Cut(value, "#"); ok { value = cut } if strings.Contains(value, "/") && store.Store != nil && store.Store.GetMediaHandler() != nil { if fid := store.Store.GetMediaHandler().GetIdFromUrl(value); !fid.IsZero() { return fid.String() } } if fid := types.ParseUid(value); !fid.IsZero() { return fid.String() } if dot := strings.IndexByte(value, '.'); dot > 0 { if fid := types.ParseUid(value[:dot]); !fid.IsZero() { return fid.String() } } return "" } func linkIMGroupAvatarAttachment(tenantID types.TenantID, topic, avatarFileID string) error { fileID := normalizeIMUploadedFileID(avatarFileID) if fileID == "" { return types.ErrMalformed } return store.Files.LinkAttachments(tenantID, topic, types.ZeroUid, []string{fileID}) } 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 parseIMOptionalDate(raw string, fallback time.Time) (time.Time, error) { if strings.TrimSpace(raw) == "" { return time.Date(fallback.UTC().Year(), fallback.UTC().Month(), fallback.UTC().Day(), 0, 0, 0, 0, time.UTC), nil } return parseIMDate(raw) } func validIMOptionalDateQuery(wrt http.ResponseWriter, req *http.Request, field string) bool { value := strings.TrimSpace(req.URL.Query().Get(field)) if value == "" { return true } if _, err := parseIMDate(value); err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) return false } return true } 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 uidListContains(uids []types.Uid, uid types.Uid) bool { for _, item := range uids { if item == uid { return true } } return false } func ensureIMMeetingUserExists(tenantID types.TenantID, uid types.Uid) bool { if uid.IsZero() || store.Users == nil { return !uid.IsZero() } user, err := store.Users.Get(tenantID, uid) if err != nil { return errors.Is(err, types.ErrUnsupported) } return user != nil } 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 mergeGroupListSettingsPayload(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["member_count"] = settings.MemberCount payload["updated_at"] = settings.UpdatedAt.UTC().Format(time.RFC3339Nano) return payload } 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, remark string) 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, remark) copyUserNickname(data, sub.GetPublic()) 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 groupBusinessMemberPayload(member types.GroupMember, remark string) map[string]any { access := groupMemberAccess(member) user := &types.User{ObjHeader: types.ObjHeader{Id: member.UserID.String()}, Public: member.Public, Trusted: member.Trusted} data := userSummaryPayload(user, remark) copyUserNickname(data, member.Public) if nickname := strings.TrimSpace(member.Nickname); nickname != "" { data["group_nickname"] = nickname } data["role"] = groupMemberRole(access) data["mentionable"] = access.IsJoiner() && access.IsReader() data["acs"] = map[string]any{ "mode": access.String(), "want": member.ModeWant.String(), "given": member.ModeGiven.String(), } return data } func copyUserNickname(data map[string]any, public any) { var fields map[string]any if value, ok := public.(map[string]any); ok { fields = value } else if value, ok := public.(types.KVMap); ok { fields = map[string]any(value) } if fields == nil { return } if nickname := strings.TrimSpace(fmt.Sprint(fields["nickname"])); nickname != "" { data["nickname"] = nickname } } func groupMemberRemarkMap(tenantID types.TenantID, viewer types.Uid, members []types.GroupMember) map[types.Uid]string { remarks := make(map[types.Uid]string) for _, member := range members { if member.UserID.IsZero() || member.UserID == viewer { continue } ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, member.UserID, viewer.P2PName(member.UserID)) if err != nil { logs.Warn.Printf("group member[%s:%s]: failed to resolve friend remark for viewer %s: %v", member.Topic, member.UserID.UserId(), viewer.UserId(), err) continue } if ctx != nil && ctx.Remark != "" { remarks[member.UserID] = ctx.Remark } } return remarks } func groupMemberAccess(member types.GroupMember) types.AccessMode { access := member.ModeGiven & member.ModeWant if access != types.ModeNone { return access } switch member.Role { case types.GroupMemberRoleOwner: return types.ModeCFull case types.GroupMemberRoleAdmin: return types.ModeCPublic | types.ModeApprove default: return types.ModeCPublic } } func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any { data := groupMemberPayload(*sub, true, ctx.Remark) data["topic"] = topic data["relationship"] = ctx.Relationship data["capabilities"] = map[string]any{ "can_send": ctx.CanSend, "can_call": imCanCall(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, ctx.Remark) { 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["status"] = settings.GroupStatus 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 dedupePendingGroupJoinRequests(items []types.GroupJoinRequest, status string) []types.GroupJoinRequest { if len(items) < 2 || status != types.GroupJoinRequestStatusPending { return items } seen := make(map[string]struct{}, len(items)) deduped := make([]types.GroupJoinRequest, 0, len(items)) for i := len(items) - 1; i >= 0; i-- { key := items[i].Topic + ":" + items[i].ApplicantUserID.UserId() if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} deduped = append(deduped, items[i]) } for left, right := 0, len(deduped)-1; left < right; left, right = left+1, right-1 { deduped[left], deduped[right] = deduped[right], deduped[left] } return deduped } func groupJoinRequestPayloadsWithUsers(tenantID types.TenantID, items []types.GroupJoinRequest) ([]map[string]any, error) { payloads := groupJoinRequestPayloads(items) if len(items) == 0 || store.Users == nil { return payloads, nil } seen := make(map[types.Uid]struct{}, len(items)*2) uids := make([]types.Uid, 0, len(items)*2) for i := range items { for _, uid := range []types.Uid{items[i].ApplicantUserID, items[i].InviterUserID} { if uid.IsZero() { continue } if _, ok := seen[uid]; ok { continue } seen[uid] = struct{}{} uids = append(uids, uid) } } if len(uids) == 0 { return payloads, nil } users, err := store.Users.GetAll(tenantID, uids...) if err != nil { return nil, err } byID := make(map[types.Uid]*types.User, len(users)) for i := range users { uid := types.ParseUid(users[i].Id) if uid.IsZero() { uid = types.ParseUserId(users[i].Id) } if uid.IsZero() { continue } byID[uid] = &users[i] } for i := range items { enrichGroupJoinRequestUserPayload(payloads[i], "user", "", items[i].ApplicantUserID, byID) enrichGroupJoinRequestUserPayload(payloads[i], "inviter", "inviter_", items[i].InviterUserID, byID) } return payloads, nil } func enrichGroupJoinRequestUserPayload(data map[string]any, objectKey, fieldPrefix string, uid types.Uid, users map[types.Uid]*types.User) { if data == nil || uid.IsZero() { return } user := users[uid] summary := map[string]any{"user_id": uid.UserId(), "display_name": uid.UserId()} if user != nil { summary = userSummaryPayload(user, "") } data[objectKey] = summary if displayName, ok := summary["display_name"]; ok { data[fieldPrefix+"display_name"] = displayName } if avatarFileID, ok := summary["avatar_file_id"]; ok { data[fieldPrefix+"avatar_file_id"] = avatarFileID } } 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 groupAnnouncementPayloadWithPermissions(topic string, ann *types.GroupAnnouncement, selfSub *types.Subscription) map[string]any { data := groupAnnouncementPayload(ann) data["topic"] = topic access := types.ModeNone if selfSub != nil { access = selfSub.ModeGiven & selfSub.ModeWant } data["permissions"] = map[string]any{ "can_edit": access.IsAdmin(), } return data } 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 int, cursor string, mentionableOnly, muteEligible bool) string { return strings.Join([]string{ topic, "v3", uid.UserId(), access.String(), strings.ToLower(strings.TrimSpace(query)), strings.TrimSpace(roleFilter), strings.TrimSpace(eligibleRole), strconv.Itoa(limit), strings.TrimSpace(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 messageMutationErrorItem(seq int64, err error) map[string]any { switch { case errors.Is(err, types.ErrNotFound): return messageBatchRecallErrorItem(seq, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found") case errors.Is(err, types.ErrDuplicate): return messageBatchRecallErrorItem(seq, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict") case errors.Is(err, types.ErrPermissionDenied): return messageBatchRecallErrorItem(seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required") case errors.Is(err, types.ErrMalformed): return messageBatchRecallErrorItem(seq, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request") default: return messageBatchRecallErrorItem(seq, http.StatusInternalServerError, 90000, imReasonInternal, "internal error") } } func messageBatchRecallErrorItem(seq int64, status, code int, reason, message string) map[string]any { return map[string]any{ "seq": seq, "status": "failed", "http": status, "code": code, "reason": reason, "message": message, } } 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 favoritePayloadForPrincipal(principal *imPrincipal, item types.FavoriteItem) (map[string]any, bool) { if principal == nil { return nil, false } sourceTopic, ok := normalizeIMFavoriteSourceTopic(principal.Uid, item.SourceTopic) if !ok { if legacyFavoriteSnapshotOnly(item) { if item.Availability == "" { item.Availability = "available" } return favoritePayload(item), true } return nil, false } if err := requireTopicReader(principal, sourceTopic); err != nil { return nil, false } item.SourceTopic = sourceTopic if store.Messages != nil { msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, item.SourceSeq) if err == nil && msg != nil { item.Availability = messageAvailability(*msg) if item.Availability == "available" { item.ItemType = imMessageType(msg) item.Snapshot = favoriteSnapshot(msg) item.AttachmentRefs = favoriteAttachmentRefs(msg) } } else if errors.Is(err, types.ErrNotFound) { item.Availability = "removed" } } return favoritePayload(item), true } func legacyFavoriteSnapshotOnly(item types.FavoriteItem) bool { source := strings.TrimSpace(item.SourceTopic) return strings.HasPrefix(source, "favSeed") && item.Snapshot != nil } func messageSearchPayload(msg types.Message, query string) map[string]any { text := messageText(msg) snippet := textSnippet(text, query) return map[string]any{ "seq": msg.SeqId, "from": messageFromUserID(msg.From), "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(&msg), "version": msg.Version, "snippet": snippet, "matches": textMatches(snippet, query), "snapshot": favoriteSnapshot(&msg), "availability": messageAvailability(msg), } } func messageMutationEventPayload(mutation types.MessageMutation) map[string]any { return map[string]any{ "seq": mutation.SeqId, "version": mutation.Version, "event": mutation.Event, "operator": mutation.Operator, "mutated_at": mutation.MutatedAt.UTC().Format(time.RFC3339Nano), "mutation_version": mutation.MutationVersion, } } func messageContextPayload(msg types.Message) map[string]any { return map[string]any{ "seq": msg.SeqId, "from": messageFromUserID(msg.From), "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(&msg), "version": msg.Version, "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": messageFromUserID(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 messageFromUserID(from string) string { from = strings.TrimSpace(from) if from == "" { return "" } if strings.HasPrefix(from, "usr") { if uid := types.ParseUserId(from); !uid.IsZero() { return uid.UserId() } return from } if uid := types.ParseUid(from); !uid.IsZero() { return uid.UserId() } return from } 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, "sort_order": module.SortOrder, "unread_count": module.UnreadCount, "enabled": module.Enabled, "permission": module.Permission, }) } return items } func populateWorkbenchModuleUnreadCounts(tenantID types.TenantID, uid types.Uid, modules []map[string]any) error { if store.Security == nil { return nil } for _, module := range modules { code, _ := module["code"].(string) switch code { case "meetings": count, err := store.Security.CountUnreadSystemNotificationsByEventPrefix(tenantID, uid, "meeting.") if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { return err } if err == nil { module["unread_count"] = count } } } return nil } 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 attendanceConfigPayload(config *types.AttendanceConfig) map[string]any { return map[string]any{ "date": config.Date.Format("2006-01-02"), "timezone": config.Timezone, "rule": attendanceRulePayload(config.Rule), "shifts": attendanceShiftPayloads(config.Shifts), "allowed_actions": config.AllowedActions, "today_record": attendanceRecordPayload(config.TodayRecord), "permissions": map[string]any{ "can_checkin": true, "can_view_managed": config.CanViewManaged, "can_appeal": config.Rule.AppealAllowed, }, } } func attendanceRulePayload(rule types.AttendanceRule) map[string]any { data := map[string]any{ "rule_id": rule.RuleID, "name": rule.Name, "workday": rule.Workday, "location_required": rule.LocationRequired, "wifi_required": rule.WifiRequired, "allow_outside_checkin": rule.AllowOutsideCheckin, "appeal_allowed": rule.AppealAllowed, } if rule.AppealDeadline != nil { data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano) } return data } func attendanceShiftPayloads(shifts []types.AttendanceShift) []map[string]any { items := make([]map[string]any, 0, len(shifts)) for _, shift := range shifts { items = append(items, map[string]any{ "shift_id": shift.ShiftID, "name": shift.Name, "work_start": shift.WorkStart, "work_end": shift.WorkEnd, "checkin_start": shift.CheckinStart, "checkout_end": shift.CheckoutEnd, }) } return items } func attendanceRecordPayloads(records []types.AttendanceRecord) []map[string]any { items := make([]map[string]any, 0, len(records)) for i := range records { items = append(items, attendanceRecordPayload(&records[i])) } return items } func attendanceRecordPayload(record *types.AttendanceRecord) map[string]any { if record == nil { return nil } return map[string]any{ "record_id": record.RecordID, "user_id": record.UserID.UserId(), "date": record.Date.Format("2006-01-02"), "rule_id": record.RuleID, "shift_id": record.ShiftID, "status": record.Status, "checkins": attendanceCheckinPayloads(record.Checkins), "exceptions": record.Exceptions, "appeal_status": record.AppealStatus, "version": record.Version, "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func attendanceCheckinPayloads(checkins []types.AttendanceCheckin) []map[string]any { items := make([]map[string]any, 0, len(checkins)) for _, checkin := range checkins { items = append(items, attendanceCheckinPayload(checkin)) } return items } func attendanceCheckinPayload(checkin types.AttendanceCheckin) map[string]any { return map[string]any{ "checkin_id": checkin.CheckinID, "action": checkin.Action, "client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano), "server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano), "result": checkin.Result, "location": checkin.Location, "wifi": checkin.Wifi, "device": checkin.Device, } } func attendanceStatsPayload(stats *types.AttendanceRecordStats) map[string]any { if stats == nil { return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0} } return map[string]any{ "expected_count": stats.ExpectedCount, "checked_count": stats.CheckedCount, "exception_count": stats.ExceptionCount, "not_checked_count": stats.NotCheckedCount, } } func attendanceAppealPayloads(appeals []types.AttendanceAppeal) []map[string]any { items := make([]map[string]any, 0, len(appeals)) for i := range appeals { items = append(items, attendanceAppealPayload(&appeals[i])) } return items } func attendanceAppealPayload(appeal *types.AttendanceAppeal) map[string]any { if appeal == nil { return nil } data := map[string]any{ "appeal_id": appeal.AppealID, "record_id": appeal.RecordID, "user_id": appeal.UserID.UserId(), "appeal_type": appeal.AppealType, "reason": appeal.Reason, "target_action": appeal.TargetAction, "attachment_file_ids": appeal.AttachmentFileIDs, "status": appeal.Status, "version": appeal.Version, "submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano), "created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano), } if appeal.TargetTime != nil { data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano) } return data } func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { return meetingPayloadWithUserSummaries(meeting, viewer, nil) } func meetingPayloadForTenant(tenantID types.TenantID, meeting *types.Meeting, viewer types.Uid) map[string]any { if meeting == nil { return map[string]any{} } return meetingPayloadWithUserSummaries(meeting, viewer, meetingUserSummaries(tenantID, []types.Meeting{*meeting})) } func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any { if meeting == nil { return map[string]any{} } participants := meetingParticipantPayloadsWithSummaries(meeting.Participants, userSummaries) organizer := meetingUserSummary(meeting.OrganizerID, userSummaries) recorderID := meeting.RecorderID if recorderID.IsZero() { recorderID = meeting.OrganizerID } recorder := meetingUserSummary(recorderID, userSummaries) startAt := meeting.StartAt.UTC() endAt := meeting.EndAt.UTC() data := map[string]any{ "meeting_id": meeting.MeetingID, "title": meeting.Title, "organizer_id": meeting.OrganizerID.UserId(), "organizer": organizer, "recorder_user_id": recorderID.UserId(), "recorder": recorder, "start_at": startAt.Format(time.RFC3339Nano), "end_at": endAt.Format(time.RFC3339Nano), "meeting_time": meetingTimeRange(startAt, endAt), "time_range": meetingTimeRange(startAt, endAt), "location": meeting.Location, "state": meeting.State, "reminder_minutes": meeting.ReminderMinutes, "version": meeting.Version, "participants": participants, "participant_count": len(participants), "participant_names": meetingParticipantNames(participants), "permissions": meetingPermissionsPayload(meeting, viewer), "created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano), } if displayName, ok := organizer["display_name"]; ok { data["organizer_display_name"] = displayName } if displayName, ok := recorder["display_name"]; ok { data["recorder_display_name"] = displayName } 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 { return meetingParticipantPayloadsWithSummaries(participants, nil) } func meetingParticipantPayloadsWithSummaries(participants []types.MeetingParticipant, userSummaries map[types.Uid]map[string]any) []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 summary := meetingUserSummary(participant.UserID, userSummaries); summary != nil { if displayName, ok := summary["display_name"]; ok { item["display_name"] = displayName } if avatar, ok := summary["avatar_file_id"]; ok { item["avatar_file_id"] = avatar } } if participant.ResponseAt != nil { item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano) } items = append(items, item) } return items } func meetingUserSummaries(tenantID types.TenantID, meetings []types.Meeting) map[types.Uid]map[string]any { if store.Users == nil { return nil } seen := make(map[types.Uid]bool) uids := make([]types.Uid, 0, len(meetings)*2) for _, meeting := range meetings { if !meeting.OrganizerID.IsZero() && !seen[meeting.OrganizerID] { seen[meeting.OrganizerID] = true uids = append(uids, meeting.OrganizerID) } if !meeting.RecorderID.IsZero() && !seen[meeting.RecorderID] { seen[meeting.RecorderID] = true uids = append(uids, meeting.RecorderID) } for _, participant := range meeting.Participants { if !participant.UserID.IsZero() && !seen[participant.UserID] { seen[participant.UserID] = true uids = append(uids, participant.UserID) } } } if len(uids) == 0 { return nil } users, err := store.Users.GetAll(tenantID, uids...) if err != nil { logs.Warn.Printf("im http: failed to load meeting user summaries: %v", err) return nil } summaries := make(map[types.Uid]map[string]any, len(users)) for i := range users { uid := users[i].Uid() if uid.IsZero() { uid = types.ParseUid(users[i].Id) } if !uid.IsZero() { summaries[uid] = userSummaryPayload(&users[i], "") } } return summaries } func meetingUserSummary(uid types.Uid, summaries map[types.Uid]map[string]any) map[string]any { if uid.IsZero() { return nil } if summary, ok := summaries[uid]; ok && summary != nil { return summary } return map[string]any{ "user_id": uid.UserId(), "display_name": uid.UserId(), } } func meetingParticipantNames(participants []map[string]any) []string { names := make([]string, 0, len(participants)) for _, participant := range participants { if name := strings.TrimSpace(fmt.Sprint(participant["display_name"])); name != "" { names = append(names, name) } } return names } func meetingNoteListItemPayload(meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any { data := meetingPayloadWithUserSummaries(meeting, viewer, userSummaries) noteStatus := types.MeetingNoteStatusEmpty if len(files) > 0 { noteStatus = types.MeetingNoteStatusAvailable } data["note_status"] = noteStatus data["file_count"] = len(files) data["permissions"] = meetingNotePermissionsPayload(meeting, viewer) return data } func meetingNotesDetailPayload(tenantID types.TenantID, meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid) map[string]any { userSummaries := meetingUserSummaries(tenantID, []types.Meeting{*meeting}) meetingData := meetingPayloadWithUserSummaries(meeting, viewer, userSummaries) data := make(map[string]any, len(meetingData)+4) for k, v := range meetingData { data[k] = v } data["meeting"] = meetingData data["note_status"] = types.MeetingNoteStatusEmpty if len(files) > 0 { data["note_status"] = types.MeetingNoteStatusAvailable } data["file_count"] = len(files) data["permissions"] = meetingNotePermissionsPayload(meeting, viewer) fileItems := make([]map[string]any, 0, len(files)) for i := range files { fileItems = append(fileItems, meetingNoteFilePayload(&files[i], viewer, userSummaries)) } data["files"] = fileItems return data } func meetingNoteFilePayload(file *types.MeetingNoteFile, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any { if file == nil { return map[string]any{} } return map[string]any{ "note_file_id": file.NoteFileID, "meeting_id": file.MeetingID, "file_id": file.FileID, "file_name": file.FileName, "mime_type": file.MIMEType, "file_size": file.FileSize, "file_etag": file.FileETag, "status": file.Status, "preview_status": file.PreviewStatus, "preview_mode": file.PreviewMode, "preview_file_id": file.PreviewFileID, "uploader_user_id": file.UploaderID.UserId(), "uploader": meetingUserSummary(file.UploaderID, userSummaries), "version": file.Version, "permissions": map[string]any{ "can_preview": file.Status == types.MeetingNoteFileStatusActive, "can_download": file.Status == types.MeetingNoteFileStatusActive, "can_replace": file.Status == types.MeetingNoteFileStatusActive && file.UploaderID == viewer, "can_delete": file.Status == types.MeetingNoteFileStatusActive && file.UploaderID == viewer, }, "created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano), } } func meetingNotePermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { recorderID := meeting.RecorderID if recorderID.IsZero() { recorderID = meeting.OrganizerID } readable := meetingNotesReadable(meeting, viewer) return map[string]any{ "can_view": readable, "can_upload": readable && recorderID == viewer, "can_replace": readable && recorderID == viewer, "can_delete": readable && recorderID == viewer, "can_download": readable, } } func meetingNotesReadable(meeting *types.Meeting, viewer types.Uid) bool { if meeting == nil || viewer.IsZero() || meeting.State == types.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) { return false } if meeting.OrganizerID == viewer || meeting.RecorderID == viewer { return true } for _, participant := range meeting.Participants { if participant.UserID == viewer { return true } } return false } func meetingTimeRange(startAt, endAt time.Time) string { if startAt.IsZero() || endAt.IsZero() { return "" } if startAt.Format("2006-01-02") == endAt.Format("2006-01-02") { return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("15:04") } return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("2006-01-02 15:04") } func meetingParticipantUIDs(meeting *types.Meeting) []types.Uid { if meeting == nil { return nil } items := meetingParticipantUserIDs(meeting.Participants) if !meeting.OrganizerID.IsZero() && !uidListContains(items, meeting.OrganizerID) { items = append(items, meeting.OrganizerID) } if !meeting.RecorderID.IsZero() && !uidListContains(items, meeting.RecorderID) { items = append(items, meeting.RecorderID) } return items } 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) } publishIMAuditOnly(ctx, tenantID, actor, module, action, resourceID, data) } func publishIMAuditOnly(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, data map[string]any) { 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}, "region": map[string]any{"max_length": 128}, "birth_date": map[string]any{"format": "YYYY-MM-DD"}, "gender": []string{"male", "female", "other", ""}, "email": map[string]any{"max_length": 128}, }, } return data } func validIMSecurityChallengeScene(scene string) bool { switch strings.TrimSpace(scene) { case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password", "group_owner_transfer", "group_dissolve": return true default: return false } } 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, "group_invite_verification": settings.GroupInviteVerification, "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, "group_invite_verification": 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 MeetingIM'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, currentVersion string, hasUpdate bool, updateType string) map[string]any { return map[string]any{ "current_version": currentVersion, "has_update": hasUpdate, "force_update": hasUpdate && updateType == "force", "platform": release.Platform, "channel": release.Channel, "latest_version": release.LatestVersion, "min_supported_version": release.MinSupportedVersion, "update_type": 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 parseNumericAppVersion(raw string) ([]uint64, error) { raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v") parts := strings.Split(raw, ".") if len(parts) == 0 || len(parts) > 8 { return nil, types.ErrMalformed } parsed := make([]uint64, len(parts)) for i, part := range parts { if part == "" { return nil, types.ErrMalformed } value, err := strconv.ParseUint(part, 10, 32) if err != nil { return nil, types.ErrMalformed } parsed[i] = value } return parsed, nil } func compareNumericAppVersion(left, right []uint64) int { length := len(left) if len(right) > length { length = len(right) } for i := 0; i < length; i++ { var leftPart, rightPart uint64 if i < len(left) { leftPart = left[i] } if i < len(right) { rightPart = right[i] } if leftPart < rightPart { return -1 } if leftPart > rightPart { return 1 } } return 0 } func youthModePayload(settings *types.YouthModeSettings) map[string]any { data := map[string]any{ "enabled": settings.Enabled, "policy_version": settings.PolicyVersion, "restrictions": settings.Restrictions, "restriction_items": youthModeRestrictionItems(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 youthModeRestrictionItems(restrictions []string) []map[string]any { catalog := map[string]map[string]any{ "night_quiet": { "code": "night_quiet", "title": "深夜免打扰", "description": "夜间减少消息提醒打扰,帮助用户保持健康作息。", "enforced": false, }, "content_filter": { "code": "content_filter", "title": "内容过滤", "description": "对不适合青少年的内容进行识别和限制展示。", "enforced": false, }, "payment_guard": { "code": "payment_guard", "title": "支付守护", "description": "限制或确认支付、充值等敏感消费行为。", "enforced": false, }, } items := make([]map[string]any, 0, len(restrictions)) for _, code := range restrictions { code = strings.TrimSpace(code) if code == "" { continue } if item, ok := catalog[code]; ok { items = append(items, item) continue } items = append(items, map[string]any{ "code": code, "title": code, "description": "", "enforced": false, }) } return items } 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, "sdk_version": token.SDKVersion, "push_env": token.PushEnv, "notification_permission": token.NotificationPermission, "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 isAllowedIMAbuseTargetType(targetType string) bool { switch strings.ToLower(strings.TrimSpace(targetType)) { case "user", "group", "message": return true default: return false } } 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", "/attendance", "/meetings", "/meeting-notes", "/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": imCanCall(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.Remark != "" { data["remark"] = ctx.Remark } if ctx.Peer != nil { peer := userSummaryPayload(ctx.Peer, ctx.Remark) peer["public"] = ctx.Peer.Public peer["trusted"] = ctx.Peer.Trusted data["peer"] = peer if displayName, ok := peer["display_name"]; ok { data["display_name"] = displayName } if avatarFileID, ok := peer["avatar_file_id"]; ok { data["avatar_file_id"] = avatarFileID } } if _, ok := data["display_name"]; !ok { data["display_name"] = ctx.PeerID.UserId() } 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(tenantID types.TenantID, viewer types.Uid, item types.FriendListItem) map[string]any { online := false onlineVisibility := "hidden" if item.Friendship.Status == types.RelationshipFriend && imCanViewLastSeen(tenantID, viewer, item.Friendship.PeerID) { onlineVisibility = "visible" online = redisUserOnline(tenantID, item.Friendship.PeerID, false) } data := map[string]any{ "user_id": item.Friendship.PeerID.UserId(), "relationship": item.Friendship.Status, "remark": item.Friendship.Remark, "topic": item.Friendship.Topic, "online": online, "online_visibility": onlineVisibility, "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": imCanCall(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["peer"] = userSummaryPayload(item.Peer, item.Friendship.Remark) if peer, ok := data["peer"].(map[string]any); ok { peer["online"] = online } } if item.Friendship.Remark == "" && data["display_name"] == data["user_id"] { data["display_name_source"] = "user_id" } 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, ctx.Remark) data["relationship"] = ctx.Relationship data["topic"] = ctx.Topic data["capabilities"] = map[string]any{ "can_send": ctx.CanSend, "can_call": imCanCall(ctx.CanSend), "can_add_friend": ctx.Relationship == types.RelationshipNone, } if ctx.Remark != "" { data["remark"] = ctx.Remark } 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 blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any { data := userSummaryPayload(user, ctx.Remark) data["relationship"] = ctx.Relationship data["topic"] = ctx.Topic data["capabilities"] = map[string]any{ "can_send": false, "can_call": false, "can_add_friend": false, } if ctx.Remark != "" { data["remark"] = ctx.Remark } return data } func copyOptionalProfileFields(data map[string]any, public map[string]any) { for _, key := range []string{"nickname", "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 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 remark != "" { data["display_name"] = remark } 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, imReasonResourceNotFound, "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 } 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 } topic = normalizeConversationSettingsTopic(topic, principal.Uid) 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": messageFromUserID(msg.From), "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(msg), "version": msg.Version, "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 favoriteForwardMessageHeadContent(item *types.FavoriteItem) (types.KVMap, any) { itemType := "text" if item != nil && strings.TrimSpace(item.ItemType) != "" { itemType = strings.TrimSpace(item.ItemType) } head := types.KVMap{"type": itemType, "x-im-type": itemType, "x-im-forwarded": true} if item != nil { if snapshot, ok := item.Snapshot.(map[string]any); ok { if rawHead, ok := snapshot["head"].(map[string]any); ok && len(rawHead) > 0 { head = types.KVMap{} for key, value := range rawHead { head[key] = value } } head["x-im-forwarded"] = true if _, ok := head["x-im-type"]; !ok { head["x-im-type"] = itemType } if _, ok := head["type"]; !ok { head["type"] = itemType } if content, ok := snapshot["content"]; ok { return head, content } } } return forwardBundleMessageHead(""), favoriteForwardContent(item, "") } 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 saveForwardCommentMessage(ctx context.Context, principal *imPrincipal, target, comment, clientMid string) map[string]any { comment = strings.TrimSpace(comment) if principal == nil || comment == "" { return nil } msg := &types.Message{ TenantID: principal.TenantID, Topic: target, From: principal.Uid.String(), Head: types.KVMap{ "type": "text", "x-im-type": "text", "x-im-forwarded": true, "x-im-client-mid": clientMid, }, Content: map[string]any{"text": comment}, } result := map[string]any{"comment_client_mid": clientMid} if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil { result["comment_status"] = "failed" result["comment_reason"] = relationErrorReason(err) return result } saved, err := saveMessageMaybeKafka(ctx, principal.TenantID, msg, nil, true) if err != nil { if saved != nil && saved.Pending { result["comment_status"] = "pending" result["comment_seq"] = saved.SeqId result["comment_reason"] = "MESSAGE_PERSIST_PENDING" return result } result["comment_status"] = "failed" result["comment_reason"] = relationErrorReason(err) return result } if saved != nil && saved.Pending { result["comment_status"] = "pending" result["comment_seq"] = saved.SeqId result["comment_reason"] = "MESSAGE_PERSIST_PENDING" return result } if saved != nil { msg.SeqId = saved.SeqId broadcastHTTPForwardMessage(principal.TenantID, msg) result["comment_status"] = "sent" result["comment_seq"] = saved.SeqId return result } result["comment_status"] = "failed" result["comment_reason"] = imReasonInternal return result } func mergeForwardCommentResult(item, comment map[string]any) { if item == nil || comment == nil { return } for key, value := range comment { item[key] = value } } func forwardClientMid(requestKey string, parts ...any) string { requestKey = strings.TrimSpace(requestKey) if requestKey == "" { return "fwd_" + store.Store.GetUidString() } args := make([]any, 0, len(parts)+2) args = append(args, "forward", requestKey) args = append(args, parts...) return "fwd_" + stableIMEventID(args...) } func normalizeIMForwardTopic(viewer types.Uid, topic string) (string, bool) { return normalizeIMUserVisibleTopic(viewer, topic) } func normalizeIMFavoriteSourceTopic(viewer types.Uid, topic string) (string, bool) { return normalizeIMUserVisibleTopic(viewer, topic) } func favoriteItemIdempotencyKey(requestKey, topic string, seq int64) string { requestKey = strings.TrimSpace(requestKey) if requestKey == "" { return "" } return stableIMEventID("favorite", requestKey, strings.TrimSpace(topic), seq) } func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) { topic = strings.TrimSpace(topic) if topic == "" { return "", false } if topic == "slf" { return viewer.SlfName(), !viewer.IsZero() } if len(topic) < 3 { return "", false } switch topic[:3] { case "usr": peer := types.ParseUserId(topic) if peer.IsZero() { return "", false } p2p := viewer.P2PName(peer) return p2p, p2p != "" case "p2p", "grp", "slf": return topic, true default: return "", false } } func buildForwardBundle(principal *imPrincipal, sources []struct { Topic string `json:"topic"` Seq imForwardSeq `json:"seq"` }, target string, sourceReadable map[string]error, comment string) (map[string]any, []map[string]any) { failures := make([]map[string]any, 0) sourceMessages := make([]*types.Message, 0, len(sources)) sourceTopics := make([]string, 0, len(sources)) sourceSeqs := make([]int64, 0, len(sources)) for _, source := range sources { sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic) if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK { failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, imReasonMalformedRequest)) continue } readErr, ok := sourceReadable[sourceTopic] if !ok { readErr = requireTopicReader(principal, sourceTopic) sourceReadable[sourceTopic] = readErr } if readErr != nil { failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(readErr))) continue } msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, int64(source.Seq)) if err != nil { failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err))) continue } if msg == nil { failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "NOT_FOUND")) continue } if messageAvailability(*msg) != "available" { failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "MESSAGE_UNAVAILABLE")) continue } sourceMessages = append(sourceMessages, msg) sourceTopics = append(sourceTopics, sourceTopic) sourceSeqs = append(sourceSeqs, int64(source.Seq)) } if len(failures) > 0 { return nil, failures } userSummaries := forwardMessageUserSummaries(principal.TenantID, sourceMessages, forwardBundleTitleUIDs(sourceTopics)...) items := make([]map[string]any, 0, len(sourceMessages)) preview := make([]map[string]any, 0, min(len(sourceMessages), 3)) sourceRefs := make([]map[string]any, 0, len(sourceMessages)) for i, msg := range sourceMessages { snapshot := favoriteSnapshot(msg) if fromUser := forwardMessageUserSummary(msg.From, userSummaries); fromUser != nil { snapshot["from_user"] = fromUser } items = append(items, snapshot) ref := map[string]any{"topic": sourceTopics[i], "seq": sourceSeqs[i]} sourceRefs = append(sourceRefs, ref) if len(preview) < 3 { previewItem := map[string]any{ "from": msg.From, "type": imMessageType(msg), "summary": textSnippet(messageText(*msg), ""), "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), } if fromUser := forwardMessageUserSummary(msg.From, userSummaries); fromUser != nil { previewItem["from_user"] = fromUser } preview = append(preview, previewItem) } } bundleID := "fbundle_" + store.Store.GetUidString() return map[string]any{ "bundle_id": bundleID, "title": forwardBundleTitle(principal.TenantID, sourceTopics, userSummaries), "item_count": len(items), "preview": preview, "items": items, "source_refs": sourceRefs, "comment": strings.TrimSpace(comment), "forwarded": true, }, nil } func forwardBundleTitleUIDs(sourceTopics []string) []types.Uid { seen := make(map[types.Uid]bool) uids := make([]types.Uid, 0, 2) for _, topic := range sourceTopics { if types.GetTopicCat(topic) != types.TopicCatP2P { continue } uid1, uid2, err := types.ParseP2P(topic) if err != nil { continue } for _, uid := range []types.Uid{uid1, uid2} { if uid.IsZero() || seen[uid] { continue } seen[uid] = true uids = append(uids, uid) } } return uids } func forwardBundleTitle(tenantID types.TenantID, sourceTopics []string, userSummaries map[types.Uid]map[string]any) string { if len(sourceTopics) == 0 { return "聊天记录" } topic := sourceTopics[0] switch types.GetTopicCat(topic) { case types.TopicCatP2P: uid1, uid2, err := types.ParseP2P(topic) if err != nil { return "聊天记录" } return forwardBundleDisplayName(uid1, userSummaries) + "和" + forwardBundleDisplayName(uid2, userSummaries) + "的聊天记录" case types.TopicCatGrp: groupName := topic if store.Topics != nil { if group, err := store.Topics.Get(tenantID, topic); err == nil && group != nil { groupName = imDisplayNameFromPublic(group.Public, topic) } else if err != nil { logs.Warn.Printf("im message forward: failed to load group title topic=%s: %v", topic, err) } } if strings.HasSuffix(groupName, "群") { return groupName + "的聊天记录" } return groupName + "群的聊天记录" default: return "聊天记录" } } func forwardBundleDisplayName(uid types.Uid, summaries map[types.Uid]map[string]any) string { if summary := forwardMessageUserSummary(uid.String(), summaries); summary != nil { if name := strings.TrimSpace(fmt.Sprint(summary["display_name"])); name != "" { return name } } return uid.UserId() } func forwardMessageUserSummaries(tenantID types.TenantID, messages []*types.Message, extraUIDs ...types.Uid) map[types.Uid]map[string]any { if store.Users == nil { return nil } seen := make(map[types.Uid]bool) uids := make([]types.Uid, 0, len(messages)+len(extraUIDs)) for _, uid := range extraUIDs { if uid.IsZero() || seen[uid] { continue } seen[uid] = true uids = append(uids, uid) } for _, msg := range messages { if msg == nil { continue } uid := types.ParseUid(msg.From) if uid.IsZero() || seen[uid] { continue } seen[uid] = true uids = append(uids, uid) } if len(uids) == 0 { return nil } users, err := store.Users.GetAll(tenantID, uids...) if err != nil { logs.Warn.Printf("im http: failed to load forward user summaries: %v", err) return nil } summaries := make(map[types.Uid]map[string]any, len(users)) for i := range users { uid := users[i].Uid() if uid.IsZero() { uid = types.ParseUid(users[i].Id) } if !uid.IsZero() { summaries[uid] = userSummaryPayload(&users[i], "") } } return summaries } func forwardMessageUserSummary(from string, summaries map[types.Uid]map[string]any) map[string]any { uid := types.ParseUid(from) if uid.IsZero() { return nil } if summary, ok := summaries[uid]; ok && summary != nil { return summary } return map[string]any{ "user_id": uid.UserId(), "display_name": uid.UserId(), } } func prepareHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) error { if msg == nil || msg.TenantID != tenantID || strings.TrimSpace(msg.Topic) == "" { return types.ErrMalformed } if msg.SeqId > 0 { return nil } if !kafkaEnabled() && globals.hub == nil { return nil } if store.Topics == nil { return types.ErrMalformed } topicRecord, err := store.Topics.Get(tenantID, msg.Topic) if err != nil { return err } if topicRecord == nil { return types.ErrNotFound } msg.SeqId = topicRecord.SeqId + 1 if msg.CreatedAt.IsZero() { msg.ObjHeader.CreatedAt = time.Now().UTC().Round(time.Millisecond) } if msg.UpdatedAt.IsZero() { msg.ObjHeader.UpdatedAt = msg.CreatedAt } return nil } func broadcastHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) { if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" { return } timestamp := msg.CreatedAt if timestamp.IsZero() { timestamp = time.Now().UTC().Round(time.Millisecond) } select { case globals.hub.routeSrv <- &ServerComMessage{ TenantID: tenantID, RcptTo: msg.Topic, AsUser: types.ParseUid(msg.From).UserId(), Timestamp: timestamp, Data: &MsgServerData{ Topic: msg.Topic, From: types.ParseUid(msg.From).UserId(), Timestamp: timestamp, SeqId: msg.SeqId, Head: msg.Head, Content: msg.Content, }, }: default: logs.Warn.Printf("im message forward: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId) } broadcastHTTPForwardPresence(tenantID, msg, timestamp) } func broadcastHTTPForwardPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) { if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" { return } uid1, uid2, err := types.ParseP2P(msg.Topic) if err != nil { return } from := types.ParseUid(msg.From).UserId() for _, rcpt := range []struct { uid types.Uid src string }{ {uid: uid1, src: uid2.UserId()}, {uid: uid2, src: uid1.UserId()}, } { actor := from if actor == rcpt.uid.UserId() { actor = "" } select { case globals.hub.routeSrv <- &ServerComMessage{ TenantID: tenantID, RcptTo: rcpt.uid.UserId(), Timestamp: timestamp, Pres: &MsgServerPres{ Topic: "me", What: "msg", Src: rcpt.src, SeqId: msg.SeqId, AcsActor: actor, SkipTopic: msg.Topic, }, }: default: logs.Warn.Printf("im message forward: hub presence queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, rcpt.uid.UserId()) } } } 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 forwardBundleMessageHead(clientMid string) types.KVMap { return types.KVMap{ "type": "forward_bundle", "x-im-type": "forward_bundle", "x-im-forwarded": true, "x-im-client-mid": clientMid, } } 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", "txt", "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) runes := []rune(text) if len(runes) <= 120 || strings.TrimSpace(query) == "" { return text } idx := runeIndexFold(text, query) if idx < 0 { idx = 0 } start := idx - 40 if start < 0 { start = 0 } end := start + 120 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 := runeIndexFold(text, query) if idx < 0 { return []map[string]any{} } return []map[string]any{{"start": idx, "end": idx + len([]rune(query))}} } func runeIndexFold(text, query string) int { query = strings.TrimSpace(query) if text == "" || query == "" { return -1 } textRunes := []rune(strings.ToLower(text)) queryRunes := []rune(strings.ToLower(query)) if len(queryRunes) == 0 || len(queryRunes) > len(textRunes) { return -1 } for i := 0; i <= len(textRunes)-len(queryRunes); i++ { matched := true for j := range queryRunes { if textRunes[i+j] != queryRunes[j] { matched = false break } } if matched { return i } } return -1 } 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, types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar: 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") case types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar: 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"] = messageFromUserID(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 publishIMConversationSystemNotice(tenantID types.TenantID, topic, msgType, template, text string, actors []imSystemMessageActor, actions []imSystemMessageAction, extra map[string]any) { if tenantID == types.ZeroTenantID || strings.TrimSpace(topic) == "" || store.Store == nil || !store.Store.IsOpen() || store.Topics == nil || store.Messages == nil { return } msgType = strings.TrimSpace(msgType) if msgType == "" { msgType = "system_notice" } if msgType != "system_notice" && msgType != "system_action" { logs.Warn.Printf("im system message: unsupported type %q for topic %s", msgType, topic) return } template = strings.TrimSpace(template) text = strings.TrimSpace(text) if template == "" || text == "" { return } topicRecord, err := store.Topics.Get(tenantID, topic) if err != nil || topicRecord == nil { if err != nil { logs.Warn.Printf("im system message: failed to load topic %s: %v", topic, err) } return } content := map[string]any{ "text": text, "template": template, "level": "normal", } if len(actors) > 0 { content["actors"] = actors } if len(actions) > 0 { content["actions"] = actions } if len(extra) > 0 { content["extra"] = extra } now := time.Now().UTC().Round(time.Millisecond) msg := &types.Message{ ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now}, TenantID: tenantID, SeqId: topicRecord.SeqId + 1, Topic: topic, From: types.ZeroUid.String(), Head: types.KVMap{ "x-im-schema": 1, "x-im-type": msgType, "x-im-template": template, "x-im-from": "system", }, Content: content, } _, err = store.Messages.Save(tenantID, msg, nil, false) if err != nil && !errors.Is(err, types.ErrDuplicate) { logs.Warn.Printf("im system message: failed to save %s for topic %s: %v", template, topic, err) return } broadcastIMConversationSystemMessage(tenantID, msg, now) } func broadcastIMConversationSystemMessage(tenantID types.TenantID, msg *types.Message, timestamp time.Time) { if globals.hub == nil || msg == nil || msg.SeqId <= 0 || strings.TrimSpace(msg.Topic) == "" { return } if timestamp.IsZero() { timestamp = time.Now().UTC().Round(time.Millisecond) } out := &ServerComMessage{ TenantID: tenantID, RcptTo: msg.Topic, AsUser: types.ParseUid(msg.From).UserId(), Timestamp: timestamp, PreserveTopicName: true, Data: &MsgServerData{ Topic: msg.Topic, From: types.ParseUid(msg.From).UserId(), Timestamp: timestamp, SeqId: msg.SeqId, Head: msg.Head, Content: msg.Content, }, } select { case globals.hub.routeSrv <- out: default: logs.Warn.Printf("im system message: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId) } deliverIMConversationSystemMessageToP2PSessions(tenantID, msg, out) if !publishRealtimeP2PConversationSystemMessage(tenantID, msg.Topic, out) { publishRealtimeTopicMessage(msg.Topic, out) } broadcastIMConversationSystemPresence(tenantID, msg, timestamp) } func publishIMGroupInvitationNotification(tenantID types.TenantID, inv *types.GroupInvitation) { if tenantID == types.ZeroTenantID || inv == nil || inv.TargetUserID.IsZero() || store.Security == nil { return } eventID := stableIMEventID("group-invitation", inv.InvitationID, inv.TargetUserID.UserId(), "created") action := types.KVMap{ "type": "group_invitation_accept", "invitation_id": inv.InvitationID, "topic": inv.Topic, "inviter_id": inv.InviterUserID.UserId(), } if inv.Message != "" { action["message"] = inv.Message } if err := createIMSystemNotification(tenantID, inv.TargetUserID, eventID, "group.invitation.created", "群聊邀请", "邀请你加入群聊。", action); err != nil { logs.Warn.Printf("group invitation: create notification invitation=%s target=%s: %v", inv.InvitationID, inv.TargetUserID.UserId(), err) } } func publishIMGroupMemberJoinedSystemNotice(tenantID types.TenantID, topic string, inviter, target types.Uid, text string, extra map[string]any) { if target.IsZero() { return } actors := make([]imSystemMessageActor, 0, 2) if !inviter.IsZero() && inviter != target { actors = append(actors, imSystemMessageActor{ UserID: inviter.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inviter.UserId()}, }) } actors = append(actors, imSystemMessageActor{ UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}, }) publishIMConversationSystemNotice(tenantID, topic, "system_action", "group_member_joined", text, actors, []imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}}, extra) } func deliverIMConversationSystemMessageToP2PSessions(tenantID types.TenantID, msg *types.Message, out *ServerComMessage) { if globals.sessionStore == nil || msg == nil || out == nil || out.Data == nil { return } uid1, uid2, err := types.ParseP2P(msg.Topic) if err != nil { return } participants := map[types.Uid]struct{}{uid1: {}, uid2: {}} globals.sessionStore.Range(func(_ string, session *Session) bool { if session == nil || session.tenantID != tenantID { return true } if _, ok := participants[session.uid]; !ok { return true } if _, err := types.P2PNameForUser(session.uid, msg.Topic); err != nil { return true } if session.getSub(msg.Topic) != nil { return true } msgCopy, ok := p2pConversationSystemMessageForUser(out, msg.Topic, session.uid) if !ok { return true } if !session.queueOut(msgCopy) { logs.Warn.Printf("im system message: session queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, session.uid.UserId()) } return true }) } func publishRealtimeP2PConversationSystemMessage(tenantID types.TenantID, topic string, out *ServerComMessage) bool { uid1, uid2, err := types.ParseP2P(topic) if err != nil { return false } for _, uid := range []types.Uid{uid1, uid2} { msgCopy, ok := p2pConversationSystemMessageForUser(out, topic, uid) if !ok { continue } publishRealtimeUserMessage(uid, msgCopy) } return true } func p2pConversationSystemMessageForUser(out *ServerComMessage, topic string, uid types.Uid) (*ServerComMessage, bool) { if out == nil || uid.IsZero() { return nil, false } viewTopic, err := types.P2PNameForUser(uid, topic) if err != nil || viewTopic == "" { return nil, false } msgCopy := out.copy() msgCopy.RcptTo = uid.UserId() if msgCopy.Data != nil { msgCopy.Data.Topic = viewTopic } if msgCopy.Info != nil { msgCopy.Info.Topic = viewTopic } if msgCopy.Pres != nil { msgCopy.Pres.Topic = viewTopic } return msgCopy, true } func broadcastIMConversationSystemPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) { if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" { return } uid1, uid2, err := types.ParseP2P(msg.Topic) if err != nil { return } for _, uid := range []types.Uid{uid1, uid2} { select { case globals.hub.routeSrv <- &ServerComMessage{ TenantID: tenantID, RcptTo: uid.UserId(), Timestamp: timestamp, PreserveTopicName: true, Pres: &MsgServerPres{ Topic: "me", What: "msg", Src: msg.Topic, SeqId: msg.SeqId, SkipTopic: msg.Topic, }, }: default: logs.Warn.Printf("im system message: hub presence queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, uid.UserId()) } } } 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 topicFromMessageMutationsPath(path string) string { const prefix = "/v1/im/topics/" const suffix = "/message-mutations" 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 parseIMMutationSince(primary, fallback string) (int64, error) { value := strings.TrimSpace(primary) if value == "" { value = strings.TrimSpace(fallback) } if value == "" { return 0, nil } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil || parsed < 0 { return 0, strconv.ErrSyntax } return parsed, nil } 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 topicMessagesActionPath(path, action string) (string, bool) { const marker = "/v1/im/topics/" idx := strings.Index(path, marker) if idx < 0 { return "", false } rest := strings.TrimPrefix(path[idx:], marker) topic, suffix, ok := strings.Cut(rest, "/messages/") if !ok || topic == "" || suffix != action { return "", false } return topic, 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 resolveConversationContextTopic(principal *imPrincipal, topic string) (string, types.Uid, error) { normalized, peer := normalizeP2PConversationTopic(topic, principal.Uid) if normalized != "" && !peer.IsZero() { return normalized, peer, nil } topic = strings.TrimSpace(topic) if types.GetTopicCat(topic) != types.TopicCatP2P { return "", types.ZeroUid, nil } if _, _, err := types.ParseP2P(topic); err != nil { return "", types.ZeroUid, nil } cursor := "" for { items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid, "", "", cursor, 100) if err != nil { return "", types.ZeroUid, err } for _, item := range items { if strings.TrimSpace(item.Friendship.Topic) == topic && !item.Friendship.PeerID.IsZero() { return topic, item.Friendship.PeerID, nil } } if nextCursor == "" { break } cursor = nextCursor } return "", types.ZeroUid, nil } func normalizeConversationSettingsTopic(topic string, uid types.Uid) string { if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() { return normalized } return strings.TrimSpace(topic) }