Files
im_server/server/im_http.go
T

8847 lines
295 KiB
Go

package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"mime"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/nyaruka/phonenumbers"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"golang.org/x/crypto/bcrypt"
)
const (
imReasonAPIKeyRequired = "API_KEY_REQUIRED"
imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
imReasonMalformedRequest = "MALFORMED_REQUEST"
imReasonTenantUnavailable = "TENANT_UNAVAILABLE"
imReasonVerificationRate = "VERIFICATION_RATE_LIMITED"
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
imReasonCodeInvalid = "CODE_INVALID"
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED"
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
imReasonAuthRequired = "AUTH_REQUIRED"
imReasonRealNameRequired = "REALNAME_REQUIRED"
imReasonActionToken = "ACTION_TOKEN_INVALID"
imReasonPermissionDenied = "PERMISSION_DENIED"
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS"
imReasonInternal = "INTERNAL_ERROR"
imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
imReasonFileUnavailable = "FILE_UNAVAILABLE"
imReasonRateLimited = "RATE_LIMITED"
)
const (
imVerificationCodeLength = 6
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour
imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300
imQRCodeDefaultExpiresInSeconds = 86400
imQRCodeMaxExpiresInSeconds = 7 * 86400
imPublicUploadMaxBytes = 10 << 20
groupMaxAdminCount = 10
monthlyReportPreviewMaxRunes = 120
)
type imHTTPResponse struct {
Code int `json:"code"`
Data any `json:"data,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Details any `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
ServerTime string `json:"server_time"`
}
type imHTTPContextKey int
const imHTTPProtocolValidated imHTTPContextKey = iota
func imReliableHTTPEventsEnabled(req *http.Request) bool {
enabled, _ := req.Context().Value(imHTTPProtocolValidated).(bool)
return enabled
}
func withIMHTTPProtocol(prefix string, next http.Handler) http.Handler {
return http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
if !strings.HasPrefix(req.URL.Path, prefix) {
next.ServeHTTP(wrt, req)
return
}
requestID := strings.TrimSpace(req.Header.Get("X-Request-ID"))
if requestID == "" {
if token, err := randomIMToken(18); err == nil {
requestID = "req_" + token
} else {
requestID = fmt.Sprintf("req_%d", time.Now().UTC().UnixNano())
}
req.Header.Set("X-Request-ID", requestID)
}
wrt.Header().Set("X-Request-ID", requestID)
wrt.Header().Set("X-IM-Schema", "1")
if valid, _ := checkAPIKey(strings.TrimSpace(req.Header.Get("X-Tinode-APIKey"))); !valid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
if schema := strings.TrimSpace(req.Header.Get("X-IM-Schema")); schema != "1" {
writeIMError(wrt, req, http.StatusBadRequest, 30006, imReasonSchemaUnsupported, "unsupported IM schema", map[string]any{"supported": []int{1}})
return
}
req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true))
if req.Method == http.MethodGet || req.Method == http.MethodHead {
if principal, err := resolveIMHTTPBearer(req); err == nil {
dispatchIMBusinessEvents(principal.TenantID)
}
}
serveIMHTTPIdempotent(wrt, req, next)
})
}
type imBufferedResponseWriter struct {
header http.Header
status int
wroteHeader bool
body bytes.Buffer
}
func newIMBufferedResponseWriter() *imBufferedResponseWriter {
return &imBufferedResponseWriter{header: make(http.Header), status: http.StatusOK}
}
func (w *imBufferedResponseWriter) Header() http.Header { return w.header }
func (w *imBufferedResponseWriter) WriteHeader(status int) {
if w.wroteHeader {
return
}
w.status = status
w.wroteHeader = true
}
func (w *imBufferedResponseWriter) Write(data []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.Write(data)
}
func (w *imBufferedResponseWriter) flushTo(wrt http.ResponseWriter) {
for key, values := range w.header {
for _, value := range values {
wrt.Header().Add(key, value)
}
}
wrt.WriteHeader(w.status)
_, _ = wrt.Write(w.body.Bytes())
}
func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http.Handler) {
if req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodPatch && req.Method != http.MethodDelete {
next.ServeHTTP(wrt, req)
return
}
if !strings.HasPrefix(strings.TrimSpace(req.Header.Get("Authorization")), "Bearer ") {
next.ServeHTTP(wrt, req)
return
}
principal, err := resolveIMHTTPBearer(req)
if err != nil {
next.ServeHTTP(wrt, req)
return
}
dispatchIMBusinessEvents(principal.TenantID)
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if key == "" || len(key) > 128 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return
}
body, err := io.ReadAll(io.LimitReader(req.Body, 4<<20+1))
if err != nil || len(body) > 4<<20 {
writeIMError(wrt, req, http.StatusRequestEntityTooLarge, 30000, imReasonMalformedRequest, "request body too large", nil)
return
}
req.Body = io.NopCloser(bytes.NewReader(body))
hashInput := append([]byte(req.URL.RawQuery+"\x00"), body...)
bodyHash := sha256.Sum256(hashInput)
record := &types.HTTPIdempotencyRecord{
UserID: principal.Uid, Method: req.Method, Route: req.URL.Path, Key: key,
BodyHash: fmt.Sprintf("%x", bodyHash[:]), ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
existing, acquired, err := store.HTTPInfrastructure.BeginIdempotency(principal.TenantID, record)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if !acquired {
if existing == nil || existing.BodyHash != record.BodyHash {
writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil)
return
}
if existing.State == types.HTTPIdempotencyCompleted {
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
wrt.Header().Set("X-Idempotent-Replay", "true")
wrt.WriteHeader(existing.ResponseStatus)
_, _ = wrt.Write(existing.ResponseBody)
return
}
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
return
}
buffered := newIMBufferedResponseWriter()
next.ServeHTTP(buffered, req)
record.ResponseStatus = buffered.status
record.ResponseBody = append([]byte(nil), buffered.body.Bytes()...)
if buffered.status >= http.StatusInternalServerError {
_ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record)
buffered.flushTo(wrt)
return
}
if err = store.HTTPInfrastructure.CompleteIdempotency(principal.TenantID, record); err != nil {
_ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
buffered.flushTo(wrt)
}
type imTenantResolveRequest struct {
TenantCode string `json:"tenant_code"`
Platform string `json:"platform,omitempty"`
AppVersion string `json:"app_version,omitempty"`
}
type imTenantApplicationRequest struct {
CreditCode string `json:"credit_code"`
EnterpriseEmail string `json:"enterprise_email,omitempty"`
LicenseFileID string `json:"license_file_id,omitempty"`
TenantCodeHint string `json:"tenant_code_hint,omitempty"`
ClientContext map[string]any `json:"client_context,omitempty"`
}
type imTenantEntryFeedbackRequest struct {
Description string `json:"description"`
Contact string `json:"contact,omitempty"`
TenantCodeHint string `json:"tenant_code_hint,omitempty"`
ScreenshotFileIDs []string `json:"screenshot_file_ids,omitempty"`
ClientContext map[string]any `json:"client_context,omitempty"`
}
type imVerificationCreateRequest struct {
TenantCode string `json:"tenant_code,omitempty"`
Scene string `json:"scene"`
Phone string `json:"phone"`
Country string `json:"country,omitempty"`
}
type imVerificationVerifyRequest struct {
Code string `json:"code"`
}
type imPasswordResetConfirmRequest struct {
ResetToken string `json:"reset_token"`
NewPassword string `json:"new_password"`
}
type imPrincipal struct {
TenantID types.TenantID
Uid types.Uid
}
type imSecurityChallengeRequest struct {
Scene string `json:"scene"`
Channel string `json:"channel"`
}
type imURLCheckRequest struct {
URL string `json:"url"`
Source string `json:"source,omitempty"`
}
type imRealNameSubmissionRequest struct {
Name string `json:"name"`
IDType string `json:"id_type"`
IDNumber string `json:"id_number"`
}
type imRealNameAppealRequest struct {
Reason string `json:"reason"`
Attachments []string `json:"attachments,omitempty"`
}
type imIndependentPasswordRequest struct {
Password string `json:"password"`
Purpose string `json:"purpose"`
ActionToken string `json:"action_token,omitempty"`
}
type imIndependentPasswordVerifyRequest struct {
Password string `json:"password"`
Purpose string `json:"purpose"`
Action string `json:"action,omitempty"`
}
type imYouthModeEnableRequest struct {
PolicyVersion int `json:"policy_version"`
ActionToken string `json:"action_token"`
}
type imDevicePushTokenRequest struct {
Provider string `json:"provider"`
Token string `json:"token"`
AppVersion string `json:"app_version,omitempty"`
Platform string `json:"platform,omitempty"`
}
type imP2PConversationRequest struct {
PeerUserID string `json:"peer_user_id"`
}
type imConversationSettingsRequest struct {
Pinned *bool `json:"pinned,omitempty"`
Muted *bool `json:"muted,omitempty"`
MuteNotifications *bool `json:"mute_notifications,omitempty"`
Remark *string `json:"remark,omitempty"`
MessageRetention *string `json:"message_retention,omitempty"`
Version int `json:"version"`
}
type imTopicDraftRequest struct {
Content string `json:"content"`
Entities any `json:"entities,omitempty"`
Version int `json:"version"`
}
type imRiskChallengeConfirmRequest struct {
Decision string `json:"decision"`
}
type imAbuseReportRequest struct {
Target types.AbuseTarget `json:"target"`
ReasonCode string `json:"reason_code"`
Description string `json:"description,omitempty"`
EvidenceFileIDs []string `json:"evidence_file_ids,omitempty"`
}
type imFeedbackRequest struct {
Category string `json:"category"`
Content string `json:"content"`
Contact string `json:"contact,omitempty"`
AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"`
ClientContext map[string]any `json:"client_context,omitempty"`
IncludeDiagnostics bool `json:"include_diagnostics,omitempty"`
}
type imSystemNotificationReadRequest struct {
ReadAt string `json:"read_at,omitempty"`
}
type imMessageRecallRequest struct {
Version int `json:"version"`
Reason string `json:"reason,omitempty"`
}
type imMessageRevisionRequest struct {
Version int `json:"version"`
Content any `json:"content"`
ClientMid string `json:"client_mid,omitempty"`
Head types.KVMap `json:"head,omitempty"`
Reason string `json:"reason,omitempty"`
}
type imFavoritesBatchCreateRequest struct {
Messages []struct {
Topic string `json:"topic"`
Seq int64 `json:"seq,string"`
} `json:"messages"`
}
type imFavoritesBatchDeleteRequest struct {
FavoriteIDs []string `json:"favorite_ids"`
}
type imFavoritesForwardRequest struct {
FavoriteIDs []string `json:"favorite_ids"`
TargetTopics []string `json:"target_topics"`
Comment string `json:"comment,omitempty"`
}
type imFriendRequestCreateRequest struct {
TargetUserID string `json:"target_user_id"`
Message string `json:"message,omitempty"`
Source string `json:"source,omitempty"`
SourceTopic string `json:"source_topic,omitempty"`
}
type imQRCodeCreateRequest struct {
Purpose string `json:"purpose"`
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
}
type imQRCodeResolveRequest struct {
Token string `json:"token"`
Purpose string `json:"purpose,omitempty"`
}
type imQRCodeClaims struct {
Version int `json:"v"`
TenantID types.TenantID `json:"tid"`
UserID string `json:"uid"`
Topic string `json:"topic,omitempty"`
Purpose string `json:"pur"`
Expires int64 `json:"exp"`
Nonce string `json:"non"`
}
type imGroupQRCodeCreateRequest struct {
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
}
type imFriendUpdateRequest struct {
Remark string `json:"remark"`
}
type imBlockBatchDeleteRequest struct {
UserIDs []string `json:"user_ids"`
}
type imGroupCreateRequest struct {
Name string `json:"name"`
MemberUserIDs []string `json:"member_user_ids,omitempty"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
Description string `json:"description,omitempty"`
}
type imGroupPatchRequest struct {
Name *string `json:"name,omitempty"`
AvatarFileID *string `json:"avatar_file_id,omitempty"`
Description *string `json:"description,omitempty"`
Version int `json:"version,omitempty"`
}
type imGroupMemberSettingsRequest struct {
Pinned *bool `json:"pinned,omitempty"`
MuteNotifications *bool `json:"mute_notifications,omitempty"`
}
type imGroupMemberProfileUpdateRequest struct {
Nickname string `json:"nickname"`
Version int `json:"version,omitempty"`
}
type imGroupInvitationCreateRequest struct {
UserIDs []string `json:"user_ids"`
Message string `json:"message,omitempty"`
}
type imGroupJoinRequestCreateRequest struct {
Message string `json:"message,omitempty"`
QRToken string `json:"qr_token,omitempty"`
Source string `json:"source,omitempty"`
}
type imGroupJoinRequestActionRequest struct {
Version int `json:"version,omitempty"`
}
type imGroupFileCreateRequest struct {
FileID string `json:"file_id"`
FileName string `json:"file_name,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
FileType string `json:"file_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
SourceTopic string `json:"source_topic,omitempty"`
SourceSeq int64 `json:"source_seq,omitempty"`
}
type imGroupAnnouncementPutRequest struct {
Content string `json:"content"`
Version int `json:"version,omitempty"`
}
type imGroupGovernancePatchRequest struct {
JoinApproval *bool `json:"join_approval,omitempty"`
MuteAll *bool `json:"mute_all,omitempty"`
MemberUploadFiles *bool `json:"member_upload_files,omitempty"`
MemberPrivateChat *bool `json:"member_private_chat,omitempty"`
MemberInvite *bool `json:"member_invite,omitempty"`
Version int `json:"version,omitempty"`
}
type imGroupAdminsBatchRequest struct {
UserIDs []string `json:"user_ids"`
Version int `json:"version,omitempty"`
}
type imGroupOwnerTransferRequest struct {
TargetUserID string `json:"target_user_id"`
Version int `json:"version,omitempty"`
}
type imGroupMuteBatchRequest struct {
Members []struct {
UserID string `json:"user_id"`
ExpiresAt string `json:"expires_at,omitempty"`
} `json:"members"`
Reason string `json:"reason,omitempty"`
}
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imTenantResolveRequest
if req.Body != nil {
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
err := dec.Decode(&body)
if err != nil {
if errors.Is(err, io.EOF) {
err = nil
}
}
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var extra any
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
} else if err == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
}
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
if tenantCode == "" {
tenantCode = strings.TrimSpace(body.TenantCode)
}
if tenantCode == "" || len(tenantCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
"field": "tenant_code",
})
return
}
if store.Tenants == nil {
logs.Err.Println("im tenant resolve: tenant store is not initialized")
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return
}
logs.Warn.Println("im tenant resolve: failed to resolve tenant", tenantCode, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return
}
endpoints := imPublicEndpoints(req)
writeIMSuccess(wrt, req, map[string]any{
"tenant": map[string]any{
"code": tenant.Code,
"name": tenant.Name,
"tenant_desc": tenant.TenantDesc,
"branding": map[string]any{},
},
"endpoints": endpoints,
"auth_methods": []string{"password", "sms"},
"password_policy": map[string]any{
"min_length": 8,
"max_length": 64,
},
"app_update": map[string]any{"type": "none"},
})
}
func serveIMPublicUploads(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if store.Store == nil || store.Store.GetMediaHandler() == nil {
writeIMError(wrt, req, http.StatusServiceUnavailable, 90000, imReasonInternal, "media handler is not configured", nil)
return
}
uploader, ok := store.Store.GetMediaHandler().(media.PublicUploader)
if !ok {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30000, imReasonUnsupportedMedia, "public upload is not supported", nil)
return
}
req.Body = http.MaxBytesReader(wrt, req.Body, imPublicUploadMaxBytes)
if err := req.ParseMultipartForm(imPublicUploadMaxBytes); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed multipart form", nil)
return
}
purpose := strings.TrimSpace(req.FormValue("purpose"))
if !isAllowedPublicUploadPurpose(purpose) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
return
}
file, header, err := req.FormFile("file")
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "file is required", map[string]any{"field": "file"})
return
}
defer file.Close()
mimeType, err := detectIMUploadMime(file, header.Header.Get("Content-Type"))
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed file", nil)
return
}
if !isAllowedPublicUploadMime(purpose, mimeType) {
writeIMError(wrt, req, http.StatusUnsupportedMediaType, 30000, imReasonUnsupportedMedia, "unsupported media type", map[string]any{"mime_type": mimeType})
return
}
token, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
publicFile := &media.PublicFileDef{
ID: "file_" + token,
Purpose: purpose,
MimeType: mimeType,
CreatedAt: now,
}
location, size, err := uploader.UploadPublic(publicFile, file)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if location != "" {
publicFile.Location = location
}
upload, err := store.TenantEntry.CreatePublicUpload(&types.PublicUpload{
FileID: publicFile.ID,
Purpose: purpose,
MimeType: mimeType,
Size: size,
ETag: publicFile.ETag,
Location: publicFile.Location,
ExpiresAt: now.Add(24 * time.Hour),
CreatedAt: now,
UploadedAt: now,
})
if err != nil {
_ = store.Store.GetMediaHandler().Delete([]string{publicFile.Location})
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, publicUploadPayload(upload))
}
func serveIMTenantApplications(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
var body imTenantApplicationRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
creditCode := strings.ToUpper(strings.TrimSpace(body.CreditCode))
email := strings.TrimSpace(body.EnterpriseEmail)
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return
}
if !isValidCreditCode(creditCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_CREDIT_CODE", "invalid credit code", map[string]any{"field": "credit_code"})
return
}
if email != "" && !isValidIMEmail(email) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_EMAIL", "invalid email", map[string]any{"field": "enterprise_email"})
return
}
token, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
app, err := store.TenantEntry.CreateTenantApplication(&types.TenantApplication{
ApplicationID: "entapp_" + token,
CreditCode: creditCode,
EnterpriseEmail: email,
LicenseFileID: strings.TrimSpace(body.LicenseFileID),
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
ClientContext: body.ClientContext,
IdempotencyKey: idempotencyKey,
Status: types.TenantApplicationStatusReceived,
EstimatedReviewHour: 24,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, tenantApplicationPayload(app))
}
func serveIMTenantEntryFeedback(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
var body imTenantEntryFeedbackRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
description := strings.TrimSpace(body.Description)
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return
}
if len([]rune(description)) < 10 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_REQUIRED", "description is required", map[string]any{"field": "description"})
return
}
if len([]rune(description)) > 1000 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_TOO_LONG", "description is too long", map[string]any{"field": "description"})
return
}
if len(body.ScreenshotFileIDs) > 9 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "TOO_MANY_FILES", "too many files", map[string]any{"field": "screenshot_file_ids"})
return
}
token, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
feedback, err := store.TenantEntry.CreateTenantEntryFeedback(&types.TenantEntryFeedback{
FeedbackID: "entryfb_" + token,
Description: description,
Contact: strings.TrimSpace(body.Contact),
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
ScreenshotFileIDs: cleanStringList(body.ScreenshotFileIDs, 9),
ClientContext: body.ClientContext,
IdempotencyKey: idempotencyKey,
Status: types.TenantEntryFeedbackReceived,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, tenantEntryFeedbackPayload(feedback))
}
func imPublicEndpoints(req *http.Request) map[string]any {
base := imPublicHTTPBase(req)
wsBase := base
if strings.HasPrefix(wsBase, "https://") {
wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://")
} else if strings.HasPrefix(wsBase, "http://") {
wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://")
}
return map[string]any{
"websocket": strings.TrimRight(wsBase, "/") + "/v0/channels",
"api": strings.TrimRight(base, "/") + "/v1/im",
"file": strings.TrimRight(base, "/") + "/v0/file",
}
}
func imPublicHTTPBase(req *http.Request) string {
configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/")
parsed, _ := url.Parse(configured)
basePath := ""
if parsed != nil {
basePath = strings.TrimRight(parsed.Path, "/")
if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) {
return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/")
}
}
if basePath == "" && req != nil && req.URL != nil {
if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 {
basePath = strings.TrimRight(req.URL.Path[:idx], "/")
}
}
scheme := "http"
if req != nil && req.TLS != nil {
scheme = "https"
}
if req != nil {
if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" {
scheme = forwarded
}
}
host := ""
if req != nil {
host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host"))
if host == "" {
host = strings.TrimSpace(req.Host)
}
}
if host == "" && parsed != nil {
host = parsed.Host
}
if host == "" {
return configured
}
return strings.TrimRight(scheme+"://"+host+basePath, "/")
}
func isPublicEndpointHost(host string) bool {
host = strings.TrimSpace(host)
return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:")
}
func firstForwardedHeader(value string) string {
if idx := strings.Index(value, ","); idx >= 0 {
value = value[:idx]
}
return strings.TrimSpace(strings.ToLower(value))
}
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imVerificationCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
scene := strings.TrimSpace(body.Scene)
if !isAllowedVerificationScene(scene) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "scene"})
return
}
phone, err := normalizeIMPhone(body.Phone, body.Country)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, body.TenantCode)
if !ok {
return
}
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
retryAfter := int(time.Until(active.ResendAfter).Seconds())
if retryAfter < 1 {
retryAfter = 1
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": retryAfter,
})
return
}
challengeID, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
code := "123456"
deliveryEnabled := scene != "register" && registrationSMSEnabled(now)
vc := &types.VerificationCode{
TenantID: tenant.ID,
ChallengeID: challengeID,
Scene: scene,
PhoneHash: phoneHash,
PhoneMask: maskIMPhone(phone),
CodeHash: hashIMSecret(tenant.ID, scene, challengeID, phoneHash, code),
State: types.VerificationStatePending,
MaxAttempts: imVerificationMaxAttempts,
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
}
if scene == "reset_password" {
if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil {
logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err)
} else {
vc.TargetUser = uid
}
}
if err := store.Verifications.Create(tenant.ID, vc); err != nil {
logs.Warn.Println("im verification create: store failed", tenant.Code, scene, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if deliveryEnabled {
if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil {
logs.Warn.Println("im verification delivery failed; keeping fixed test code active:", tenant.Code, scene, vc.PhoneMask, err)
}
} else {
logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask)
}
logs.Info.Println("im verification challenge created:", tenant.Code, scene, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
"expires_in_seconds": imVerificationExpiresInSeconds,
"resend_after_seconds": imVerificationResendAfterSeconds,
})
}
func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
challengeID := verificationChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
code := strings.TrimSpace(body.Code)
if len(code) != imVerificationCodeLength {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
vc, err := store.Verifications.Get(tenant.ID, challengeID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
logs.Warn.Println("im verification verify: lookup failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
_ = store.Verifications.Fail(tenant.ID, challengeID, types.VerificationStateExpired)
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenant.ID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
token, err := randomIMToken(32)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := store.Verifications.MarkVerified(tenant.ID, challengeID, hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
logs.Warn.Println("im verification verify: mark verified failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"verification_token": token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
}
func serveIMPasswordResetVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
challengeID := passwordResetChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
vc, token, ok := verifyIMCodeChallenge(wrt, req, tenant.ID, challengeID, strings.TrimSpace(body.Code), "reset_password")
if !ok {
return
}
if vc.TargetUser.IsZero() {
writeIMSuccess(wrt, req, map[string]any{
"reset_token": vc.ChallengeID + "." + token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
return
}
writeIMSuccess(wrt, req, map[string]any{
"reset_token": vc.ChallengeID + "." + token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
}
func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imPasswordResetConfirmRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
challengeID, token, ok := strings.Cut(strings.TrimSpace(body.ResetToken), ".")
if !ok || challengeID == "" || token == "" || strings.TrimSpace(body.NewPassword) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
vc, err := store.Verifications.Get(tenant.ID, challengeID)
if err != nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.Scene != "reset_password" || vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
tokenHash := hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token)
if *vc.VerificationTokenHash != tokenHash || !time.Now().UTC().Before(vc.ExpiresAt) {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.TargetUser.IsZero() {
_ = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, time.Now().UTC().Round(time.Millisecond))
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
return
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser)
if err != nil || login == "" {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
basicAuth := store.Store.GetLogicalAuthHandler("basic")
if basicAuth == nil || !basicAuth.IsInitialized() {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if _, err = basicAuth.UpdateRecord(auth.AuthContext{TenantID: tenant.ID}, &auth.Rec{
TenantID: tenant.ID,
Uid: vc.TargetUser,
AuthLevel: authLevel,
}, []byte(":"+body.NewPassword)); err != nil {
if err == types.ErrPolicy {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if store.Security == nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if _, err = store.Security.BumpAuthVersion(tenant.ID, vc.TargetUser, now); err != nil {
logs.Warn.Println("im password reset confirm: bump auth version failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, now); err != nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" {
if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: vc.TargetUser.String(),
Method: "tel",
Value: account.Phone,
Done: true,
}); credErr != nil {
logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr)
}
} else if getErr != nil {
logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr)
}
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(vc.TargetUser, "")
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imURLCheckRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
decision, riskLevel, reason := classifyIMURLRisk(strings.TrimSpace(body.URL))
payload := map[string]any{
"url": strings.TrimSpace(body.URL),
"source": strings.TrimSpace(body.Source),
"decision": decision,
"risk_level": riskLevel,
"reason": reason,
}
if decision != "allow" {
challengeID := "risk_" + shortTokenHash(fmt.Sprintf("%d:%s:%d", principal.Uid, strings.ToLower(strings.TrimSpace(body.URL)), time.Now().UnixNano()))
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(10 * time.Minute)
if _, err := store.Safety.CreateRiskChallenge(principal.TenantID, &types.RiskChallenge{
ChallengeID: challengeID,
UserID: principal.Uid,
Action: "open_url",
Level: riskLevel,
NoticeCode: "url_" + riskLevel,
NoticeParams: map[string]any{"source": strings.TrimSpace(body.Source)},
State: types.RiskChallengeStatePending,
ExpiresAt: expiresAt,
}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload["risk_challenge_id"] = challengeID
payload["expires_at"] = expiresAt.Format(time.RFC3339Nano)
}
writeIMSuccess(wrt, req, payload)
}
func serveIMRiskChallengeConfirm(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
challengeID := riskChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "risk_challenge_id"})
return
}
var body imRiskChallengeConfirmRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
decision := strings.ToLower(strings.TrimSpace(body.Decision))
var tokenID string
var token string
var expiresAt time.Time
var err error
if decision == "continue" {
token, expiresAt, err = issueIMActionToken(principal.TenantID, principal.Uid, "risk_continue", challengeID)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
tokenID, _, _ = strings.Cut(token, ".")
}
challenge, err := store.Safety.ConfirmRiskChallenge(principal.TenantID, principal.Uid, challengeID, decision, tokenID, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := riskChallengePayload(challenge)
if decision == "continue" {
payload["risk_action_token"] = token
payload["token_action"] = "risk_continue"
payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano)
}
if imReliableHTTPEventsEnabled(req) {
eventType := "security.risk." + decision
eventID := stableIMEventID("risk", challengeID, decision, challenge.State)
if err = createIMSystemNotification(principal.TenantID, principal.Uid,
stableIMEventID(eventID, "notification"), eventType, "安全操作确认", decision,
types.KVMap{"risk_challenge_id": challengeID}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "user", principal.Uid.UserId(),
principal.Uid, eventType, map[string]any{"risk_challenge_id": challengeID, "decision": decision}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, payload)
}
func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type")))
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
if err != nil {
logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err)
reasons = defaultIMAbuseReasons(targetType)
}
if len(reasons) == 0 {
reasons = defaultIMAbuseReasons(targetType)
}
items := make([]map[string]any, 0, len(reasons))
for _, reason := range reasons {
items = append(items, abuseReasonPayload(reason))
}
writeIMSuccess(wrt, req, map[string]any{"items": items})
}
func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imAbuseReportRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
targetType, targetUser, targetTopic, targetSeq, snapshot, err := normalizeIMAbuseTarget(principal.TenantID, principal.Uid, body.Target)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
description := strings.TrimSpace(body.Description)
if len([]rune(description)) > 500 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
if len(body.EvidenceFileIDs) > 9 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "evidence_file_ids"})
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
report, err := store.Safety.CreateAbuseReport(principal.TenantID, &types.AbuseReport{
ReportID: "ar_" + token,
ReporterID: principal.Uid,
TargetType: targetType,
TargetUserID: targetUser,
TargetTopic: targetTopic,
TargetSeq: targetSeq,
ReasonCode: strings.TrimSpace(body.ReasonCode),
Description: description,
EvidenceFileIDs: cleanStringList(body.EvidenceFileIDs, 9),
Snapshot: snapshot,
}, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, abuseReportPayload(report))
}
func serveIMFeedbackConfig(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
config, err := store.Safety.FeedbackConfig(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, feedbackConfigPayload(config))
}
func serveIMFeedback(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imFeedbackRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
content := strings.TrimSpace(body.Content)
if idempotencyKey == "" || strings.TrimSpace(body.Category) == "" || content == "" || len([]rune(content)) > 1000 || len(body.AttachmentFileIDs) > 5 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
feedback, err := store.Safety.CreateFeedback(principal.TenantID, &types.FeedbackSubmission{
FeedbackID: "fb_" + token,
UserID: principal.Uid,
Category: strings.TrimSpace(body.Category),
Content: content,
Contact: strings.TrimSpace(body.Contact),
AttachmentFileIDs: cleanStringList(body.AttachmentFileIDs, 5),
ClientContext: body.ClientContext,
IncludeDiagnostics: body.IncludeDiagnostics,
}, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback))
}
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imSecurityChallengeRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
action := strings.TrimSpace(body.Scene)
if action == "" || len(action) > 64 || strings.TrimSpace(body.Channel) != "sms" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
phone, err := currentUserPhone(principal.TenantID, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30041, imReasonRealNameRequired, "verified phone required", nil)
return
}
createSensitiveChallenge(wrt, req, principal, action, phone)
}
func serveIMSecurityChallengeVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
challengeID := securityChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
vc, _, ok := verifyIMCodeChallenge(wrt, req, principal.TenantID, challengeID, strings.TrimSpace(body.Code), "sensitive_action")
if !ok {
return
}
if vc.TargetUser != principal.Uid {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, vc.Action, defaultIMActionTarget(vc.Action, vc.ChallengeID))
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"action_token": token,
"action": vc.Action,
"expires_at": expiresAt.Format(time.RFC3339Nano),
})
}
func serveIMRealName(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMSuccess(wrt, req, map[string]any{"status": "unverified", "can_submit": true})
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, realNamePayload(rv))
}
func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
var body imRealNameSubmissionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
name := strings.TrimSpace(body.Name)
idType := strings.TrimSpace(body.IDType)
idNumber := strings.ToUpper(strings.TrimSpace(body.IDNumber))
if !validIMRealName(name, idType, idNumber) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
current, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
if err == nil && (current.Status == "pending" || current.Status == "verified") {
writeIMSuccess(wrt, req, realNamePayload(current))
return
}
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32]
rv := &types.RealNameVerification{
TenantID: principal.TenantID,
UserID: principal.Uid,
SubmissionID: submissionID,
Status: "pending",
NameCipher: protectIMSensitive(principal.TenantID, name),
IDType: idType,
IDNumberCipher: protectIMSensitive(principal.TenantID, idNumber),
IDNumberHash: hashIMSecret(principal.TenantID, idType, idNumber, "realname-id"),
MaskedName: maskIMName(name),
MaskedIDNumber: maskIMIDNumber(idNumber),
}
if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil {
if errors.Is(err, types.ErrDuplicate) {
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending", "can_submit": false})
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"})
}
func serveIMRealNameAppeals(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imRealNameAppealRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" || len([]rune(reason)) > 500 || len(body.Attachments) > 9 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"})
return
}
attachments := make([]string, 0, len(body.Attachments))
for _, raw := range body.Attachments {
fileID := strings.TrimSpace(raw)
if fileID == "" {
continue
}
attachments = append(attachments, fileID)
}
encoded, err := json.Marshal(attachments)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
appeal, err := store.Security.CreateRealNameAppeal(principal.TenantID, &types.RealNameAppeal{
TenantID: principal.TenantID,
UserID: principal.Uid,
AppealID: "rna_" + token,
Reason: reason,
Attachments: attachments,
AttachmentsJSON: encoded,
Status: "pending",
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, realNameAppealPayload(appeal))
}
func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
status := "unverified"
if rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid); err == nil {
status = rv.Status
} else if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
var modules []types.WorkbenchModule
workbenchVersion := 1
if store.Workbench != nil {
var err error
modules, workbenchVersion, err = store.Workbench.Get(principal.TenantID, principal.Uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
writeIMRelationError(wrt, req, err)
return
}
}
modulePayloads := workbenchModulePayloads(modules)
entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid, modulePayloads)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"features": map[string]any{
"create_group": true,
"user_search": true,
"scan_qr": true,
"system_notifications": true,
"message_recall": true,
"message_edit": true,
},
"permissions": map[string]any{
"create_group": true,
"user_search": true,
"scan_qr": true,
},
"entry_badges": entryBadges,
"entries": entries,
"modules": modulePayloads,
"workbench_version": workbenchVersion,
"limits": map[string]any{
"max_file_size": globals.maxFileUploadSize,
"max_text_length": globals.maxMessageSize,
"max_forward_targets": 9,
},
"system_topic": map[string]any{
"topic": "sys",
"type": "system",
},
"message_policy": map[string]any{
"recall_window_seconds": 120,
"edit_window_seconds": 300,
"supported_actions": []string{"copy", "quote", "delete", "recall", "edit"},
},
"security": map[string]any{
"realname": map[string]any{
"status": status,
"required": false,
"allow_skip": true,
"remind_after": "24h",
},
},
})
}
func serveIMWorkbench(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
modules, layoutVersion, err := store.Workbench.Get(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
tenant, err := store.Tenants.GetByID(principal.TenantID)
if err != nil || tenant == nil || strings.TrimSpace(tenant.Code) == "" {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"layout_version": layoutVersion,
"modules": workbenchModulePayloads(modules),
"enterprise": map[string]any{
"tenant_code": tenant.Code,
"feature_flags": map[string]any{
"organization": true,
"meetings": true,
"daily_reports": true,
"monthly_reports": true,
"executive_mailbox": true,
},
},
})
}
func serveIMOrganizationDepartments(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
parentID := strings.TrimSpace(req.URL.Query().Get("parent_id"))
if parentID == "" {
parentID = "root"
}
items, nextCursor, err := store.Organization.ListDepartments(principal.TenantID, principal.Uid, parentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"items": organizationDepartmentPayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
func serveIMOrganizationDepartmentResource(wrt http.ResponseWriter, req *http.Request) {
departmentID, tail, ok := organizationDepartmentResourcePath(req.URL.Path)
if !ok || tail != "members" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "organization route not found", nil)
return
}
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Organization.ListMembers(principal.TenantID, principal.Uid, departmentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"department_id": departmentID,
"items": organizationMemberPayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
func serveIMOrganizationMembers(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
query := strings.TrimSpace(req.URL.Query().Get("q"))
if query == "" {
query = strings.TrimSpace(req.URL.Query().Get("keyword"))
}
departmentID := strings.TrimSpace(req.URL.Query().Get("department_id"))
items, nextCursor, err := store.Organization.ListAllMembers(principal.TenantID, principal.Uid, departmentID, query, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"department_id": departmentID,
"items": organizationMemberPayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
func serveIMOrganizationSearch(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
query := strings.TrimSpace(req.URL.Query().Get("q"))
if query == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"})
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
items, nextCursor, err := store.Organization.Search(principal.TenantID, principal.Uid, query, strings.TrimSpace(req.URL.Query().Get("types")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"items": organizationSearchPayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
var body struct {
Title string `json:"title"`
StartAt string `json:"start_at"`
EndAt string `json:"end_at"`
Location string `json:"location"`
AttendeeUserIDs []string `json:"attendee_user_ids"`
ReminderMinutes []int `json:"reminder_minutes"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
startAt, err := parseIMTime(body.StartAt)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"})
return
}
endAt, err := parseIMTime(body.EndAt)
if err != nil || !endAt.After(startAt) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"})
return
}
meetingID, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
attendeeIDs, ok := parseIMUserIDList(wrt, req, body.AttendeeUserIDs, "attendee_user_ids")
if !ok {
return
}
meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{
MeetingID: "mtg_" + meetingID,
OrganizerID: principal.Uid,
Title: strings.TrimSpace(body.Title),
StartAt: startAt,
EndAt: endAt,
Location: strings.TrimSpace(body.Location),
ReminderMinutes: body.ReminderMinutes,
IdempotencyKey: idempotencyKey,
}, attendeeIDs)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.created"); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) {
meetingID, tail, ok := meetingResourcePath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting route not found", nil)
return
}
switch tail {
case "":
if req.Method == http.MethodGet {
serveIMMeetingGet(wrt, req, meetingID)
return
}
if req.Method == http.MethodPatch {
serveIMMeetingUpdate(wrt, req, meetingID)
return
}
case "cancel":
serveIMMeetingCancel(wrt, req, meetingID)
return
case "reminders":
serveIMMeetingReminders(wrt, req, meetingID)
return
case "my-response":
serveIMMeetingMyResponse(wrt, req, meetingID)
return
}
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
func serveIMMeetingGet(wrt http.ResponseWriter, req *http.Request, meetingID string) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID string) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
var body struct {
Title *string `json:"title"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
Location *string `json:"location"`
ReminderMinutes []int `json:"reminder_minutes"`
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
patch := types.MeetingPatch{Title: body.Title, Location: body.Location, ReminderMinutes: body.ReminderMinutes, Version: body.Version}
if body.StartAt != nil {
startAt, err := parseIMTime(*body.StartAt)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"})
return
}
patch.StartAt = &startAt
}
if body.EndAt != nil {
endAt, err := parseIMTime(*body.EndAt)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"})
return
}
patch.EndAt = &endAt
}
meeting, err := store.Meetings.Update(principal.TenantID, principal.Uid, meetingID, patch, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.updated"); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
meeting, err := store.Meetings.Cancel(principal.TenantID, principal.Uid, meetingID, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.canceled"); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meetingID string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
participants, err := store.Meetings.Remind(principal.TenantID, principal.Uid, meetingID, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
meeting, getErr := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID)
if getErr != nil {
writeIMRelationError(wrt, req, getErr)
return
}
meeting.Participants = participants
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.reminded"); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, map[string]any{
"meeting_id": meetingID,
"reminded": meetingParticipantPayloads(participants),
})
}
func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetingID string) {
if req.Method != http.MethodPut {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body struct {
Response string `json:"response"`
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
meeting, err := store.Meetings.Respond(principal.TenantID, principal.Uid, meetingID, strings.TrimSpace(body.Response), body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.response.updated"); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
}
func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
reports, nextCursor, err := store.DailyReports.List(principal.TenantID, principal.Uid,
req.URL.Query().Get("scope"),
req.URL.Query().Get("from"),
req.URL.Query().Get("to"),
req.URL.Query().Get("status"),
req.URL.Query().Get("cursor"),
limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"items": dailyReportListPayloads(reports),
"next_cursor": nextCursor,
"permissions": map[string]any{
"can_create": true,
"makeup_allowed_days": 7,
"can_view_managed": true,
"can_remind_unsubmitted": true,
},
})
case http.MethodPost:
var body struct {
ReportDate string `json:"report_date"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
reportDate, err := parseIMDate(body.ReportDate)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"})
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
report, err := store.DailyReports.CreateDraft(principal.TenantID, principal.Uid, "dr_"+token, reportDate)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
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
}
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
}
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"})
}
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
}
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
}
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
}
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
}
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
func serveIMMonthlyReportReopen(wrt http.ResponseWriter, req *http.Request, reportID string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body struct {
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
report, err := store.MonthlyReports.Reopen(principal.TenantID, principal.Uid, reportID, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
func serveIMMonthlyReportStatistics(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
month := strings.TrimSpace(req.URL.Query().Get("month"))
if !validIMMonth(month) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"})
return
}
stats, err := store.MonthlyReports.Stats(principal.TenantID, principal.Uid, month, req.URL.Query().Get("department_id"))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"expected_count": stats.ExpectedCount,
"submitted_count": stats.SubmittedCount,
"missing_count": stats.MissingCount,
"scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))},
})
}
func serveIMMailboxConfig(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
config, err := store.Mailbox.Config(principal.TenantID, principal.Uid)
if err != nil {
logs.Warn.Println("im mailbox config: using default config after store error", principal.TenantID, err)
config = defaultIMMailboxConfig()
}
writeIMSuccess(wrt, req, mailboxConfigPayload(config))
}
func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
var body struct {
Subject string `json:"subject"`
Content string `json:"content"`
Anonymous bool `json:"anonymous"`
AttachmentFileIDs []string `json:"attachment_file_ids"`
NoticeVersion string `json:"notice_version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
submission, err := store.Mailbox.Submit(principal.TenantID, principal.Uid, &types.MailboxSubmission{
SubmissionID: "mb_" + token,
SenderID: principal.Uid,
Subject: strings.TrimSpace(body.Subject),
Content: strings.TrimSpace(body.Content),
Anonymous: body.Anonymous,
AttachmentFileIDs: body.AttachmentFileIDs,
NoticeVersion: strings.TrimSpace(body.NoticeVersion),
}, idempotencyKey)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, "MAILBOX_UNAVAILABLE", "mailbox unavailable", nil)
return
}
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, mailboxSubmissionResultPayload(submission))
}
func serveIMMe(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
profile, err := store.Profiles.Get(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"user_id": principal.Uid.UserId(),
"profile": userProfilePayload10(profile),
"profile_version": profile.Version,
"realname": map[string]any{"status": "unknown"},
"youth_mode": map[string]any{"enabled": false},
})
}
func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
profile, err := store.Profiles.Get(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, userProfilePayload10(profile))
case http.MethodPatch:
var body struct {
AvatarFileID *string `json:"avatar_file_id"`
Nickname *string `json:"nickname"`
BirthDate *string `json:"birth_date"`
Signature *string `json:"signature"`
Gender *string `json:"gender"`
Region *string `json:"region"`
Email *string `json:"email"`
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if body.BirthDate != nil && strings.TrimSpace(*body.BirthDate) != "" {
if date, err := parseIMDate(*body.BirthDate); err != nil || date.After(time.Now().UTC()) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "birth_date"})
return
}
}
profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{
AvatarFileID: body.AvatarFileID,
Nickname: body.Nickname,
BirthDate: body.BirthDate,
Signature: body.Signature,
Gender: body.Gender,
Region: body.Region,
Email: body.Email,
Version: body.Version,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, userProfilePayload10(profile))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
settings, err := store.Profiles.GetPrivacy(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
case http.MethodPatch:
var body struct {
AddMeMethods map[string]bool `json:"add_me_methods"`
ProfileVisibility *string `json:"profile_visibility"`
LastSeenVisibility *string `json:"last_seen_visibility"`
ReadReceipts *bool `json:"read_receipts"`
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{
AddMeMethods: body.AddMeMethods,
ProfileVisibility: body.ProfileVisibility,
LastSeenVisibility: body.LastSeenVisibility,
ReadReceipts: body.ReadReceipts,
Version: body.Version,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "default")
if !ok {
return
}
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel")))
if channel == "" {
channel = "stable"
}
if platform != "ios" && platform != "android" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
return
}
release, err := store.AppReleases.Latest(tenant.ID, platform, channel)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, appReleasePayload(release))
}
func serveIMYouthMode(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
settings, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, youthModePayload(settings))
}
func serveIMYouthModeEnable(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imYouthModeEnableRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if body.PolicyVersion <= 0 || strings.TrimSpace(body.ActionToken) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, true, body.PolicyVersion, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, youthModePayload(settings))
}
func serveIMYouthModeDisable(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
actionToken := strings.TrimSpace(req.Header.Get("X-Action-Token"))
if actionToken == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "X-Action-Token"})
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, "close_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
current, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, false, current.PolicyVersion, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, youthModePayload(settings))
}
func serveIMDevicePushToken(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPut {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
deviceID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/v1/im/devices/"))
deviceID = strings.TrimSuffix(deviceID, "/push-token")
if deviceID == "" || strings.Contains(deviceID, "/") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"})
return
}
var body imDevicePushTokenRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
token, err := store.DevicePushTokens.Upsert(principal.TenantID, &types.DevicePushToken{
UserID: principal.Uid,
DeviceID: deviceID,
Provider: body.Provider,
Token: body.Token,
AppVersion: body.AppVersion,
Platform: body.Platform,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, devicePushTokenPayload(token))
}
func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
start := 0
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
start = parsed
}
items := make([]map[string]any, 0, limit)
nextIndex := start
for i := start; i < len(subs); i++ {
if query != "" && !conversationMatches(subs[i], query) {
continue
}
items = append(items, conversationPayload(subs[i]))
nextIndex = i + 1
if len(items) == limit {
break
}
}
nextCursor := ""
if nextIndex < len(subs) {
nextCursor = strconv.Itoa(nextIndex)
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
}
func serveIMP2PConversation(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imP2PConversationRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
peer := types.ParseUserId(strings.TrimSpace(body.PeerUserID))
if peer.IsZero() || peer == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "peer_user_id"})
return
}
topic, err := store.Conversations.EnsureP2P(principal.TenantID, principal.Uid, peer)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
}
func serveIMForwardTargets(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("query")))
if query == "" {
query = strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
}
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
start := 0
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
start = parsed
}
items := make([]map[string]any, 0, limit)
if start == 0 && forwardTargetMatches("slf", "文件助手", query) {
items = append(items, map[string]any{
"topic": "slf",
"type": "file_assistant",
"display_name": "文件助手",
"avatar_file_id": "",
"can_receive_forward": true,
})
}
nextIndex := start
for i := start; i < len(subs); i++ {
target := forwardTargetPayload(subs[i])
if target == nil {
nextIndex = i + 1
continue
}
if query != "" && !forwardTargetMatches(target["topic"].(string), target["display_name"].(string), query) && !conversationMatches(subs[i], query) {
continue
}
items = append(items, target)
nextIndex = i + 1
if len(items) == limit {
break
}
}
nextCursor := ""
if nextIndex < len(subs) {
nextCursor = strconv.Itoa(nextIndex)
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
}
func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
items, nextCursor, err := store.Security.ListSystemNotifications(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
}
func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
eventID := systemNotificationIDFromPath(req.URL.Path)
if eventID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "id"})
return
}
if strings.TrimSpace(req.Header.Get("Idempotency-Key")) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
var body imSystemNotificationReadRequest
if req.Body != nil && req.ContentLength != 0 {
if !decodeIMJSONBody(wrt, req, &body) {
return
}
}
readAt := time.Now().UTC().Round(time.Millisecond)
if body.ReadAt != "" {
parsed, err := time.Parse(time.RFC3339Nano, body.ReadAt)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "read_at"})
return
}
readAt = parsed.UTC().Round(time.Millisecond)
}
if err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt); err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)})
}
func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
serveIMFavoriteList(wrt, req)
case http.MethodPost:
serveIMFavoriteBatchCreate(wrt, req)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Favorites.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
payload = append(payload, favoritePayload(item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imFavoritesBatchCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.Messages) == 0 || len(body.Messages) > 100 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "messages"})
return
}
now := time.Now().UTC().Round(time.Millisecond)
created, existing := 0, 0
failed := make([]map[string]any, 0)
for _, src := range body.Messages {
if src.Topic == "" || src.Seq <= 0 {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest})
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, src.Topic, src.Seq)
if err != nil {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)})
continue
}
fav := &types.FavoriteItem{
TenantID: principal.TenantID,
FavoriteID: "fav_" + store.Store.GetUidString(),
UserID: principal.Uid,
ItemType: imMessageType(msg),
SourceTopic: src.Topic,
SourceSeq: src.Seq,
Snapshot: favoriteSnapshot(msg),
AttachmentRefs: favoriteAttachmentRefs(msg),
Availability: "available",
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
CollectedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
item, err := store.Favorites.Create(principal.TenantID, fav)
if err != nil {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)})
continue
}
if item.FavoriteID == fav.FavoriteID {
created++
} else {
existing++
}
}
writeIMSuccess(wrt, req, map[string]any{"created_count": created, "existing_count": existing, "failed": failed})
}
func serveIMFavoriteResource(wrt http.ResponseWriter, req *http.Request) {
const prefix = "/v1/im/favorites/"
idx := strings.Index(req.URL.Path, prefix)
if idx < 0 {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "favorite route not found", nil)
return
}
tail := strings.Trim(strings.TrimPrefix(req.URL.Path[idx:], prefix), "/")
switch {
case tail == "batch":
serveIMFavoriteBatchCreate(wrt, req)
case tail == "batch-delete":
serveIMFavoriteBatchDelete(wrt, req)
case tail == "forward":
serveIMFavoriteForward(wrt, req)
default:
serveIMFavoriteDetail(wrt, req, tail)
}
}
func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteID string) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, favoritePayload(*item))
case http.MethodDelete:
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"favorite_id": favoriteID, "deleted": true})
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFavoriteBatchDelete(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imFavoritesBatchDeleteRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
results := make([]map[string]any, 0, len(body.FavoriteIDs))
for _, favoriteID := range body.FavoriteIDs {
favoriteID = strings.TrimSpace(favoriteID)
item := map[string]any{"favorite_id": favoriteID}
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
item["ok"] = true
}
results = append(results, item)
}
writeIMSuccess(wrt, req, map[string]any{"items": results})
}
func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imFavoritesForwardRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.FavoriteIDs) == 0 || len(body.FavoriteIDs) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs))
comment := strings.TrimSpace(body.Comment)
for _, target := range body.TargetTopics {
target = strings.TrimSpace(target)
if target == "" {
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": imReasonMalformedRequest})
continue
}
if err := requireTopicWriter(principal, target); err != nil {
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": relationErrorReason(err)})
continue
}
for _, favoriteID := range body.FavoriteIDs {
favoriteID = strings.TrimSpace(favoriteID)
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
if err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
}
msg := &types.Message{
TenantID: principal.TenantID,
Topic: target,
From: principal.Uid.String(),
Head: types.KVMap{
"type": item.ItemType,
"x-im-type": item.ItemType,
"x-im-forwarded": true,
},
Content: favoriteForwardContent(item, comment),
}
saved, err := store.Messages.Save(principal.TenantID, msg, nil, true)
if err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
}
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId})
}
}
writeIMSuccess(wrt, req, map[string]any{"items": results})
}
func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/draft"):
serveIMTopicDraft(wrt, req)
case strings.HasSuffix(req.URL.Path, "/calls"):
serveIMTopicCalls(wrt, req)
case strings.HasSuffix(req.URL.Path, "/messages/search"):
serveIMMessageSearch(wrt, req)
case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"):
serveIMMessageContext(wrt, req)
case strings.HasSuffix(req.URL.Path, "/media"):
serveIMTopicMedia(wrt, req)
case strings.HasSuffix(req.URL.Path, "/receipts"):
serveIMMessageReceipts(wrt, req)
case strings.HasSuffix(req.URL.Path, "/recall"):
serveIMMessageRecall(wrt, req)
case strings.HasSuffix(req.URL.Path, "/revisions"):
serveIMMessageRevision(wrt, req)
default:
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message action route not found", nil)
}
}
func serveIMTopicDraft(wrt http.ResponseWriter, req *http.Request) {
topic := topicFromDraftPath(req.URL.Path)
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
draft, err := store.Conversations.GetDraft(principal.TenantID, principal.Uid, topic)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "content": "", "entities": []any{}, "version": 0})
return
}
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, topicDraftPayload(draft))
case http.MethodPut:
var body imTopicDraftRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
encoded, err := json.Marshal(body.Entities)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "entities"})
return
}
if int64(len([]byte(body.Content))) > globals.maxMessageSize {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
return
}
draft, err := store.Conversations.PutDraft(principal.TenantID, principal.Uid, topic, body.Content, encoded, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, topicDraftPayload(draft))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMCalls(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, "", strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
}
func serveIMTopicCalls(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromCallsPath(req.URL.Path))
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
}
func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessagesSearchPath(req.URL.Path))
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items := make([]map[string]any, 0, len(msgs))
for _, msg := range msgs {
items = append(items, messageSearchPayload(msg, strings.TrimSpace(req.URL.Query().Get("q"))))
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
}
func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
topic, seq, ok := messageContextPath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
return
}
principal, _, ok := authenticateTopicReader(wrt, req, topic)
if !ok {
return
}
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items := make([]map[string]any, 0, len(msgs))
for _, msg := range msgs {
items = append(items, messageContextPayload(msg))
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "anchor_seq": seq, "items": items})
}
func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMediaPath(req.URL.Path))
if !ok {
return
}
mediaType := strings.TrimSpace(req.URL.Query().Get("type"))
if mediaType == "" {
mediaType = "image"
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), mediaType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items := make([]map[string]any, 0, len(msgs))
for _, msg := range msgs {
items = append(items, mediaMessagePayload(msg))
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
}
func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
topic, seq, ok := messageReceiptsPath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message receipt route not found", nil)
return
}
if seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "seq"})
return
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 100, 200)
cursor, _ := strconv.Atoi(req.URL.Query().Get("cursor"))
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: limit + 1})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
readItems := make([]map[string]any, 0, limit)
deliveredItems := make([]map[string]any, 0, limit)
nextCursor := 0
for i, member := range members {
if i < cursor {
continue
}
if len(readItems) >= limit && len(deliveredItems) >= limit {
nextCursor = i
break
}
item := receiptMemberPayload(member)
readReceipts := false
memberID := types.ZeroUid
if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 {
memberID = types.Uid(decoded)
} else {
memberID = types.ParseUid(member.User)
}
if !memberID.IsZero() {
if privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, memberID); privacyErr == nil {
readReceipts = privacy.ReadReceipts
}
}
if readReceipts && member.ReadSeqId >= seq && len(readItems) < limit {
readItems = append(readItems, item)
}
if member.RecvSeqId >= seq && len(deliveredItems) < limit {
deliveredItems = append(deliveredItems, item)
}
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"seq": seq,
"read": readItems,
"delivered": deliveredItems,
"next_cursor": nextCursor,
})
}
func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
topic, seq, ok := messageActionPath(req.URL.Path, "recall")
if !ok || seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var body imMessageRecallRequest
if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 {
if body.Version <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
}
return
}
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, true)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if !authorized {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required", nil)
return
}
if time.Since(msg.CreatedAt) > 120*time.Second {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired", nil)
return
}
recalledAt := time.Now().UTC().Round(time.Millisecond)
updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, body.Version, recalledAt)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
eventID := stableIMEventID("message", topic, seq, updated.Version, "recalled")
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
"message.recalled", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, messageMutationPayload(updated, "recalled"))
}
func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
topic, seq, ok := messageActionPath(req.URL.Path, "revisions")
if !ok || seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var body imMessageRevisionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if body.Version <= 0 || body.Content == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version/content"})
return
}
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, false)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if !authorized {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit permission required", nil)
return
}
if time.Since(msg.CreatedAt) > 300*time.Second {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit window expired", nil)
return
}
if !isIMEditableTextMessage(msg) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "only text messages can be edited", nil)
return
}
head := body.Head
if head == nil {
head = types.KVMap{}
}
head["type"] = "text"
if body.ClientMid != "" {
head["x-im-client-mid"] = body.ClientMid
}
editedAt := time.Now().UTC().Round(time.Millisecond)
updated, err := store.Messages.Edit(principal.TenantID, topic, seq, principal.Uid, body.Version, head, body.Content, body.Reason, editedAt)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
eventID := stableIMEventID("message", topic, seq, updated.Version, "edited")
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
"message.edited", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited"))
}
func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
topic, tail := conversationResourceFromPath(req.URL.Path)
if tail == "settings" {
serveIMConversationSettings(wrt, req, principal, topic)
return
}
if tail == "messages" {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false})
return
}
if tail != "context" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "conversation route not found", nil)
return
}
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
topic, peer := normalizeP2PConversationTopic(topic, principal.Uid)
if topic == "" || peer.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
}
func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
switch req.Method {
case http.MethodGet:
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
case http.MethodPatch:
var body imConversationSettingsRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
muted := body.Muted
if muted == nil {
muted = body.MuteNotifications
}
patch := types.ConversationSettingsPatch{
Pinned: body.Pinned,
Muted: muted,
Remark: body.Remark,
MessageRetention: body.MessageRetention,
Version: body.Version,
}
settings, err := store.Conversations.UpdateSettings(principal.TenantID, principal.Uid, topic, patch)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
serveIMFriendRequestList(wrt, req)
case http.MethodPost:
serveIMFriendRequestCreate(wrt, req)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFriendRequestList(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
items, nextCursor, err := store.Relations.ListFriendRequests(principal.TenantID, principal.Uid,
strings.TrimSpace(req.URL.Query().Get("direction")),
strings.TrimSpace(req.URL.Query().Get("status")),
strings.TrimSpace(req.URL.Query().Get("cursor")),
limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
payload = append(payload, friendRequestListItemPayload(item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imFriendRequestCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
target := types.ParseUserId(body.TargetUserID)
message := strings.TrimSpace(body.Message)
if target.IsZero() || target == principal.Uid || len([]rune(message)) > 50 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !imPrivacyAllowsAdd(privacy, body.Source) {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "user does not accept friend requests from this source", nil)
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return
}
now := time.Now().UTC().Round(time.Millisecond)
requestID := "frq_" + store.Store.GetUidString()
created, err := store.Relations.CreateFriendRequest(principal.TenantID, &types.FriendRequest{
TenantID: principal.TenantID,
RequestID: requestID,
ApplicantID: principal.Uid,
TargetID: target,
Message: message,
Source: strings.TrimSpace(body.Source),
SourceTopic: strings.TrimSpace(body.SourceTopic),
Status: types.FriendRequestPending,
IdempotencyKey: idempotencyKey,
ExpiresAt: now.Add(7 * 24 * time.Hour),
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, target, created.RequestID,
"friend.request.created", "新的好友申请", message); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, friendRequestPayload(created))
}
func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid,
strings.TrimSpace(req.URL.Query().Get("query")),
strings.TrimSpace(req.URL.Query().Get("initial")),
strings.TrimSpace(req.URL.Query().Get("cursor")),
limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
payload = append(payload, friendListItemPayload(item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMGroups(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
serveIMGroupList(wrt, req)
case http.MethodPost:
serveIMGroupCreate(wrt, req)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
category := strings.TrimSpace(req.URL.Query().Get("category"))
switch category {
case "", "owned", "managed", "joined":
default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "category"})
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
query := strings.TrimSpace(req.URL.Query().Get("query"))
subs, err := store.Users.GetSubs(principal.TenantID, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
items := make([]map[string]any, 0, limit)
seen := 0
nextCursor := ""
for _, sub := range subs {
if types.GetTopicCat(sub.Topic) != types.TopicCatGrp {
continue
}
item := groupListItemPayload(sub)
if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category {
continue
}
if query != "" && !groupListItemMatches(item, query) {
continue
}
if seen < cursor {
seen++
continue
}
if len(items) == limit {
nextCursor = strconv.Itoa(seen)
break
}
items = append(items, item)
seen++
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
}
func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imGroupCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
name := strings.TrimSpace(body.Name)
if len([]rune(name)) < 1 || len([]rune(name)) > 32 || len(body.MemberUserIDs) > 99 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
description := strings.TrimSpace(body.Description)
if len([]rune(description)) > 200 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
topicName := genTopicName()
now := time.Now().UTC().Round(time.Millisecond)
public := map[string]any{"name": name, "version": 1}
if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" {
public["avatar_file_id"] = avatar
}
if description != "" {
public["description"] = description
}
topic := &types.Topic{
ObjHeader: types.ObjHeader{Id: topicName},
TenantID: principal.TenantID,
State: types.StateOK,
Access: types.DefaultAccess{Auth: types.ModeCPublic, Anon: types.ModeNone},
Public: public,
}
topic.GiveAccess(principal.Uid, types.ModeCFull, types.ModeCFull)
settings := &types.GroupSettings{
Topic: topicName,
OwnerUserID: principal.Uid,
Name: name,
AvatarFileID: strings.TrimSpace(body.AvatarFileID),
Description: description,
GroupStatus: types.GroupStatusActive,
MemberUploadFiles: true,
MemberPrivateChat: true,
MemberInvite: true,
MaxAdminCount: groupMaxAdminCount,
MemberCount: 1,
Version: 1,
CreatedBy: principal.Uid,
CreatedAt: now,
UpdatedAt: now,
}
memberIDs := make([]types.Uid, 0, len(body.MemberUserIDs))
seen := map[types.Uid]bool{principal.Uid: true}
for _, rawID := range body.MemberUserIDs {
uid := types.ParseUserId(strings.TrimSpace(rawID))
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
memberIDs = append(memberIDs, uid)
}
memberPrivate := groupMemberPrivate("", false, false, 1, now)
if err := store.GroupBusiness.CreateGroup(principal.TenantID, topic, principal.Uid, memberPrivate,
settings, memberIDs, memberPrivate, now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
created, err := store.Topics.Get(principal.TenantID, topicName)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
selfSub, err := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub))
}
func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
topic, tail, ok := groupResourcePath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
return
}
if tail == "settings" {
serveIMGroupSettings(wrt, req, topic)
return
}
if tail == "member-settings" {
serveIMGroupMemberSettings(wrt, req, topic)
return
}
if tail == "members/me/profile" {
serveIMGroupMyProfile(wrt, req, topic)
return
}
if tail == "leave" {
serveIMGroupLeave(wrt, req, topic)
return
}
if tail == "qrcodes" {
serveIMGroupQRCodes(wrt, req, topic)
return
}
if tail == "invitations" {
serveIMGroupInvitations(wrt, req, topic)
return
}
if tail == "join-requests" {
serveIMGroupJoinRequests(wrt, req, topic)
return
}
if strings.HasPrefix(tail, "join-requests/") {
serveIMGroupJoinRequestAction(wrt, req, topic, tail)
return
}
if tail == "summary" {
serveIMGroupSummary(wrt, req, topic)
return
}
if tail == "announcements/latest" || tail == "announcements/current" || strings.HasPrefix(tail, "announcements/") {
serveIMGroupAnnouncements(wrt, req, topic, tail)
return
}
if tail == "files" || strings.HasPrefix(tail, "files/") {
serveIMGroupFiles(wrt, req, topic, tail)
return
}
if tail == "governance" {
serveIMGroupGovernance(wrt, req, topic)
return
}
if tail == "role-limits" {
serveIMGroupRoleLimits(wrt, req, topic)
return
}
if tail == "admins/batch" || strings.HasPrefix(tail, "admins/") {
serveIMGroupAdmins(wrt, req, topic, tail)
return
}
if tail == "owner-transfer" {
serveIMGroupOwnerTransfer(wrt, req, topic)
return
}
if tail == "mutes" || strings.HasPrefix(tail, "mutes/") {
serveIMGroupMutes(wrt, req, topic, tail)
return
}
if tail == "members" {
serveIMGroupMembers(wrt, req, topic)
return
}
if strings.HasPrefix(tail, "members/") && strings.HasSuffix(tail, "/profile") {
memberID, ok := groupMemberProfilePath(tail)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
return
}
serveIMGroupMemberProfile(wrt, req, topic, memberID)
return
}
if tail == "" {
serveIMGroupDetail(wrt, req, topic)
return
}
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
}
func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
selfAccess := types.ModeNone
if selfSub != nil {
selfAccess = selfSub.ModeGiven & selfSub.ModeWant
}
if selfSub == nil || !selfAccess.IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
query := strings.TrimSpace(req.URL.Query().Get("query"))
mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable"))
roleFilter := strings.TrimSpace(req.URL.Query().Get("role"))
eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role"))
muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible"))
cacheKey := groupMemberPageCacheKey(topic, principal.Uid, selfAccess, query, roleFilter, eligibleRole, limit, cursor, mentionableOnly, muteEligible)
if cached, ok := store.GetCachedGroupMemberPage(principal.TenantID, cacheKey); ok {
writeIMSuccess(wrt, req, cached)
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
items := make([]map[string]any, 0, limit)
seen := 0
nextCursor := ""
for _, member := range members {
access := member.ModeGiven & member.ModeWant
mentionable := access.IsJoiner() && access.IsReader()
role := groupMemberRole(access)
if roleFilter != "" && role != roleFilter {
continue
}
if mentionableOnly && !mentionable {
continue
}
item := groupMemberPayload(member, mentionable)
if eligibleRole != "" {
eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole)
item["eligible"] = eligible
if reason != "" {
item["ineligible_reason"] = reason
}
}
if muteEligible {
eligible, reason := groupMuteEligibility(selfAccess, access)
item["eligible"] = eligible
if reason != "" {
item["ineligible_reason"] = reason
}
}
if query != "" && !groupMemberMatches(item, query) {
continue
}
if seen < cursor {
seen++
continue
}
if len(items) == limit {
nextCursor = strconv.Itoa(seen)
break
}
items = append(items, item)
seen++
}
permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(),
}
payload := map[string]any{
"topic": topic,
"items": items,
"next_cursor": nextCursor,
"permissions": permissions,
}
store.CacheGroupMemberPage(principal.TenantID, cacheKey, payload)
writeIMSuccess(wrt, req, payload)
}
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if group == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
payload := groupDetailPayload(group, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
}
writeIMSuccess(wrt, req, payload)
case http.MethodPatch:
access := selfSub.ModeGiven & selfSub.ModeWant
if !access.IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupPatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if group == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
public := groupPublicMap(group.Public)
currentVersion := intFromAny(public["version"])
if currentVersion <= 0 {
currentVersion = 1
}
if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
}
if body.Name != nil {
name := strings.TrimSpace(*body.Name)
if len([]rune(name)) < 1 || len([]rune(name)) > 32 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "name"})
return
}
public["name"] = name
}
if body.AvatarFileID != nil {
public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID)
}
if body.Description != nil {
description := strings.TrimSpace(*body.Description)
if len([]rune(description)) > 200 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
public["description"] = description
}
public["version"] = currentVersion + 1
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": public}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
patch := types.GroupSettingsPatch{Name: body.Name, AvatarFileID: body.AvatarFileID, Description: body.Description, Version: body.Version}
if _, err = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch); err != nil {
if rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr)
}
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
updated, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := groupDetailPayload(updated, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
}
writeIMSuccess(wrt, req, payload)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := groupSettingsPayload(group, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub)
}
writeIMSuccess(wrt, req, payload)
}
func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPatch {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
var body imGroupMemberSettingsRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
private := groupPrivateMap(selfSub.Private)
if body.Pinned != nil {
private["pinned"] = *body.Pinned
}
if body.MuteNotifications != nil {
private["mute_notifications"] = *body.MuteNotifications
}
private["version"] = intFromAny(private["version"]) + 1
private["updated_at"] = time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano)
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Pinned: body.Pinned, MuteNotifications: body.MuteNotifications}); err != nil {
if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back member settings: %v", topic, rollbackErr)
}
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
selfSub.Private = private
writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub))
}
func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPut {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
var body imGroupMemberProfileUpdateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
nickname := strings.TrimSpace(body.Nickname)
if len([]rune(nickname)) > 32 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"})
return
}
private := groupPrivateMap(selfSub.Private)
currentVersion := intFromAny(private["profile_version"])
if currentVersion <= 0 {
currentVersion = 1
}
if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
}
private["nickname"] = nickname
private["profile_version"] = currentVersion + 1
updatedAt := time.Now().UTC().Round(time.Millisecond)
private["profile_updated_at"] = updatedAt.Format(time.RFC3339Nano)
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version}); err != nil {
if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back member profile: %v", topic, rollbackErr)
}
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
}
func serveIMGroupLeave(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if (selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "owner must transfer or dismiss group before leaving", nil)
return
}
if err := store.GroupBusiness.Leave(principal.TenantID, topic, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"})
}
func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsSharer() && !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupQRCodeCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
expiresIn := body.ExpiresInSeconds
if expiresIn <= 0 {
expiresIn = imQRCodeDefaultExpiresInSeconds
}
if expiresIn > imQRCodeMaxExpiresInSeconds {
expiresIn = imQRCodeMaxExpiresInSeconds
}
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
token, err := generateIMQRCodeTokenForTopic(principal.TenantID, principal.Uid, topic, "join_group", expiresAt)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group",
"expires_at": expiresAt.Format(time.RFC3339Nano),
"group_snapshot": groupDetailPayload(group, selfSub),
})
}
func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
access := selfSub.ModeGiven & selfSub.ModeWant
if !access.IsSharer() && !access.IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupInvitationCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
return
}
results := make([]map[string]any, 0, len(body.UserIDs))
for _, rawID := range body.UserIDs {
uid := types.ParseUserId(strings.TrimSpace(rawID))
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
if uid.IsZero() || uid == principal.Uid {
item["status"] = "failed"
item["reason"] = imReasonMalformedRequest
results = append(results, item)
continue
}
item["user_id"] = uid.UserId()
now := time.Now().UTC().Round(time.Millisecond)
joined, err := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, uid,
strings.TrimSpace(body.Message), strings.TrimSpace(req.Header.Get("Idempotency-Key")),
groupMemberPrivate("", false, false, 1, now), now)
if err != nil {
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
results = append(results, item)
continue
}
if !joined {
item["status"] = "already_member"
results = append(results, item)
continue
}
item["status"] = "joined"
results = append(results, item)
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results})
}
func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
access := selfSub.ModeGiven & selfSub.ModeWant
switch {
case tail == "files" && req.Method == http.MethodGet:
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.GroupBusiness.ListFiles(principal.TenantID, topic,
strings.TrimSpace(req.URL.Query().Get("type")),
strings.TrimSpace(req.URL.Query().Get("q")),
strings.TrimSpace(req.URL.Query().Get("cursor")),
limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"items": groupFilePayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
case tail == "files" && req.Method == http.MethodPost:
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
var body imGroupFileCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
fileID := strings.TrimSpace(body.FileID)
if fileID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
file, err := store.GroupBusiness.CreateFile(principal.TenantID, &types.GroupFile{
Topic: topic,
FileID: fileID,
SourceTopic: strings.TrimSpace(body.SourceTopic),
SourceSeq: body.SourceSeq,
UploaderID: principal.Uid,
FileName: strings.TrimSpace(body.FileName),
MIMEType: strings.TrimSpace(body.MIMEType),
FileType: normalizeIMFileType(body.FileType),
FileSize: body.FileSize,
Status: "active",
IdempotencyKey: idempotencyKey,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
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
}
writeIMSuccess(wrt, req, groupFilePayload(file))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request) {
invitationID, tail, ok := groupInvitationResourcePath(req.URL.Path)
if !ok || tail != "accept" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group invitation route not found", nil)
return
}
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
now := time.Now().UTC().Round(time.Millisecond)
inv, err := store.GroupBusiness.AcceptInvitation(principal.TenantID, invitationID, principal.Uid, groupMemberPrivate("", false, false, 1, now), now)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupInvitationPayload(inv))
}
func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method == http.MethodPost {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
} else {
writeIMRelationError(wrt, req, err)
}
return
}
if settings == nil || settings.GroupStatus != types.GroupStatusActive {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if sub != nil && (sub.ModeGiven & sub.ModeWant).IsJoiner() {
writeIMError(wrt, req, http.StatusConflict, 30006, "ALREADY_GROUP_MEMBER", "already a group member", nil)
return
}
var body imGroupJoinRequestCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
now := time.Now().UTC().Round(time.Millisecond)
requestID := "gjr_" + store.Store.GetUidString()
item, err := store.GroupBusiness.CreateJoinRequest(principal.TenantID, &types.GroupJoinRequest{
RequestID: requestID, Topic: topic, ApplicantUserID: principal.Uid,
Source: defaultString(strings.TrimSpace(body.Source), "manual"), Message: strings.TrimSpace(body.Message),
Status: types.GroupJoinRequestStatusPending, IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
Version: 1, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupJoinRequestPayload(item))
return
}
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if req.Method == http.MethodGet {
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
status := strings.TrimSpace(req.URL.Query().Get("status"))
if status == "" {
status = "pending"
}
items, nextCursor, err := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, status, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
if err != nil {
if !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
items = nil
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupJoinRequestPayloads(items), "next_cursor": nextCursor})
}
}
func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
requestID, action, ok := groupJoinRequestActionPath(tail)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request route not found", nil)
return
}
var body imGroupJoinRequestActionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
now := time.Now().UTC().Round(time.Millisecond)
updated, err := store.GroupBusiness.ResolveJoinRequest(principal.TenantID, topic, requestID, principal.Uid,
action, "", body.Version, groupMemberPrivate("", false, false, 1, now), now)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupJoinRequestPayload(updated))
}
func serveIMGroupSummary(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
requests, _, _ := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, types.GroupJoinRequestStatusPending, "", 100)
announcement, _ := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"pending_join_request_count": len(requests),
"announcement": groupAnnouncementPayload(announcement),
"permissions": groupPermissions(selfSub.ModeGiven & selfSub.ModeWant),
})
}
func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
switch {
case tail == "announcements/latest" && req.Method == http.MethodGet:
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im group announcement latest: returning empty after store error", principal.TenantID, topic, err)
ann = nil
}
writeIMSuccess(wrt, req, groupAnnouncementPayload(ann))
case tail == "announcements/current" && req.Method == http.MethodPut:
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupAnnouncementPutRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
content := strings.TrimSpace(body.Content)
if content == "" || len([]rune(content)) > 2000 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
return
}
now := time.Now().UTC().Round(time.Millisecond)
announcement, err := store.GroupBusiness.PutAnnouncement(principal.TenantID, &types.GroupAnnouncement{
AnnouncementID: "ann_" + store.Store.GetUidString(),
Topic: topic,
AuthorUserID: principal.Uid,
Content: content,
Status: types.GroupAnnouncementStatusCurrent,
PublishedAt: now,
}, body.Version)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupAnnouncementPayload(announcement))
case strings.HasPrefix(tail, "announcements/") && strings.HasSuffix(tail, "/read") && req.Method == http.MethodPost:
announcementID := strings.TrimSuffix(strings.TrimPrefix(tail, "announcements/"), "/read")
now := time.Now().UTC().Round(time.Millisecond)
if err := store.GroupBusiness.ReadAnnouncement(principal.TenantID, topic, announcementID, principal.Uid, now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)})
case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet:
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im group announcement get: returning empty after store error", principal.TenantID, topic, err)
ann = nil
}
writeIMSuccess(wrt, req, groupAnnouncementPayload(ann))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
if settings != nil {
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
return
}
group, _ := store.Topics.Get(principal.TenantID, topic)
writeIMSuccess(wrt, req, groupGovernancePayload(group, selfSub))
case http.MethodPatch:
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupGovernancePatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
settings, err := store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{
JoinApproval: body.JoinApproval,
MuteAll: body.MuteAll,
MemberUploadFiles: body.MemberUploadFiles,
MemberPrivateChat: body.MemberPrivateChat,
MemberInvite: body.MemberInvite,
Version: body.Version,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupRoleLimits(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"max_admin_count": groupMaxAdminCount, "current_admin_count": countGroupAdmins(members)})
}
func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
switch {
case tail == "admins/batch" && req.Method == http.MethodPost:
var body imGroupAdminsBatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.UserIDs) == 0 || len(body.UserIDs) > groupMaxAdminCount {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if countGroupAdmins(members)+len(body.UserIDs) > groupMaxAdminCount {
writeIMError(wrt, req, http.StatusConflict, 30006, "GROUP_ADMIN_LIMIT_EXCEEDED", "group admin limit exceeded", map[string]any{"max": groupMaxAdminCount, "current": countGroupAdmins(members), "requested": len(body.UserIDs)})
return
}
results := make([]map[string]any, 0, len(body.UserIDs))
for _, rawID := range body.UserIDs {
uid := types.ParseUserId(strings.TrimSpace(rawID))
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
if uid.IsZero() || uid == principal.Uid {
item["ok"] = false
item["reason"] = imReasonMalformedRequest
results = append(results, item)
continue
}
item["user_id"] = uid.UserId()
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
types.GroupMemberRoleAdmin, types.ModeCAdmin|types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil {
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
item["ok"] = true
}
results = append(results, item)
}
members, err = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"succeeded": results, "admins": groupAdminItems(members), "version": body.Version + 1, "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
case strings.HasPrefix(tail, "admins/") && req.Method == http.MethodDelete:
userID := strings.TrimPrefix(tail, "admins/")
uid := types.ParseUserId(strings.TrimSpace(userID))
if uid.IsZero() || uid == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
types.GroupMemberRoleMember, types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "role": "member", "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupOwnerTransfer(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupOwnerTransferRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
target := types.ParseUserId(strings.TrimSpace(body.TargetUserID))
if target.IsZero() || target == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_user_id"})
return
}
targetSub, err := store.Subs.Get(principal.TenantID, topic, target, false)
if err != nil || targetSub == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
return
}
if err = store.GroupBusiness.TransferOwner(principal.TenantID, topic, principal.Uid, target,
body.Version, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId(), "version": body.Version + 1})
}
func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
switch {
case tail == "mutes" && req.Method == http.MethodGet:
items, nextCursor, err := store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
case tail == "mutes" && req.Method == http.MethodPost:
var body imGroupMuteBatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.Members) == 0 || len(body.Members) > 100 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "members"})
return
}
now := time.Now().UTC().Round(time.Millisecond)
mutes := make([]types.GroupMute, 0, len(body.Members))
for _, member := range body.Members {
uid := types.ParseUserId(strings.TrimSpace(member.UserID))
if uid.IsZero() {
continue
}
var expiresAt *time.Time
if raw := strings.TrimSpace(member.ExpiresAt); raw != "" {
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
expiresAt = &parsed
}
}
mute, err := store.GroupBusiness.UpsertMute(principal.TenantID, &types.GroupMute{Topic: topic, UserID: uid, MutedBy: principal.Uid, MutedAt: now, ExpiresAt: expiresAt, Reason: strings.TrimSpace(body.Reason), Status: types.GroupMuteStatusActive})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
mutes = append(mutes, *mute)
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete:
uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/")))
if uid.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
if err := store.GroupBusiness.DeleteMute(principal.TenantID, topic, uid, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "muted": false})
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
if types.GetTopicCat(topic) != types.TopicCatGrp || memberID.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
return
}
user, err := store.Users.Get(principal.TenantID, memberID)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx))
}
func serveIMMeQRCodes(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imQRCodeCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "add_friend"
}
if purpose != "add_friend" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
return
}
expiresIn := body.ExpiresInSeconds
if expiresIn <= 0 {
expiresIn = imQRCodeDefaultExpiresInSeconds
}
if expiresIn > imQRCodeMaxExpiresInSeconds {
expiresIn = imQRCodeMaxExpiresInSeconds
}
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
token, err := generateIMQRCodeToken(principal.TenantID, principal.Uid, purpose, expiresAt)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
user, err := store.Users.Get(principal.TenantID, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
data := map[string]any{
"qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose),
"expires_at": expiresAt.Format(time.RFC3339Nano),
}
if user != nil {
data["display_profile"] = userSummaryPayload(user, "")
}
writeIMSuccess(wrt, req, data)
}
func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imQRCodeResolveRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
claims, err := parseIMQRCodeToken(strings.TrimSpace(body.Token), strings.TrimSpace(body.Purpose), time.Now().UTC())
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "qr code invalid", nil)
return
}
if claims.TenantID != principal.TenantID {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
if claims.Purpose == "join_group" {
if types.GetTopicCat(claims.Topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"})
return
}
group, err := store.Topics.Get(principal.TenantID, claims.Topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if group == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
selfSub, _ := store.Subs.Get(principal.TenantID, claims.Topic, principal.Uid, false)
status := "can_request"
if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() {
status = "joined"
}
writeIMSuccess(wrt, req, map[string]any{
"purpose": claims.Purpose,
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
"group": groupDetailPayload(group, selfSub),
"status": status,
"source": "qr",
})
return
}
peer := types.ParseUserId(claims.UserID)
if peer.IsZero() || peer == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"})
return
}
user, err := store.Users.Get(principal.TenantID, peer)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if user == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, peer)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
canAdd := ctx.Relationship == types.RelationshipNone && imPrivacyAllowsAdd(privacy, "qr")
action := imRelationshipAction(ctx.Relationship)
if ctx.Relationship == types.RelationshipNone && !canAdd {
action = "none"
}
writeIMSuccess(wrt, req, map[string]any{
"purpose": claims.Purpose,
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
"user": userSearchItemPayload(types.UserSearchItem{User: user, Relationship: ctx.Relationship, RequestID: ctx.RequestID, CanAdd: canAdd, Action: action}),
"source": "qr",
"source_topic": "",
})
}
func serveIMUserSearch(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
searchType := strings.TrimSpace(req.URL.Query().Get("type"))
query := strings.TrimSpace(req.URL.Query().Get("q"))
if searchType == "phone" {
if phone, err := normalizeIMPhone(query, "CN"); err == nil {
query = phone
}
}
if query == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"})
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 50)
items, nextCursor, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, query, searchType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
payload = append(payload, userSearchItemPayload(item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) {
identifier, tail, ok := userResourcePath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
return
}
switch tail {
case "profile":
serveIMUserProfile(wrt, req, identifier)
case "qrcodes":
serveIMUserQRCodes(wrt, req, identifier)
default:
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
}
}
func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
userID, err := resolveIMUserIdentifier(principal, identifier)
if err != nil {
writeIMResolveUserError(wrt, req, err)
return
}
if userID.IsZero() || userID == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
user, err := store.Users.Get(principal.TenantID, userID)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if user == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil)
return
}
writeIMSuccess(wrt, req, userProfilePayload(user, ctx))
}
func serveIMUserQRCodes(wrt http.ResponseWriter, req *http.Request, identifier string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
userID, err := resolveIMUserIdentifier(principal, identifier)
if err != nil {
writeIMResolveUserError(wrt, req, err)
return
}
if userID.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
var body imQRCodeCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "add_friend"
}
if purpose != "add_friend" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
return
}
user, err := store.Users.Get(principal.TenantID, userID)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if user == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
return
}
if userID != principal.Uid {
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) || !imPrivacyAllowsAdd(privacy, "qr") {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
}
expiresIn := body.ExpiresInSeconds
if expiresIn <= 0 {
expiresIn = imQRCodeDefaultExpiresInSeconds
}
if expiresIn > imQRCodeMaxExpiresInSeconds {
expiresIn = imQRCodeMaxExpiresInSeconds
}
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
token, err := generateIMQRCodeToken(principal.TenantID, userID, purpose, expiresAt)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose),
"expires_at": expiresAt.Format(time.RFC3339Nano),
"display_profile": userSummaryPayload(user, ""),
})
}
func resolveIMUserIdentifier(principal *imPrincipal, identifier string) (types.Uid, error) {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return types.ZeroUid, types.ErrMalformed
}
if userID := types.ParseUserId(identifier); !userID.IsZero() {
return userID, nil
}
if store.Relations == nil {
return types.ZeroUid, types.ErrUnsupported
}
items, _, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, identifier, "exact_account", "", 2)
if err != nil {
return types.ZeroUid, err
}
for _, item := range items {
if item.User == nil {
continue
}
if userID := item.User.Uid(); !userID.IsZero() {
return userID, nil
}
}
return types.ZeroUid, types.ErrNotFound
}
func writeIMResolveUserError(wrt http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, types.ErrMalformed):
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
default:
writeIMRelationError(wrt, req, err)
}
}
func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
requestID, action := friendRequestActionFromPath(req.URL.Path)
status := ""
switch action {
case "accept":
status = types.FriendRequestAccepted
case "reject":
status = types.FriendRequestRejected
default:
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "friend request action not found", nil)
return
}
updated, err := store.Relations.ResolveFriendRequest(principal.TenantID, requestID, principal.Uid, status, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
eventType := "friend.request." + status
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, updated.ApplicantID,
updated.RequestID+":"+status, eventType, "好友申请已处理", status); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
updated.RequestID+":"+status+":self", eventType, "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, friendRequestPayload(updated))
}
func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) {
if strings.TrimSuffix(req.URL.Path, "/") == "/v1/im/blocks/batch-delete" {
serveIMBlocksBatchDelete(wrt, req)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
peer := userIDFromTail(req.URL.Path, "/v1/im/blocks/")
if peer.IsZero() || peer == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
switch req.Method {
case http.MethodPut:
if _, err := store.Relations.BlockUser(principal.TenantID, principal.Uid, peer, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.blocked", "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
case http.MethodDelete:
if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.unblocked", "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
}
func serveIMBlocks(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Relations.ListBlocks(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
payload = append(payload, blockListItemPayload(item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMBlocksBatchDelete(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imBlockBatchDeleteRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
return
}
results := make([]map[string]any, 0, len(body.UserIDs))
for _, rawID := range body.UserIDs {
peer := types.ParseUserId(strings.TrimSpace(rawID))
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
if peer.IsZero() || peer == principal.Uid {
item["ok"] = false
item["reason"] = imReasonMalformedRequest
results = append(results, item)
continue
}
item["user_id"] = peer.UserId()
if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil {
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
item["ok"] = true
}
results = append(results, item)
}
writeIMSuccess(wrt, req, map[string]any{"items": results})
}
func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
peer := userIDFromTail(req.URL.Path, "/v1/im/friends/")
if peer.IsZero() || peer == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
switch req.Method {
case http.MethodDelete:
if err := store.Relations.DeleteFriend(principal.TenantID, principal.Uid, peer, now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
stableID := stableIMEventID(req.Header.Get("Idempotency-Key"), principal.Uid.UserId(), peer.UserId())
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, peer, stableID,
"friend.deleted", "好友关系已解除", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableID+":self",
"friend.deleted", "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
case http.MethodPatch:
var body imFriendUpdateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if _, err := store.Relations.UpdateFriendRemark(principal.TenantID, principal.Uid, peer, strings.TrimSpace(body.Remark), now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "friend.remark.updated", "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
}
func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imIndependentPasswordRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "youth_mode"
}
if !validIMIndependentPassword(body.Password) || purpose != "youth_mode" {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", purpose, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := store.Security.UpsertIndependentPassword(principal.TenantID, &types.IndependentPassword{
TenantID: principal.TenantID,
UserID: principal.Uid,
Purpose: purpose,
PasswordHash: hash,
}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "purpose": purpose})
}
func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imIndependentPasswordVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "youth_mode"
}
action := strings.TrimSpace(body.Action)
if action == "" {
action = "close_youth_mode"
}
pass, err := store.Security.GetIndependentPassword(principal.TenantID, principal.Uid, purpose)
if err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if pass.LockedUntil != nil && now.Before(*pass.LockedUntil) {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
"retry_after_seconds": int(time.Until(*pass.LockedUntil).Seconds()) + 1,
})
return
}
if bcrypt.CompareHashAndPassword(pass.PasswordHash, []byte(body.Password)) != nil {
attempts := pass.FailedAttempts + 1
var lockedUntil *time.Time
if attempts >= imIndependentPasswordMaxAttempts {
until := now.Add(imIndependentPasswordLockSeconds * time.Second)
lockedUntil = &until
}
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, attempts, lockedUntil, now)
if lockedUntil != nil {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
"retry_after_seconds": imIndependentPasswordLockSeconds,
})
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, 0, nil, now)
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, action, purpose)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"action_token": token,
"action": action,
"expires_at": expiresAt.Format(time.RFC3339Nano),
})
}
func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, challengeID, code, scene string) (*types.VerificationCode, string, bool) {
if len(code) != imVerificationCodeLength {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return nil, "", false
}
vc, err := store.Verifications.Get(tenantID, challengeID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
return nil, "", false
}
logs.Warn.Println("im verification verify: lookup failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
now := time.Now().UTC().Round(time.Millisecond)
if vc.Scene != scene || vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return nil, "", false
}
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
_ = store.Verifications.Fail(tenantID, challengeID, types.VerificationStateExpired)
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return nil, "", false
}
if vc.CodeHash != hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenantID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return nil, "", false
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return nil, "", false
}
token, err := randomIMToken(32)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
if err := store.Verifications.MarkVerified(tenantID, challengeID, hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
logs.Warn.Println("im verification verify: mark verified failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
return vc, token, true
}
func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPrincipal, bool) {
principal, err := resolveIMHTTPBearer(req)
if err != nil {
if errors.Is(err, types.ErrUnsupported) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
} else {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
}
return nil, false
}
return principal, true
}
func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) {
authz := strings.TrimSpace(req.Header.Get("Authorization"))
token, ok := strings.CutPrefix(authz, "Bearer ")
if !ok || strings.TrimSpace(token) == "" || store.Store == nil {
return nil, types.ErrPermissionDenied
}
handler := store.Store.GetLogicalAuthHandler("token")
if handler == nil || !handler.IsInitialized() {
return nil, types.ErrUnsupported
}
secret := []byte(strings.TrimSpace(token))
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil {
secret = decoded
}
rec, _, err := handler.Authenticate(auth.AuthContext{}, secret)
if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
return nil, types.ErrPermissionDenied
}
return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, nil
}
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
user, err := store.Users.Get(tenantID, uid)
if err != nil {
return "", err
}
if user == nil || strings.TrimSpace(user.Phone) == "" {
return "", types.ErrNotFound
}
return strings.TrimSpace(user.Phone), nil
}
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
})
return
}
challengeID, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
code := "123456"
vc := &types.VerificationCode{
TenantID: principal.TenantID,
ChallengeID: challengeID,
Scene: "sensitive_action",
Action: action,
PhoneHash: phoneHash,
PhoneMask: maskIMPhone(phone),
TargetUser: principal.Uid,
CodeHash: hashIMSecret(principal.TenantID, "sensitive_action", challengeID, phoneHash, code),
State: types.VerificationStatePending,
MaxAttempts: imVerificationMaxAttempts,
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
}
if err := store.Verifications.Create(principal.TenantID, vc); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil {
logs.Warn.Println("im security verification delivery failed; keeping fixed test code active:", action, vc.PhoneMask, err)
}
logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
"expires_in_seconds": imVerificationExpiresInSeconds,
"resend_after_seconds": imVerificationResendAfterSeconds,
})
}
func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target string) (string, time.Time, error) {
if action == "" {
return "", time.Time{}, types.ErrMalformed
}
tokenID, err := randomIMToken(12)
if err != nil {
return "", time.Time{}, err
}
secret, err := randomIMToken(32)
if err != nil {
return "", time.Time{}, err
}
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(imActionTokenExpiresInSeconds * time.Second)
if err := store.Security.CreateActionToken(tenantID, &types.ActionToken{
TenantID: tenantID,
UserID: uid,
TokenID: tokenID,
Action: action,
Target: target,
TokenHash: hashIMActionToken(tenantID, uid, action, target, tokenID, secret),
State: 0,
ExpiresAt: expiresAt,
}); err != nil {
return "", time.Time{}, err
}
return tokenID + "." + secret, expiresAt, nil
}
func defaultIMActionTarget(action, fallback string) string {
switch action {
case "set_independent_password", "close_youth_mode", "reset_independent_password":
return "youth_mode"
default:
return fallback
}
}
func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string {
h := sha256.New()
h.Write([]byte(fmt.Sprintf("%d", tenantID)))
h.Write([]byte{0})
h.Write([]byte(fmt.Sprintf("%d", store.DecodeUid(uid))))
h.Write([]byte{0})
h.Write([]byte(action))
h.Write([]byte{0})
h.Write([]byte(target))
h.Write([]byte{0})
h.Write([]byte(tokenID))
h.Write([]byte{0})
h.Write([]byte(secret))
return fmt.Sprintf("%x", h.Sum(nil))
}
func realNamePayload(rv *types.RealNameVerification) map[string]any {
canSubmit := rv.Status == "unverified" || rv.Status == "rejected"
data := map[string]any{
"status": rv.Status,
"submission_id": rv.SubmissionID,
"masked_name": rv.MaskedName,
"masked_id_number": rv.MaskedIDNumber,
"can_submit": canSubmit,
}
if rv.VerifiedAt != nil {
data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano)
}
if rv.ReviewReason != nil {
data["review_reason"] = *rv.ReviewReason
}
return data
}
func realNameAppealPayload(appeal *types.RealNameAppeal) map[string]any {
if appeal == nil {
return map[string]any{}
}
attachments := appeal.Attachments
if len(attachments) == 0 && len(appeal.AttachmentsJSON) > 0 {
_ = json.Unmarshal(appeal.AttachmentsJSON, &attachments)
}
return map[string]any{
"appeal_id": appeal.AppealID,
"reason": appeal.Reason,
"attachments": attachments,
"status": appeal.Status,
"created_at": appeal.CreatedAt.Format(time.RFC3339Nano),
}
}
func groupInvitationPayload(inv *types.GroupInvitation) map[string]any {
if inv == nil {
return map[string]any{}
}
data := map[string]any{
"invitation_id": inv.InvitationID,
"topic": inv.Topic,
"inviter_id": inv.InviterUserID.UserId(),
"target_user_id": inv.TargetUserID.UserId(),
"status": inv.Status,
"result": inv.Result,
"expires_at": inv.ExpiresAt.Format(time.RFC3339Nano),
}
if inv.HandledAt != nil {
data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano)
}
return data
}
func groupFilePayloads(files []types.GroupFile) []map[string]any {
items := make([]map[string]any, 0, len(files))
for i := range files {
items = append(items, groupFilePayload(&files[i]))
}
return items
}
func groupFilePayload(file *types.GroupFile) map[string]any {
if file == nil {
return map[string]any{}
}
data := map[string]any{
"group_file_id": file.GroupFileID,
"topic": file.Topic,
"file_id": file.FileID,
"file_name": file.FileName,
"mime_type": file.MIMEType,
"file_type": file.FileType,
"file_size": file.FileSize,
"uploader_id": file.UploaderID.UserId(),
"status": file.Status,
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if file.SourceTopic != "" {
data["source_topic"] = file.SourceTopic
}
if file.SourceSeq > 0 {
data["source_seq"] = file.SourceSeq
}
if file.DeletedAt != nil {
data["deleted_at"] = file.DeletedAt.UTC().Format(time.RFC3339Nano)
data["deleted_by"] = file.DeletedBy.UserId()
}
return data
}
func normalizeIMFileType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "image", "video", "audio", "document", "file":
return strings.ToLower(strings.TrimSpace(raw))
default:
return "file"
}
}
func parseIMLimit(raw string, fallback, max int) int {
limit := fallback
if raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
limit = parsed
}
}
if limit > max {
limit = max
}
return limit
}
func parseIMTime(raw string) (time.Time, error) {
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw))
if err != nil {
return time.Time{}, err
}
return parsed.UTC().Round(time.Millisecond), nil
}
func parseIMDate(raw string) (time.Time, error) {
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.UTC)
if err != nil {
return time.Time{}, err
}
return parsed, nil
}
func validIMMonth(raw string) bool {
if _, err := time.ParseInLocation("2006-01", strings.TrimSpace(raw), time.UTC); err != nil {
return false
}
return true
}
func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) {
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if key == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return "", false
}
return key, true
}
func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []string, field string) ([]types.Uid, bool) {
uids := make([]types.Uid, 0, len(values))
seen := make(map[types.Uid]bool, len(values))
for _, value := range values {
uid := types.ParseUserId(strings.TrimSpace(value))
if uid.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field})
return nil, false
}
if seen[uid] {
continue
}
seen[uid] = true
uids = append(uids, uid)
}
return uids, true
}
func parseIMNonNegativeCursor(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
cursor, err := strconv.Atoi(raw)
if err != nil || cursor < 0 {
return 0, errors.New("invalid cursor")
}
return cursor, nil
}
func parseIMBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "y":
return true
default:
return false
}
}
func conversationMatches(sub types.Subscription, query string) bool {
if strings.Contains(strings.ToLower(sub.Topic), query) {
return true
}
encoded, _ := json.Marshal(sub.GetPublic())
return strings.Contains(strings.ToLower(string(encoded)), query)
}
func conversationPayload(sub types.Subscription) map[string]any {
unread := sub.GetSeqId() - sub.ReadSeqId
if unread < 0 {
unread = 0
}
return map[string]any{
"topic": sub.Topic,
"public": sub.GetPublic(),
"private": sub.Private,
"updated_at": sub.LastModified().UTC().Format(time.RFC3339Nano),
"seq": sub.GetSeqId(),
"read": sub.ReadSeqId,
"recv": sub.RecvSeqId,
"unread": unread,
"with": sub.GetWith(),
}
}
func conversationSettingsPayload(settings *types.ConversationSettings) map[string]any {
if settings == nil {
return map[string]any{}
}
return map[string]any{
"topic": settings.Topic,
"pinned": settings.Pinned,
"muted": settings.Muted,
"mute_notifications": settings.Muted,
"remark": settings.Remark,
"message_retention": settings.MessageRetention,
"version": settings.Version,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
"permissions": map[string]any{"can_update": true},
}
}
func topicDraftPayload(draft *types.TopicDraft) map[string]any {
if draft == nil {
return map[string]any{}
}
var entities any = []any{}
if len(draft.EntitiesJSON) > 0 {
_ = json.Unmarshal(draft.EntitiesJSON, &entities)
}
return map[string]any{
"topic": draft.Topic,
"content": draft.Content,
"entities": entities,
"version": draft.Version,
"updated_at": draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func forwardTargetPayload(sub types.Subscription) map[string]any {
topicType := ""
switch types.GetTopicCat(sub.Topic) {
case types.TopicCatGrp:
topicType = "group"
case types.TopicCatP2P:
topicType = "user"
default:
return nil
}
return map[string]any{
"topic": sub.Topic,
"type": topicType,
"display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic),
"avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()),
"can_receive_forward": true,
}
}
func forwardTargetMatches(topic, displayName, query string) bool {
if query == "" {
return true
}
return strings.Contains(strings.ToLower(topic), query) || strings.Contains(strings.ToLower(displayName), query)
}
func imDisplayNameFromPublic(public any, fallback string) string {
if value, ok := imPublicString(public, "fn"); ok {
return value
}
if value, ok := imPublicString(public, "name"); ok {
return value
}
return fallback
}
func imAvatarFileIDFromPublic(public any) string {
if value, ok := imPublicString(public, "avatar_file_id"); ok {
return value
}
if value, ok := imPublicString(public, "photo"); ok {
return value
}
return ""
}
func imPublicString(public any, key string) (string, bool) {
values, ok := public.(map[string]any)
if !ok {
return "", false
}
value, ok := values[key].(string)
if !ok {
return "", false
}
value = strings.TrimSpace(value)
return value, value != ""
}
func receiptMemberPayload(sub types.Subscription) map[string]any {
return map[string]any{
"user_id": sub.User,
"public": sub.GetPublic(),
"read": sub.ReadSeqId,
"recv": sub.RecvSeqId,
}
}
func groupListItemPayload(sub types.Subscription) map[string]any {
public := groupPublicMap(sub.GetPublic())
access := sub.ModeGiven & sub.ModeWant
return map[string]any{
"topic": sub.Topic,
"name": groupName(public, sub.Topic),
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
"member_count": sub.GetSubCnt(),
"my_role": groupMemberRole(access),
"updated_at": sub.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
public := map[string]any{}
topic := ""
memberCount := 0
ownerID := ""
if group != nil {
public = groupPublicMap(group.Public)
topic = group.Id
memberCount = group.SubCnt
if owner := types.ParseUid(group.Owner); !owner.IsZero() {
ownerID = owner.UserId()
}
}
version := intFromAny(public["version"])
if version <= 0 {
version = 1
}
access := types.ModeNone
if selfSub != nil {
access = selfSub.ModeGiven & selfSub.ModeWant
if topic == "" {
topic = selfSub.Topic
}
}
return map[string]any{
"topic": topic,
"name": groupName(public, topic),
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
"description": stringFromAny(public["description"]),
"owner": ownerID,
"member_count": memberCount,
"version": version,
"my_role": groupMemberRole(access),
"permissions": groupPermissions(access),
}
}
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
data := groupDetailPayload(group, selfSub)
private := map[string]any{}
if selfSub != nil {
private = groupPrivateMap(selfSub.Private)
}
data["pinned"] = boolFromAny(private["pinned"])
data["mute_notifications"] = boolFromAny(private["mute_notifications"])
data["message_retention"] = stringFromAny(private["message_retention"])
if data["message_retention"] == "" {
data["message_retention"] = "forever"
}
data["governance"] = defaultGroupGovernance()
return data
}
func groupMemberSettingsPayload(sub *types.Subscription) map[string]any {
private := map[string]any{}
if sub != nil {
private = groupPrivateMap(sub.Private)
}
return map[string]any{
"pinned": boolFromAny(private["pinned"]),
"mute_notifications": boolFromAny(private["mute_notifications"]),
"version": intFromAny(private["version"]),
"updated_at": stringFromAny(private["updated_at"]),
}
}
func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any {
access := sub.ModeGiven & sub.ModeWant
user := &types.User{ObjHeader: types.ObjHeader{Id: sub.User}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()}
data := userSummaryPayload(user, "")
if private := groupPrivateMap(sub.Private); stringFromAny(private["nickname"]) != "" {
data["group_nickname"] = stringFromAny(private["nickname"])
}
data["role"] = groupMemberRole(access)
data["mentionable"] = mentionable
data["acs"] = map[string]any{
"mode": access.String(),
"want": sub.ModeWant.String(),
"given": sub.ModeGiven.String(),
}
return data
}
func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
data := groupMemberPayload(*sub, true)
data["topic"] = topic
data["relationship"] = ctx.Relationship
data["capabilities"] = map[string]any{
"can_send": ctx.CanSend,
"can_call": ctx.CanSend,
"can_add_friend": ctx.Relationship == types.RelationshipNone,
}
if ctx.RequestID != "" {
data["request_id"] = ctx.RequestID
}
if user != nil {
for key, value := range userSummaryPayload(user, "") {
if key == "avatar_file_id" || key == "display_name" {
data[key] = value
}
}
if public, ok := user.Public.(map[string]any); ok {
copyOptionalProfileFields(data, public)
} else if public, ok := user.Public.(types.KVMap); ok {
copyOptionalProfileFields(data, map[string]any(public))
}
}
return data
}
func groupMemberRole(access types.AccessMode) string {
switch {
case access.IsOwner():
return "owner"
case access.IsAdmin():
return "admin"
default:
return "member"
}
}
func groupCategory(role string) string {
switch role {
case "owner":
return "owned"
case "admin":
return "managed"
default:
return "joined"
}
}
func groupPermissions(access types.AccessMode) map[string]any {
return map[string]any{
"can_edit_profile": access.IsAdmin(),
"can_invite_members": access.IsSharer() || access.IsAdmin(),
"can_manage_members": access.IsAdmin(),
"can_manage_settings": access.IsAdmin(),
"can_leave": !access.IsOwner() && access.IsJoiner(),
}
}
func defaultGroupGovernance() map[string]any {
return map[string]any{
"join_approval": false,
"mute_all": false,
"member_upload_files": true,
"member_private_chat": true,
"member_invite": true,
"version": 1,
}
}
func groupGovernanceMap(value any) map[string]any {
governance := defaultGroupGovernance()
for key, value := range groupPrivateMap(value) {
governance[key] = value
}
return governance
}
func groupGovernancePayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
aux := map[string]any{}
if group != nil {
aux = groupAuxMap(group.Aux)
}
governance := groupGovernanceMap(aux["governance"])
editable := false
if selfSub != nil {
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
}
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
governance[key+"_editable"] = editable
}
return governance
}
func groupGovernancePayloadFromSettings(settings *types.GroupSettings, selfSub *types.Subscription) map[string]any {
governance := defaultGroupGovernance()
if settings != nil {
governance["join_approval"] = settings.JoinApproval
governance["mute_all"] = settings.MuteAll
governance["member_upload_files"] = settings.MemberUploadFiles
governance["member_private_chat"] = settings.MemberPrivateChat
governance["member_invite"] = settings.MemberInvite
governance["version"] = settings.Version
}
editable := false
if selfSub != nil {
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
}
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
governance[key+"_editable"] = editable
}
return governance
}
func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
if payload == nil {
payload = map[string]any{}
}
if settings == nil {
return payload
}
payload["topic"] = settings.Topic
payload["name"] = settings.Name
payload["avatar_file_id"] = settings.AvatarFileID
payload["description"] = settings.Description
payload["owner"] = settings.OwnerUserID.UserId()
payload["member_count"] = settings.MemberCount
payload["version"] = settings.Version
return payload
}
func groupJoinRequestPayload(req *types.GroupJoinRequest) map[string]any {
if req == nil {
return map[string]any{}
}
data := map[string]any{
"request_id": req.RequestID,
"user_id": req.ApplicantUserID.UserId(),
"inviter_id": req.InviterUserID.UserId(),
"source": req.Source,
"message": req.Message,
"status": req.Status,
"version": req.Version,
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": req.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if req.HandledBy != types.ZeroUid {
data["handled_by"] = req.HandledBy.UserId()
}
if req.HandledAt != nil {
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
}
if req.RejectReason != "" {
data["reject_reason"] = req.RejectReason
}
return data
}
func groupJoinRequestPayloads(items []types.GroupJoinRequest) []map[string]any {
out := make([]map[string]any, 0, len(items))
for i := range items {
out = append(out, groupJoinRequestPayload(&items[i]))
}
return out
}
func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any {
if ann == nil {
return map[string]any{
"announcement_id": "",
"content": "",
"author": "",
"version": 0,
"published_at": "",
}
}
return map[string]any{
"announcement_id": ann.AnnouncementID,
"content": ann.Content,
"author": ann.AuthorUserID.UserId(),
"version": ann.Version,
"published_at": ann.PublishedAt.UTC().Format(time.RFC3339Nano),
}
}
func groupMutePayload(mute types.GroupMute) map[string]any {
data := map[string]any{
"user_id": mute.UserID.UserId(),
"muted_by": mute.MutedBy.UserId(),
"muted_at": mute.MutedAt.UTC().Format(time.RFC3339Nano),
"reason": mute.Reason,
"status": mute.Status,
"version": mute.Version,
}
if mute.ExpiresAt != nil {
data["expires_at"] = mute.ExpiresAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func groupMutePayloads(items []types.GroupMute) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, groupMutePayload(item))
}
return out
}
func groupAuxMap(value any) map[string]any {
switch aux := value.(type) {
case map[string]any:
return copyStringAnyMap(aux)
case types.KVMap:
return copyStringAnyMap(map[string]any(aux))
default:
return map[string]any{}
}
}
func groupPublicMap(value any) map[string]any {
switch public := value.(type) {
case map[string]any:
return copyStringAnyMap(public)
case types.KVMap:
return copyStringAnyMap(map[string]any(public))
default:
return map[string]any{}
}
}
func groupJoinRequests(group *types.Topic) []map[string]any {
if group == nil {
return nil
}
return sliceOfMaps(groupAuxMap(group.Aux)["join_requests"])
}
func filterGroupJoinRequests(items []map[string]any, status string) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if status == "" || stringFromAny(item["status"]) == status {
out = append(out, item)
}
}
return out
}
func resolveGroupJoinRequest(items []map[string]any, requestID, action string, actor types.Uid, version int) (map[string]any, bool) {
now := time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano)
for i := range items {
if stringFromAny(items[i]["request_id"]) != requestID {
continue
}
currentVersion := intFromAny(items[i]["version"])
if version > 0 && currentVersion > 0 && version != currentVersion {
items[i]["status"] = "conflict"
return items[i], true
}
switch action {
case "accept":
items[i]["status"] = "accepted"
case "reject":
items[i]["status"] = "rejected"
default:
return nil, false
}
items[i]["handled_by"] = actor.UserId()
items[i]["handled_at"] = now
items[i]["updated_at"] = now
items[i]["version"] = currentVersion + 1
return items[i], true
}
return nil, false
}
func latestGroupAnnouncement(group *types.Topic) map[string]any {
if group == nil {
return map[string]any{}
}
return groupPrivateMap(groupAuxMap(group.Aux)["announcement"])
}
func groupMuteItems(aux map[string]any) []map[string]any {
return sliceOfMaps(aux["mutes"])
}
func upsertGroupMute(items []map[string]any, item map[string]any) []map[string]any {
userID := stringFromAny(item["user_id"])
for i := range items {
if stringFromAny(items[i]["user_id"]) == userID {
items[i] = item
return items
}
}
return append(items, item)
}
func removeGroupMute(items []map[string]any, userID string) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if stringFromAny(item["user_id"]) != userID {
out = append(out, item)
}
}
return out
}
func sliceOfMaps(value any) []map[string]any {
raw, ok := value.([]any)
if !ok {
if typed, ok := value.([]map[string]any); ok {
return typed
}
return nil
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
out = append(out, copyStringAnyMap(m))
}
}
return out
}
func applyBoolPatch(target map[string]any, key string, value *bool) {
if value != nil {
target[key] = *value
}
}
func groupRoleEligibility(actorAccess, memberAccess types.AccessMode, role string) (bool, string) {
if !actorAccess.IsOwner() {
return false, "owner_required"
}
switch role {
case "admin":
if memberAccess.IsOwner() {
return false, "owner"
}
if memberAccess.IsAdmin() {
return false, "already_admin"
}
return true, ""
case "owner":
if memberAccess.IsOwner() {
return false, "current_owner"
}
return memberAccess.IsJoiner(), ""
default:
return false, "unsupported_role"
}
}
func groupMuteEligibility(actorAccess, memberAccess types.AccessMode) (bool, string) {
if !actorAccess.IsAdmin() {
return false, "admin_required"
}
if memberAccess.IsOwner() {
return false, "owner"
}
return memberAccess.IsJoiner(), ""
}
func countGroupAdmins(members []types.Subscription) int {
count := 0
for _, member := range members {
access := member.ModeGiven & member.ModeWant
if access.IsAdmin() && !access.IsOwner() {
count++
}
}
return count
}
func groupAdminItems(members []types.Subscription) []map[string]any {
items := make([]map[string]any, 0)
for _, member := range members {
access := member.ModeGiven & member.ModeWant
if access.IsAdmin() && !access.IsOwner() {
items = append(items, groupMemberPayload(member, true))
}
}
return items
}
func groupPrivateMap(value any) map[string]any {
switch private := value.(type) {
case map[string]any:
return copyStringAnyMap(private)
case types.KVMap:
return copyStringAnyMap(map[string]any(private))
default:
return map[string]any{}
}
}
func groupMemberPrivate(nickname string, pinned, mute bool, version int, updatedAt time.Time) map[string]any {
return map[string]any{
"nickname": nickname,
"pinned": pinned,
"mute_notifications": mute,
"version": version,
"profile_version": 1,
"updated_at": updatedAt.UTC().Format(time.RFC3339Nano),
}
}
func groupName(public map[string]any, fallback string) string {
for _, key := range []string{"name", "display_name", "fn"} {
value := strings.TrimSpace(fmt.Sprint(public[key]))
if value != "" && value != "<nil>" {
return value
}
}
return fallback
}
func groupListItemMatches(item map[string]any, query string) bool {
query = strings.ToLower(strings.TrimSpace(query))
return query == "" || strings.Contains(strings.ToLower(fmt.Sprint(item["topic"])), query) || strings.Contains(strings.ToLower(fmt.Sprint(item["name"])), query)
}
func groupMemberMatches(item map[string]any, query string) bool {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return true
}
for _, key := range []string{"display_name", "user_id"} {
if value, ok := item[key]; ok && strings.Contains(strings.ToLower(fmt.Sprint(value)), query) {
return true
}
}
return false
}
func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string,
limit, cursor int, mentionableOnly, muteEligible bool) string {
return strings.Join([]string{
topic,
uid.UserId(),
access.String(),
strings.ToLower(strings.TrimSpace(query)),
strings.TrimSpace(roleFilter),
strings.TrimSpace(eligibleRole),
strconv.Itoa(limit),
strconv.Itoa(cursor),
strconv.FormatBool(mentionableOnly),
strconv.FormatBool(muteEligible),
}, "|")
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, allowAdmin bool) (*types.Message, bool, error) {
if principal == nil {
return nil, false, types.ErrPermissionDenied
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
return nil, false, err
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
return nil, false, types.ErrPermissionDenied
}
msg, err := store.Messages.GetOne(principal.TenantID, topic, seq)
if err != nil {
return nil, false, err
}
if msg == nil {
return nil, false, types.ErrNotFound
}
if msg.RecalledAt != nil || msg.DeletedAt != nil {
return msg, false, types.ErrDuplicate
}
from := types.ParseUid(msg.From)
access := sub.ModeGiven & sub.ModeWant
authorized := from == principal.Uid || (allowAdmin && access.IsAdmin())
return msg, authorized, nil
}
func isIMEditableTextMessage(msg *types.Message) bool {
if msg == nil {
return false
}
if msg.Head == nil {
return true
}
msgType, _ := msg.Head["type"].(string)
return msgType == "" || msgType == "text"
}
func writeIMMessageMutationError(wrt http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found", nil)
case errors.Is(err, types.ErrDuplicate):
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
case errors.Is(err, types.ErrMalformed):
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
default:
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
}
func messageMutationPayload(msg *types.Message, status string) map[string]any {
data := map[string]any{
"status": status,
"topic": msg.Topic,
"seq": msg.SeqId,
"version": msg.Version,
"updated_at": msg.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if msg.RecalledAt != nil {
data["recalled_at"] = msg.RecalledAt.UTC().Format(time.RFC3339Nano)
data["recalled_by"] = msg.RecalledBy
}
if status == "edited" {
data["head"] = msg.Head
data["content"] = msg.Content
}
return data
}
func favoritePayload(item types.FavoriteItem) map[string]any {
return map[string]any{
"favorite_id": item.FavoriteID,
"type": item.ItemType,
"snapshot": item.Snapshot,
"source": map[string]any{"topic": item.SourceTopic, "seq": item.SourceSeq},
"collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano),
"availability": item.Availability,
}
}
func messageSearchPayload(msg types.Message, query string) map[string]any {
text := messageText(msg)
return map[string]any{
"seq": msg.SeqId,
"from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(&msg),
"snippet": textSnippet(text, query),
"matches": textMatches(text, query),
"snapshot": favoriteSnapshot(&msg),
"availability": messageAvailability(msg),
}
}
func messageContextPayload(msg types.Message) map[string]any {
return map[string]any{
"seq": msg.SeqId,
"from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(&msg),
"head": msg.Head,
"content": msg.Content,
"availability": messageAvailability(msg),
}
}
func mediaMessagePayload(msg types.Message) map[string]any {
snapshot := favoriteSnapshot(&msg)
data := map[string]any{
"seq": msg.SeqId,
"from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"availability": messageAvailability(msg),
}
if content, ok := snapshot["content"].(map[string]any); ok {
for key, value := range content {
data[key] = value
}
}
return data
}
func callRecordPayloads(records []types.CallRecord, viewer types.Uid) []map[string]any {
items := make([]map[string]any, 0, len(records))
for _, record := range records {
items = append(items, callRecordPayload(record, viewer))
}
return items
}
func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any {
peer := record.CalleeID
direction := "outgoing"
if record.CalleeID == viewer {
peer = record.CallerID
direction = "incoming"
}
data := map[string]any{
"call_id": record.CallID,
"topic": record.Topic,
"seq": record.SeqID,
"caller_id": record.CallerID.UserId(),
"callee_id": record.CalleeID.UserId(),
"peer_user_id": peer.UserId(),
"direction": direction,
"call_type": record.CallType,
"status": record.Status,
"result": record.Status,
"duration_ms": record.DurationMS,
"started_at": record.StartedAt.UTC().Format(time.RFC3339Nano),
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if record.EndReason != "" {
data["end_reason"] = record.EndReason
}
if record.AcceptedAt != nil {
data["accepted_at"] = record.AcceptedAt.UTC().Format(time.RFC3339Nano)
}
if record.FinishedAt != nil {
data["finished_at"] = record.FinishedAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any {
items := make([]map[string]any, 0, len(modules))
for _, module := range modules {
if !isAllowedIMWorkbenchRoute(module.Route) {
continue
}
items = append(items, map[string]any{
"code": module.Code,
"name": module.Name,
"icon_file_id": module.IconFileID,
"route": module.Route,
"unread_count": module.UnreadCount,
"enabled": module.Enabled,
"permission": module.Permission,
})
}
return items
}
func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid, modules []map[string]any) (map[string]any, map[string]any, error) {
newFriendsPending := 0
if store.Relations != nil {
count, err := store.Relations.CountPendingFriendRequests(tenantID, uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
return nil, nil, err
}
if err == nil {
newFriendsPending = count
}
}
systemUnread := 0
if store.Security != nil {
count, err := store.Security.CountUnreadSystemNotifications(tenantID, uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
return nil, nil, err
}
if err == nil {
systemUnread = count
}
}
workbenchTotalUnread := 0
for _, module := range modules {
enabled, _ := module["enabled"].(bool)
if !enabled {
continue
}
workbenchTotalUnread += intFromAny(module["unread_count"])
}
entryBadges := map[string]any{
"contacts_total_unread": newFriendsPending,
"new_friends_pending_count": newFriendsPending,
"groups_unread_count": 0,
"file_assistant_unread_count": 0,
"system_unread_count": systemUnread,
"workbench_total_unread": workbenchTotalUnread,
}
entries := map[string]any{
"new_friends": map[string]any{
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": newFriendsPending,
"unread_source": "business",
},
"groups": map[string]any{
"enabled": true,
"route": "/contacts/groups",
"unread_count": 0,
"unread_source": "business",
},
"file_assistant": map[string]any{
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic",
},
"system_messages": map[string]any{
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": systemUnread,
"unread_source": "business",
},
}
return entryBadges, entries, nil
}
func organizationDepartmentPayloads(departments []types.OrganizationDepartment) []map[string]any {
items := make([]map[string]any, 0, len(departments))
for _, department := range departments {
items = append(items, organizationDepartmentPayload(department))
}
return items
}
func organizationDepartmentPayload(department types.OrganizationDepartment) map[string]any {
return map[string]any{
"department_id": department.DepartmentID,
"name": department.Name,
"parent_id": department.ParentID,
"child_count": department.ChildCount,
"member_count": department.MemberCount,
"version": department.Version,
}
}
func organizationMemberPayloads(members []types.OrganizationMember) []map[string]any {
items := make([]map[string]any, 0, len(members))
for _, member := range members {
items = append(items, organizationMemberPayload(member))
}
return items
}
func organizationMemberPayload(member types.OrganizationMember) map[string]any {
payload := map[string]any{
"user_id": member.UserID.UserId(),
"department_id": member.DepartmentID,
"display_name": member.DisplayName,
"position": member.Position,
"avatar_file_id": member.AvatarFileID,
"status": member.Status,
"contactable": member.Contactable,
"version": member.Version,
}
if member.Mobile != "" {
payload["mobile"] = member.Mobile
}
if member.Email != "" {
payload["email"] = member.Email
}
return payload
}
func organizationSearchPayloads(items []types.OrganizationSearchItem) []map[string]any {
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
switch item.Type {
case "department":
if item.Department != nil {
payload = append(payload, map[string]any{
"type": "department",
"department": organizationDepartmentPayload(*item.Department),
})
}
case "user":
if item.Member != nil {
payload = append(payload, map[string]any{
"type": "user",
"user": organizationMemberPayload(*item.Member),
})
}
}
}
return payload
}
func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
participants := meetingParticipantPayloads(meeting.Participants)
data := map[string]any{
"meeting_id": meeting.MeetingID,
"title": meeting.Title,
"organizer_id": meeting.OrganizerID.UserId(),
"start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano),
"end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano),
"location": meeting.Location,
"state": meeting.State,
"reminder_minutes": meeting.ReminderMinutes,
"version": meeting.Version,
"participants": participants,
"permissions": meetingPermissionsPayload(meeting, viewer),
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if meeting.CanceledAt != nil {
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
}
for _, participant := range meeting.Participants {
if participant.UserID == viewer {
data["my_response"] = participant.Response
break
}
}
return data
}
func meetingParticipantPayloads(participants []types.MeetingParticipant) []map[string]any {
items := make([]map[string]any, 0, len(participants))
for _, participant := range participants {
item := map[string]any{
"user_id": participant.UserID.UserId(),
"response": participant.Response,
}
if participant.ResponseAt != nil {
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
}
items = append(items, item)
}
return items
}
func 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": monthlyReportPreview(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": monthlyReportPreview(report.WorkSummary),
"next_plan_short": monthlyReportPreview(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 monthlyReportPreview(text string) string {
text = strings.Join(strings.Fields(text), " ")
runes := []rune(text)
if len(runes) <= monthlyReportPreviewMaxRunes {
return text
}
return string(runes[:monthlyReportPreviewMaxRunes]) + "..."
}
func monthlyReportDetailPayload(report *types.MonthlyReport, viewer types.Uid) map[string]any {
data := monthlyReportListPayload(*report)
data["work_summary"] = report.WorkSummary
data["next_plan"] = report.NextPlan
data["attachments"] = report.Attachments
data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano)
data["permissions"] = map[string]any{
"can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft,
"can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft,
"can_view": report.AuthorID == viewer,
}
data["validation"] = map[string]any{
"work_summary_required": true,
"next_plan_required": true,
"max_length": 10000,
}
return data
}
func mailboxConfigPayload(config *types.MailboxConfig) map[string]any {
return map[string]any{
"enabled": config.Enabled,
"allow_anonymous": config.AllowAnonymous,
"required_fields": config.RequiredFields,
"max_length": config.MaxLength,
"attachment_policy": config.AttachmentPolicy,
"notice": map[string]any{
"title": config.Notice.Title,
"content": config.Notice.Content,
"updated_at": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano),
"version": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano),
},
}
}
func defaultIMMailboxConfig() *types.MailboxConfig {
now := time.Now().UTC().Round(time.Millisecond)
return &types.MailboxConfig{
Enabled: false,
AllowAnonymous: false,
RequiredFields: []string{"subject", "content"},
MaxLength: 1000,
AttachmentPolicy: map[string]any{
"max_count": 0,
"enabled": false,
},
Notice: types.MailboxNotice{
Title: "总经理信箱",
Content: "当前暂未开放。",
UpdatedAt: now,
},
UpdatedAt: now,
}
}
func mailboxSubmissionResultPayload(submission *types.MailboxSubmission) map[string]any {
return map[string]any{
"submission_id": submission.SubmissionID,
"status": submission.Status,
"submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano),
}
}
func userProfilePayload10(profile *types.UserProfile) map[string]any {
data := map[string]any{
"avatar_file_id": profile.AvatarFileID,
"nickname": profile.Nickname,
"birth_date": profile.BirthDate,
"signature": profile.Signature,
"gender": profile.Gender,
"region": profile.Region,
"email": profile.Email,
"public_account": profile.PublicAccount,
"version": profile.Version,
"updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano),
"field_rules": map[string]any{
"nickname": map[string]any{"min_length": 1, "max_length": 32},
"signature": map[string]any{"max_length": 120},
"birth_date": map[string]any{"format": "YYYY-MM-DD"},
"gender": []string{"male", "female", "other", ""},
"email": map[string]any{"max_length": 128},
},
}
return data
}
func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any {
return map[string]any{
"add_me_methods": settings.AddMeMethods,
"profile_visibility": settings.ProfileVisibility,
"last_seen_visibility": settings.LastSeenVisibility,
"read_receipts": settings.ReadReceipts,
"version": settings.Version,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
"editable": map[string]any{
"add_me_methods": true,
"profile_visibility": true,
"last_seen_visibility": true,
"read_receipts": true,
},
}
}
func imPrivacyAllowsAdd(settings *types.UserPrivacySettings, source string) bool {
if settings == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(source)) {
case "phone", "qr", "group":
source = strings.ToLower(strings.TrimSpace(source))
default:
source = "account"
}
allowed, exists := settings.AddMeMethods[source]
return !exists || allowed
}
func imPrivacyAllowsProfile(settings *types.UserPrivacySettings, relationship string) bool {
if settings == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(settings.ProfileVisibility)) {
case "everyone":
return true
case "friends", "":
return relationship == types.RelationshipFriend
default:
return false
}
}
func imCanViewLastSeen(tenantID types.TenantID, viewer, target types.Uid) bool {
if viewer.IsZero() || target.IsZero() {
return false
}
if viewer == target {
return true
}
// Legacy deployments without the product privacy stores retain Tinode's
// original presence behavior.
if store.Profiles == nil || store.Relations == nil {
return true
}
settings, err := store.Profiles.GetPrivacy(tenantID, target)
if err != nil || settings == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(settings.LastSeenVisibility)) {
case "everyone":
return true
case "friends", "":
ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, target, viewer.P2PName(target))
return err == nil && ctx != nil && ctx.Relationship == types.RelationshipFriend
default:
return false
}
}
func appReleasePayload(release *types.AppRelease) map[string]any {
return map[string]any{
"platform": release.Platform,
"channel": release.Channel,
"latest_version": release.LatestVersion,
"min_supported_version": release.MinSupportedVersion,
"update_type": release.UpdateType,
"release_notes": release.ReleaseNotes,
"store_url": release.StoreURL,
"package_url": release.PackageURL,
"checksum": release.Checksum,
"remind_after_seconds": release.RemindAfterSeconds,
"published_at": release.PublishedAt.UTC().Format(time.RFC3339Nano),
}
}
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
data := map[string]any{
"enabled": settings.Enabled,
"policy_version": settings.PolicyVersion,
"restrictions": settings.Restrictions,
"password_set": settings.PasswordSet,
"can_enable": settings.CanEnable,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if settings.EnabledAt != nil {
data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
return map[string]any{
"device_id": token.DeviceID,
"provider": token.Provider,
"app_version": token.AppVersion,
"platform": token.Platform,
"updated_at": token.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func riskChallengePayload(challenge *types.RiskChallenge) map[string]any {
return map[string]any{
"risk_challenge_id": challenge.ChallengeID,
"action": challenge.Action,
"level": challenge.Level,
"notice_code": challenge.NoticeCode,
"notice_params": challenge.NoticeParams,
"state": challenge.State,
"decision": challenge.Decision,
"expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339Nano),
"updated_at": challenge.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func abuseReasonPayload(reason types.AbuseReason) map[string]any {
return map[string]any{
"target_type": reason.TargetType,
"reason_code": reason.ReasonCode,
"label": reason.Label,
"requires_description": reason.RequiresDescription,
"allows_evidence": reason.AllowsEvidence,
}
}
func defaultIMAbuseReasons(targetType string) []types.AbuseReason {
targetType = strings.ToLower(strings.TrimSpace(targetType))
if targetType == "" {
targetType = "message"
}
return []types.AbuseReason{
{TargetType: targetType, ReasonCode: "spam", Label: "垃圾广告", RequiresDescription: false, AllowsEvidence: true, SortOrder: 10},
{TargetType: targetType, ReasonCode: "fraud", Label: "欺诈风险", RequiresDescription: false, AllowsEvidence: true, SortOrder: 20},
{TargetType: targetType, ReasonCode: "harassment", Label: "骚扰辱骂", RequiresDescription: false, AllowsEvidence: true, SortOrder: 30},
{TargetType: targetType, ReasonCode: "other", Label: "其他", RequiresDescription: true, AllowsEvidence: true, SortOrder: 99},
}
}
func abuseReportPayload(report *types.AbuseReport) map[string]any {
return map[string]any{
"report_id": report.ReportID,
"status": report.Status,
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
}
}
func feedbackConfigPayload(config *types.FeedbackConfig) map[string]any {
return map[string]any{
"categories": config.Categories,
"max_length": config.MaxLength,
"attachment_policy": config.AttachmentPolicy,
"allow_contact": config.AllowContact,
"diagnostics_notice": config.DiagnosticsNotice,
"updated_at": config.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]any {
return map[string]any{
"feedback_id": feedback.FeedbackID,
"status": feedback.Status,
"created_at": feedback.CreatedAt.UTC().Format(time.RFC3339Nano),
}
}
func publicUploadPayload(upload *types.PublicUpload) map[string]any {
return map[string]any{
"file_id": upload.FileID,
"purpose": upload.Purpose,
"mime_type": upload.MimeType,
"size": upload.Size,
"expires_at": upload.ExpiresAt.UTC().Format(time.RFC3339Nano),
}
}
func tenantApplicationPayload(application *types.TenantApplication) map[string]any {
return map[string]any{
"application_id": application.ApplicationID,
"status": application.Status,
"estimated_review_hours": application.EstimatedReviewHour,
"submitted_at": application.SubmittedAt.UTC().Format(time.RFC3339Nano),
}
}
func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]any {
return map[string]any{
"feedback_id": feedback.FeedbackID,
"status": feedback.Status,
"submitted_at": feedback.SubmittedAt.UTC().Format(time.RFC3339Nano),
}
}
func isAllowedIMWorkbenchRoute(route string) bool {
switch route {
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
return true
default:
return false
}
}
func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any {
data := map[string]any{
"topic": ctx.Topic,
"peer_user_id": ctx.PeerID.UserId(),
"relationship": ctx.Relationship,
"can_send": ctx.CanSend,
"capabilities": map[string]any{
"can_send_text": ctx.CanSend,
"can_send_media": ctx.CanSend,
"can_send_card": ctx.CanSend,
"can_call": ctx.CanSend,
"can_add_friend": ctx.Relationship == types.RelationshipNone,
"can_handle_request": ctx.Relationship == types.RelationshipPendingInbound,
},
}
if ctx.RequestID != "" {
data["request_id"] = ctx.RequestID
}
if ctx.Peer != nil {
data["peer"] = map[string]any{
"user_id": ctx.PeerID.UserId(),
"public": ctx.Peer.Public,
"trusted": ctx.Peer.Trusted,
}
}
return data
}
func friendRequestPayload(req *types.FriendRequest) map[string]any {
data := map[string]any{
"request_id": req.RequestID,
"applicant_id": req.ApplicantID.UserId(),
"target_user_id": req.TargetID.UserId(),
"message": req.Message,
"source": req.Source,
"status": req.Status,
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339Nano),
}
if topic := req.ApplicantID.P2PName(req.TargetID); topic != "" {
data["topic"] = topic
}
if req.SourceTopic != "" {
data["source_topic"] = req.SourceTopic
}
if req.HandledAt != nil {
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]any {
data := friendRequestPayload(&item.Request)
data["direction"] = item.Direction
if item.Peer != nil {
data["peer"] = userSummaryPayload(item.Peer, "")
}
return data
}
func friendListItemPayload(item types.FriendListItem) map[string]any {
data := map[string]any{
"user_id": item.Friendship.PeerID.UserId(),
"relationship": item.Friendship.Status,
"remark": item.Friendship.Remark,
"topic": item.Friendship.Topic,
"online_visibility": "unknown",
"created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano),
"capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": item.Friendship.Status == types.RelationshipFriend},
"display_name_source": "profile",
}
if item.Friendship.Remark != "" {
data["display_name"] = item.Friendship.Remark
data["display_name_source"] = "remark"
}
if item.Peer != nil {
for key, value := range userSummaryPayload(item.Peer, item.Friendship.Remark) {
_, exists := data[key]
if !exists || key == "avatar_file_id" || (key == "display_name" && item.Friendship.Remark == "") {
data[key] = value
}
}
}
data["initial"] = displayInitial(data["display_name"])
return data
}
func userSearchItemPayload(item types.UserSearchItem) map[string]any {
data := userSummaryPayload(item.User, "")
if public, ok := item.User.Public.(map[string]any); ok {
copyOptionalProfileFields(data, public)
} else if public, ok := item.User.Public.(types.KVMap); ok {
copyOptionalProfileFields(data, map[string]any(public))
}
data["relationship"] = item.Relationship
data["can_add"] = item.CanAdd
data["action"] = item.Action
if item.RequestID != "" {
data["request_id"] = item.RequestID
}
return data
}
func imRelationshipAction(relationship string) string {
switch relationship {
case types.RelationshipFriend:
return "chat"
case types.RelationshipPendingInbound, types.RelationshipPendingOutbound:
return "pending"
case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer:
return "none"
default:
return "add"
}
}
func blockListItemPayload(item types.BlockListItem) map[string]any {
data := map[string]any{
"user_id": item.Block.BlockedID.UserId(),
"blocked_at": item.Block.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": item.Block.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if item.User != nil {
data["user"] = userSummaryPayload(item.User, "")
}
return data
}
func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
data := userSummaryPayload(user, "")
data["relationship"] = ctx.Relationship
data["topic"] = ctx.Topic
data["capabilities"] = map[string]any{
"can_send": ctx.CanSend,
"can_call": ctx.CanSend,
"can_add_friend": ctx.Relationship == types.RelationshipNone,
}
if ctx.RequestID != "" {
data["request_id"] = ctx.RequestID
}
if public, ok := user.Public.(map[string]any); ok {
copyOptionalProfileFields(data, public)
} else if public, ok := user.Public.(types.KVMap); ok {
copyOptionalProfileFields(data, map[string]any(public))
}
return data
}
func copyOptionalProfileFields(data map[string]any, public map[string]any) {
for _, key := range []string{"gender", "region", "public_account", "signature"} {
if value, ok := public[key]; ok {
data[key] = value
}
}
}
func userSummaryPayload(user *types.User, remark string) map[string]any {
uid := types.ParseUid(user.Id)
userID := uid.UserId()
if uid.IsZero() {
userID = user.Id
}
data := map[string]any{"user_id": userID}
if remark != "" {
data["display_name"] = remark
}
if public, ok := user.Public.(map[string]any); ok {
copyUserPublicSummary(data, public)
} else if public, ok := user.Public.(types.KVMap); ok {
copyUserPublicSummary(data, map[string]any(public))
}
if _, ok := data["display_name"]; !ok {
data["display_name"] = userID
}
return data
}
func copyUserPublicSummary(data map[string]any, public map[string]any) {
for _, key := range []string{"display_name", "fn", "name", "nickname"} {
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
data["display_name"] = value
break
}
}
for _, key := range []string{"avatar_file_id", "photo", "avatar"} {
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
data["avatar_file_id"] = value
break
}
}
}
func displayInitial(value any) string {
name := strings.TrimSpace(fmt.Sprint(value))
if name == "" {
return "#"
}
for _, r := range name {
return strings.ToUpper(string(r))
}
return "#"
}
func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonMalformedRequest, "resource not found", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 20005, imReasonPermissionDenied, "permission denied", nil)
case errors.Is(err, types.ErrInvalidResponse):
writeIMError(wrt, req, http.StatusConflict, 20009, "VERSION_CONFLICT", "version conflict", nil)
case errors.Is(err, types.ErrMalformed):
writeIMError(wrt, req, http.StatusBadRequest, 20000, imReasonMalformedRequest, "malformed request", nil)
default:
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
}
func relationErrorReason(err error) string {
switch {
case errors.Is(err, types.ErrNotFound):
return "NOT_FOUND"
case errors.Is(err, types.ErrPermissionDenied):
return imReasonPermissionDenied
case errors.Is(err, types.ErrMalformed):
return imReasonMalformedRequest
default:
return imReasonInternal
}
}
func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, needRead bool) (*imPrincipal, *types.Subscription, bool) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return nil, nil, false
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return nil, nil, false
}
dispatchIMGroupEvents(principal.TenantID)
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, nil, false
}
access := types.ModeNone
if sub != nil {
access = sub.ModeGiven & sub.ModeWant
}
if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
return nil, nil, false
}
return principal, sub, true
}
func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return nil, "", false
}
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return nil, "", false
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return nil, "", false
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return nil, "", false
}
return principal, topic, true
}
func requireTopicWriter(principal *imPrincipal, topic string) error {
if principal == nil || topic == "" {
return types.ErrMalformed
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
return err
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsWriter() {
return types.ErrPermissionDenied
}
return nil
}
func 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 != "<nil>" {
return value
}
}
if content, ok := msg.Content.(map[string]any); ok {
if fileID := stringFromAny(content["file_id"]); fileID != "" {
mime := stringFromAny(content["mime"])
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "video/"):
return "video"
case strings.HasPrefix(mime, "audio/"):
return "voice"
default:
return "file"
}
}
if stringFromAny(content["url"]) != "" {
return "link"
}
}
return "text"
}
func favoriteSnapshot(msg *types.Message) map[string]any {
if msg == nil {
return map[string]any{}
}
return map[string]any{
"topic": msg.Topic,
"seq": msg.SeqId,
"from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(msg),
"head": msg.Head,
"content": msg.Content,
"text": messageText(*msg),
}
}
func favoriteAttachmentRefs(msg *types.Message) any {
if msg == nil {
return []string{}
}
if content, ok := msg.Content.(map[string]any); ok {
if fileID := stringFromAny(content["file_id"]); fileID != "" {
return []string{fileID}
}
}
return []string{}
}
func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string]any {
payload := map[string]any{
"favorite_id": item.FavoriteID,
"source": map[string]any{
"topic": item.SourceTopic,
"seq": item.SourceSeq,
},
"snapshot": item.Snapshot,
"forwarded": true,
}
if refs, ok := item.AttachmentRefs.([]string); ok && len(refs) > 0 {
payload["attachment_refs"] = refs
} else if item.AttachmentRefs != nil {
payload["attachment_refs"] = item.AttachmentRefs
}
if comment != "" {
payload["comment"] = comment
}
return payload
}
func classifyIMURLRisk(rawURL string) (decision, riskLevel, reason string) {
if rawURL == "" || len(rawURL) > 2048 {
return "block", "high", "invalid_url"
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "block", "high", "invalid_url"
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return "block", "high", "unsupported_scheme"
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
return "block", "high", "private_host"
}
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return "block", "high", "private_host"
}
}
normalized := strings.ToLower(rawURL)
for _, marker := range []string{"javascript:", "data:", "file:", "password=", "passwd=", "token=", "access_token=", "xn--"} {
if strings.Contains(normalized, marker) {
return "warn", "medium", "suspicious_url"
}
}
return "allow", "low", "ok"
}
func shortTokenHash(value string) string {
sum := sha256.Sum256([]byte(value))
return base64.RawURLEncoding.EncodeToString(sum[:])[:16]
}
func messageText(msg types.Message) string {
switch content := msg.Content.(type) {
case string:
return content
case map[string]any:
for _, key := range []string{"text", "content", "title", "summary", "name", "url"} {
if value := stringFromAny(content[key]); value != "" {
return value
}
}
}
encoded, _ := json.Marshal(msg.Content)
return string(encoded)
}
func messageAvailability(msg types.Message) string {
if msg.DeletedAt != nil || msg.RecalledAt != nil {
return "removed"
}
return "available"
}
func textSnippet(text, query string) string {
text = strings.TrimSpace(text)
if len([]rune(text)) <= 120 || strings.TrimSpace(query) == "" {
return text
}
lower := strings.ToLower(text)
idx := strings.Index(lower, strings.ToLower(strings.TrimSpace(query)))
if idx < 0 {
idx = 0
}
start := idx - 40
if start < 0 {
start = 0
}
end := start + 120
runes := []rune(text)
if end > len(runes) {
end = len(runes)
}
return string(runes[start:end])
}
func textMatches(text, query string) []map[string]any {
query = strings.TrimSpace(query)
if query == "" {
return []map[string]any{}
}
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
if idx < 0 {
return []map[string]any{}
}
return []map[string]any{{"start": idx, "end": idx + len(query)}}
}
func validIMRealName(name, idType, idNumber string) bool {
if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" {
return false
}
return regexp.MustCompile(`^[0-9]{17}[0-9X]$`).MatchString(idNumber)
}
func protectIMSensitive(tenantID types.TenantID, value string) string {
return base64.RawStdEncoding.EncodeToString([]byte(hashIMSecret(tenantID, value, "sensitive")))
}
func maskIMName(name string) string {
runes := []rune(name)
if len(runes) <= 1 {
return name
}
return string(runes[0]) + "*"
}
func maskIMIDNumber(id string) string {
if len(id) <= 8 {
return id
}
return id[:3] + strings.Repeat("*", len(id)-7) + id[len(id)-4:]
}
func validIMIndependentPassword(password string) bool {
if !regexp.MustCompile(`^[0-9]{4}$`).MatchString(password) {
return false
}
if password[0] == password[1] && password[1] == password[2] && password[2] == password[3] {
return false
}
ascending, descending := true, true
for i := 1; i < len(password); i++ {
ascending = ascending && password[i] == password[i-1]+1
descending = descending && password[i]+1 == password[i-1]
}
return !ascending && !descending
}
func decodeIMJSONBody(wrt http.ResponseWriter, req *http.Request, dst any) bool {
if req.Body == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
var extra any
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
} else if err == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
return true
}
func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCode string) (*types.Tenant, bool) {
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
if tenantCode == "" {
tenantCode = strings.TrimSpace(fallbackCode)
}
if tenantCode == "" || len(tenantCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if store.Tenants == nil {
logs.Err.Println("im http: tenant store is not initialized")
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
logs.Warn.Println("im http: failed to resolve tenant", tenantCode, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
return tenant, true
}
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
Code: 0,
Data: data,
RequestID: req.Header.Get("X-Request-ID"),
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
})
}
func writeIMError(wrt http.ResponseWriter, req *http.Request, status, code int, reason, message string, details any) {
writeIMJSON(wrt, status, imHTTPResponse{
Code: code,
Reason: reason,
Message: message,
Details: details,
RequestID: req.Header.Get("X-Request-ID"),
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
})
}
func writeIMJSON(wrt http.ResponseWriter, status int, resp imHTTPResponse) {
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
wrt.WriteHeader(status)
if err := json.NewEncoder(wrt).Encode(resp); err != nil {
logs.Warn.Println("im http: failed to write response", err)
}
}
func isAllowedVerificationScene(scene string) bool {
switch scene {
case "register", "login", "reset_password", "sensitive_action":
return true
default:
return false
}
}
func normalizeIMPhone(phone, country string) (string, error) {
if strings.TrimSpace(country) == "" {
country = "CN"
}
number, err := phonenumbers.Parse(strings.TrimSpace(phone), country)
if err != nil || !phonenumbers.IsValidNumber(number) {
return "", types.ErrMalformed
}
return phonenumbers.Format(number, phonenumbers.E164), nil
}
func maskIMPhone(phone string) string {
if len(phone) <= 7 {
return phone
}
return phone[:4] + "****" + phone[len(phone)-3:]
}
func randomIMNumericCode(length int) (string, error) {
max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
code := fmt.Sprintf("%0*d", length, n.Int64())
return code, nil
}
func randomIMToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashIMSecret(tenantID types.TenantID, parts ...string) string {
h := sha256.New()
h.Write(globals.apiKeySalt)
h.Write([]byte(fmt.Sprintf(":%d:", tenantID)))
for _, part := range parts {
h.Write([]byte{0})
h.Write([]byte(part))
}
return fmt.Sprintf("%x", h.Sum(nil))
}
func generateIMQRCodeToken(tenantID types.TenantID, uid types.Uid, purpose string, expiresAt time.Time) (string, error) {
return generateIMQRCodeTokenForTopic(tenantID, uid, "", purpose, expiresAt)
}
func generateIMQRCodeTokenForTopic(tenantID types.TenantID, uid types.Uid, topic, purpose string, expiresAt time.Time) (string, error) {
nonce, err := randomIMToken(12)
if err != nil {
return "", err
}
claims := imQRCodeClaims{
Version: 1,
TenantID: tenantID,
UserID: uid.UserId(),
Topic: topic,
Purpose: purpose,
Expires: expiresAt.Unix(),
Nonce: nonce,
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
mac := signIMQRCodePayload(encodedPayload)
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac), nil
}
func parseIMQRCodeToken(token, expectedPurpose string, now time.Time) (*imQRCodeClaims, error) {
payload, signature, ok := strings.Cut(token, ".")
if !ok || payload == "" || signature == "" {
return nil, types.ErrMalformed
}
got, err := base64.RawURLEncoding.DecodeString(signature)
if err != nil {
return nil, types.ErrMalformed
}
want := signIMQRCodePayload(payload)
if !hmac.Equal(got, want) {
return nil, types.ErrMalformed
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return nil, types.ErrMalformed
}
var claims imQRCodeClaims
if err = json.Unmarshal(raw, &claims); err != nil {
return nil, types.ErrMalformed
}
if claims.Version != 1 || claims.TenantID == 0 || claims.UserID == "" || claims.Purpose == "" || claims.Expires <= now.Unix() {
return nil, types.ErrMalformed
}
if expectedPurpose != "" && claims.Purpose != expectedPurpose {
return nil, types.ErrMalformed
}
return &claims, nil
}
func signIMQRCodePayload(payload string) []byte {
mac := hmac.New(sha256.New, globals.apiKeySalt)
mac.Write([]byte("im:qrcode:v1"))
mac.Write([]byte{0})
mac.Write([]byte(payload))
return mac.Sum(nil)
}
func verificationChallengeIDFromPath(path string) string {
const marker = "/v1/im/auth/verification-codes/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func passwordResetChallengeIDFromPath(path string) string {
const marker = "/v1/im/auth/password-reset/challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func securityChallengeIDFromPath(path string) string {
const marker = "/v1/im/security/challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func riskChallengeIDFromPath(path string) string {
const marker = "/v1/im/security/risk-challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "confirm" {
return ""
}
return strings.TrimSpace(challengeID)
}
func cleanStringList(values []string, limit int) []string {
if limit <= 0 {
limit = len(values)
}
capacity := len(values)
if capacity > limit {
capacity = limit
}
cleaned := make([]string, 0, capacity)
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
cleaned = append(cleaned, value)
if len(cleaned) >= limit {
break
}
}
return cleaned
}
func isAllowedPublicUploadPurpose(purpose string) bool {
switch strings.TrimSpace(purpose) {
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback:
return true
default:
return false
}
}
func isAllowedPublicUploadMime(purpose, mimeType string) bool {
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
switch purpose {
case types.PublicUploadPurposeEnterpriseLicense:
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp") ||
mimeType == "application/pdf"
case types.PublicUploadPurposeTenantFeedback:
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp")
default:
return false
}
}
func detectIMUploadMime(file interface {
io.Reader
io.Seeker
}, contentType string) (string, error) {
buff := make([]byte, 512)
n, err := file.Read(buff)
if err != nil && err != io.EOF {
return "", err
}
if _, err = file.Seek(0, io.SeekStart); err != nil {
return "", err
}
mimeType := http.DetectContentType(buff[:n])
if mimeType == "application/octet-stream" {
if parsed, _, err := mime.ParseMediaType(contentType); err == nil && parsed != "" {
mimeType = parsed
}
}
return strings.ToLower(strings.TrimSpace(mimeType)), nil
}
func isValidCreditCode(code string) bool {
if len(code) != 18 {
return false
}
for _, r := range code {
if !(r >= '0' && r <= '9' || r >= 'A' && r <= 'Z') {
return false
}
}
return true
}
func isValidIMEmail(email string) bool {
if len(email) > 255 || strings.ContainsAny(email, "\r\n\t ") {
return false
}
return regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`).MatchString(email)
}
func cleanTenantCodeHint(code string) string {
code = strings.TrimSpace(code)
if len(code) > 64 {
return code[:64]
}
return code
}
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) {
targetType := strings.ToLower(strings.TrimSpace(target.Type))
snapshot := map[string]any{"type": targetType}
switch targetType {
case "user":
uid := types.ParseUserId(strings.TrimSpace(target.UserID))
if uid.IsZero() || uid == reporter {
return "", 0, "", 0, nil, types.ErrMalformed
}
if store.Users != nil {
if _, err := store.Users.Get(tenantID, uid); err != nil {
return "", 0, "", 0, nil, err
}
}
snapshot["user_id"] = uid.UserId()
return targetType, uid, "", 0, snapshot, nil
case "group":
topic := strings.TrimSpace(target.Topic)
if types.GetTopicCat(topic) != types.TopicCatGrp {
return "", 0, "", 0, nil, types.ErrMalformed
}
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
return "", 0, "", 0, nil, err
}
snapshot["topic"] = topic
return targetType, 0, topic, 0, snapshot, nil
case "message":
topic := strings.TrimSpace(target.Topic)
seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64)
if topic == "" || seq <= 0 || err != nil {
return "", 0, "", 0, nil, types.ErrMalformed
}
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
return "", 0, "", 0, nil, err
}
msg, err := store.Messages.GetOne(tenantID, topic, seq)
if err != nil {
return "", 0, "", 0, nil, err
}
snapshot["topic"] = topic
snapshot["seq"] = seq
snapshot["from"] = msg.From
snapshot["ts"] = msg.CreatedAt.UTC().Format(time.RFC3339Nano)
return targetType, 0, topic, seq, snapshot, nil
default:
return "", 0, "", 0, nil, types.ErrMalformed
}
}
func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string) error {
if store.Subs == nil {
return types.ErrPermissionDenied
}
sub, err := store.Subs.Get(tenantID, topic, uid, false)
if err != nil {
return err
}
if sub == nil || !sub.ModeGiven.IsReader() {
return types.ErrPermissionDenied
}
return nil
}
func systemNotificationIDFromPath(path string) string {
const marker = "/v1/im/system-notifications/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
eventID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "read" {
return ""
}
return strings.TrimSpace(eventID)
}
func groupInvitationResourcePath(path string) (string, string, bool) {
const marker = "/v1/im/group-invitations/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], marker)
invitationID, tail, ok := strings.Cut(rest, "/")
if !ok || strings.TrimSpace(invitationID) == "" {
return "", "", false
}
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
}
func messageReceiptsPath(path string) (string, int64, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", 0, false
}
rest := strings.TrimPrefix(path[idx:], marker)
topic, rest, ok := strings.Cut(rest, "/messages/")
if !ok || topic == "" {
return "", 0, false
}
seqValue, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "receipts" {
return "", 0, false
}
seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil {
return topic, 0, true
}
return topic, seq, true
}
func topicFromMessagesSearchPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/messages/search"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromMediaPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/media"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromCallsPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/calls"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromDraftPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/draft"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromPathWithSuffix(path, prefix, suffix string) string {
idx := strings.Index(path, prefix)
if idx < 0 || !strings.HasSuffix(path, suffix) {
return ""
}
rest := strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
return strings.Trim(rest, "/")
}
func messageContextPath(path string) (string, int64, bool) {
const prefix = "/v1/im/topics/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", 0, false
}
rest := strings.TrimPrefix(path[idx:], prefix)
parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" {
return "", 0, false
}
seq, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || seq <= 0 {
return "", 0, false
}
return parts[0], seq, true
}
func messageActionPath(path, action string) (string, int64, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", 0, false
}
rest := strings.TrimPrefix(path[idx:], marker)
topic, rest, ok := strings.Cut(rest, "/messages/")
if !ok || topic == "" {
return "", 0, false
}
seqValue, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != action {
return "", 0, false
}
seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil {
return topic, 0, true
}
return topic, seq, true
}
func conversationContextTopicFromPath(path string) string {
const prefix = "/v1/im/conversations/"
const suffix = "/context"
if !strings.Contains(path, prefix) || !strings.HasSuffix(path, suffix) {
return ""
}
idx := strings.Index(path, prefix)
return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
}
func conversationResourceFromPath(path string) (string, string) {
const prefix = "/v1/im/conversations/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", ""
}
rest := strings.Trim(strings.TrimPrefix(path[idx:], prefix), "/")
topic, tail, ok := strings.Cut(rest, "/")
if !ok {
return topic, ""
}
return strings.TrimSpace(topic), strings.Trim(strings.TrimSpace(tail), "/")
}
func groupResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/groups/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
topic, tail, ok := strings.Cut(rest, "/")
if topic == "" {
return "", "", false
}
if !ok {
return topic, "", true
}
return topic, strings.Trim(tail, "/"), true
}
func userResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/users/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
identifier, tail, ok := strings.Cut(rest, "/")
identifier = strings.TrimSpace(identifier)
if !ok || identifier == "" || tail == "" {
return "", "", false
}
return identifier, strings.Trim(tail, "/"), true
}
func organizationDepartmentResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/organization/departments/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
departmentID, tail, ok := strings.Cut(rest, "/")
if !ok || departmentID == "" || tail == "" {
return "", "", false
}
return strings.TrimSpace(departmentID), strings.Trim(tail, "/"), true
}
func meetingResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/meetings/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
meetingID, tail, ok := strings.Cut(rest, "/")
if meetingID == "" {
return "", "", false
}
if !ok {
return strings.TrimSpace(meetingID), "", true
}
return strings.TrimSpace(meetingID), strings.Trim(tail, "/"), true
}
func dailyReportResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/reports/daily/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
reportID, tail, ok := strings.Cut(rest, "/")
if reportID == "" {
return "", "", false
}
if !ok {
return strings.TrimSpace(reportID), "", true
}
return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true
}
func monthlyReportResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/reports/monthly/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
reportID, tail, ok := strings.Cut(rest, "/")
if reportID == "" {
return "", "", false
}
if !ok {
return strings.TrimSpace(reportID), "", true
}
return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true
}
func groupMemberProfilePath(tail string) (types.Uid, bool) {
parts := strings.Split(strings.Trim(tail, "/"), "/")
if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" {
return types.ZeroUid, false
}
uid := types.ParseUserId(strings.TrimSpace(parts[1]))
return uid, !uid.IsZero()
}
func groupJoinRequestActionPath(tail string) (string, string, bool) {
parts := strings.Split(strings.Trim(tail, "/"), "/")
if len(parts) != 3 || parts[0] != "join-requests" || parts[1] == "" {
return "", "", false
}
switch parts[2] {
case "accept", "reject":
return strings.TrimSpace(parts[1]), parts[2], true
default:
return "", "", false
}
}
func friendRequestActionFromPath(path string) (string, string) {
const prefix = "/v1/im/friend-requests/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", ""
}
rest := strings.TrimPrefix(path[idx:], prefix)
requestID, action, ok := strings.Cut(rest, "/")
if !ok {
return "", ""
}
return strings.TrimSpace(requestID), strings.TrimSpace(action)
}
func userIDFromTail(path, prefix string) types.Uid {
idx := strings.Index(path, prefix)
if idx < 0 {
return types.ZeroUid
}
return types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(path[idx:], prefix)))
}
func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
if types.GetTopicCat(topic) != types.TopicCatP2P || uid.IsZero() {
return types.ZeroUid
}
prefix := "p2p"
body := strings.TrimPrefix(topic, prefix)
uidLen := len(uid.String())
if len(body) < 2*uidLen {
return types.ZeroUid
}
left := types.ParseUid(body[:uidLen])
right := types.ParseUid(body[uidLen:])
if left == uid {
return right
}
if right == uid {
return left
}
return types.ZeroUid
}
func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) {
topic = strings.TrimSpace(topic)
if len(topic) < 3 || uid.IsZero() {
return "", types.ZeroUid
}
if types.GetTopicCat(topic) == types.TopicCatMe {
peer := types.ParseUserId(topic)
if peer.IsZero() || peer == uid {
return "", types.ZeroUid
}
return uid.P2PName(peer), peer
}
peer := peerFromP2PTopic(topic, uid)
if peer.IsZero() {
return "", types.ZeroUid
}
return topic, peer
}
func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
return normalized
}
return strings.TrimSpace(topic)
}