11112 lines
383 KiB
Go
11112 lines
383 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/moderation"
|
|
realnamealiyun "github.com/tinode/chat/server/realname/aliyun"
|
|
"github.com/tinode/chat/server/store"
|
|
"github.com/tinode/chat/server/store/types"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
imReasonAPIKeyRequired = "API_KEY_REQUIRED"
|
|
imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
|
|
imReasonMalformedRequest = "MALFORMED_REQUEST"
|
|
imReasonTenantUnavailable = "TENANT_UNAVAILABLE"
|
|
imReasonVerificationRate = "VERIFICATION_RATE_LIMITED"
|
|
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
|
|
imReasonCodeInvalid = "CODE_INVALID"
|
|
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
|
|
imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED"
|
|
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
|
|
imReasonAuthRequired = "AUTH_REQUIRED"
|
|
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
|
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
|
imReasonPermissionDenied = "PERMISSION_DENIED"
|
|
imReasonForbidden = "FORBIDDEN"
|
|
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
|
|
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
|
|
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
|
|
imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS"
|
|
imReasonIdempotencyService = "IDEMPOTENCY_UNAVAILABLE"
|
|
imReasonInternal = "INTERNAL_ERROR"
|
|
imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
|
|
imReasonFileUnavailable = "FILE_UNAVAILABLE"
|
|
imReasonRateLimited = "RATE_LIMITED"
|
|
imReasonResourceNotFound = "RESOURCE_NOT_FOUND"
|
|
imReasonGroupDissolved = "GROUP_DISSOLVED"
|
|
imReasonValidationFailed = "VALIDATION_FAILED"
|
|
imReasonContentRejected = "CONTENT_REJECTED"
|
|
imReasonReviewUnavailable = "CONTENT_REVIEW_UNAVAILABLE"
|
|
)
|
|
|
|
const (
|
|
imDefaultTenantCode = "default"
|
|
registrationTypeEnterprise = 0
|
|
registrationTypePersonal = 1
|
|
)
|
|
|
|
const (
|
|
imHTTPIdempotencyTTL = 24 * time.Hour
|
|
imHTTPIdempotencyNamespace = "http_idempotency"
|
|
)
|
|
|
|
type imHTTPIdempotencyReplay struct {
|
|
BodyHash string `json:"body_hash"`
|
|
ResponseStatus int `json:"response_status"`
|
|
ResponseBody []byte `json:"response_body"`
|
|
}
|
|
|
|
var errIMHTTPIdempotencyConflict = errors.New("http idempotency conflict")
|
|
|
|
var (
|
|
beginIMHTTPIdempotency = redisBeginIMHTTPIdempotency
|
|
completeIMHTTPIdempotency = redisCompleteIMHTTPIdempotency
|
|
abortIMHTTPIdempotency = redisAbortIMHTTPIdempotency
|
|
)
|
|
|
|
func imHTTPIdempotencyID(tenantID types.TenantID, uid types.Uid, method, route, key string) string {
|
|
return strconv.FormatInt(int64(tenantID), 10) + ":" + uid.String() + ":" + method + ":" + route + ":" + key
|
|
}
|
|
|
|
func redisBeginIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string) (*imHTTPIdempotencyReplay, bool, bool, error) {
|
|
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
|
|
claimed, sameValue, enabled, err := store.ClaimShortStateValue(imHTTPIdempotencyNamespace, id, bodyHash, imHTTPIdempotencyTTL)
|
|
if err != nil || !enabled || claimed {
|
|
return nil, claimed, enabled, err
|
|
}
|
|
if !sameValue {
|
|
return nil, false, true, errIMHTTPIdempotencyConflict
|
|
}
|
|
raw, found, enabled, err := store.GetShortStateValue(imHTTPIdempotencyNamespace, id+":response")
|
|
if err != nil || !enabled || !found {
|
|
return nil, false, enabled, err
|
|
}
|
|
var replay imHTTPIdempotencyReplay
|
|
if err = json.Unmarshal([]byte(raw), &replay); err != nil {
|
|
_, _ = store.DeleteShortState(imHTTPIdempotencyNamespace, id+":response")
|
|
return nil, false, true, err
|
|
}
|
|
if replay.BodyHash != bodyHash {
|
|
return nil, false, true, nil
|
|
}
|
|
return &replay, false, true, nil
|
|
}
|
|
|
|
func redisCompleteIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string, status int, body []byte) error {
|
|
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
|
|
payload, err := json.Marshal(imHTTPIdempotencyReplay{BodyHash: bodyHash, ResponseStatus: status, ResponseBody: body})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
enabled, err := store.SetShortStateValue(imHTTPIdempotencyNamespace, id+":response", string(payload), imHTTPIdempotencyTTL)
|
|
if !enabled {
|
|
return errors.New("http idempotency redis unavailable")
|
|
}
|
|
return err
|
|
}
|
|
|
|
func redisAbortIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key string) error {
|
|
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
|
|
if _, err := store.DeleteShortState(imHTTPIdempotencyNamespace, id); err != nil {
|
|
return err
|
|
}
|
|
_, err := store.DeleteShortState(imHTTPIdempotencyNamespace, id+":response")
|
|
return err
|
|
}
|
|
|
|
func reviewIMHTTPText(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, scene, topic, text string) bool {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return true
|
|
}
|
|
tenant, err := store.Tenants.GetByID(principal.TenantID)
|
|
if err != nil || tenant == nil {
|
|
logs.Warn.Printf("content moderation skipped because tenant code is unavailable: tenant=%d scene=%s err=%v", principal.TenantID, scene, err)
|
|
return true
|
|
}
|
|
result, err := moderation.Review(req.Context(), moderation.Request{TenantID: principal.TenantID, TenantCode: tenant.Code, Scene: scene, ActorID: principal.Uid, Topic: topic, Text: text, TraceID: req.Header.Get("X-Request-ID")})
|
|
if err == nil {
|
|
return true
|
|
}
|
|
reason, message := imReasonContentRejected, "内容不符合平台规范,请修改后重试"
|
|
status := http.StatusUnprocessableEntity
|
|
if errors.Is(err, moderation.ErrUnavailable) {
|
|
reason, message, status = imReasonReviewUnavailable, "内容审核暂不可用,请稍后重试", http.StatusServiceUnavailable
|
|
}
|
|
if result != nil && strings.TrimSpace(result.BlockMessage) != "" {
|
|
message = result.BlockMessage
|
|
}
|
|
writeIMError(wrt, req, status, 30060, reason, message, nil)
|
|
return false
|
|
}
|
|
|
|
const (
|
|
imVerificationCodeLength = 6
|
|
imVerificationExpiresInSeconds = 300
|
|
imVerificationResendAfterSeconds = 60
|
|
imVerificationMaxAttempts = 5
|
|
imVerificationCreateRateWindow = time.Hour
|
|
imSensitiveChallengeRateLimitEnabled = true
|
|
imActionTokenExpiresInSeconds = 300
|
|
imIndependentPasswordMaxAttempts = 5
|
|
imIndependentPasswordLockSeconds = 300
|
|
imQRCodeDefaultExpiresInSeconds = 86400
|
|
imQRCodeMaxExpiresInSeconds = 7 * 86400
|
|
imPublicUploadMaxBytes = 10 << 20
|
|
groupMaxAdminCount = 10
|
|
reportPreviewMaxRunes = 120
|
|
)
|
|
|
|
type imHTTPResponse struct {
|
|
Code int `json:"code"`
|
|
Data any `json:"data,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Details any `json:"details,omitempty"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
ServerTime string `json:"server_time"`
|
|
}
|
|
|
|
type imHTTPContextKey int
|
|
|
|
const imHTTPProtocolValidated imHTTPContextKey = iota
|
|
|
|
func imReliableHTTPEventsEnabled(req *http.Request) bool {
|
|
enabled, _ := req.Context().Value(imHTTPProtocolValidated).(bool)
|
|
return enabled
|
|
}
|
|
|
|
func withIMHTTPProtocol(prefix string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
|
|
if !strings.HasPrefix(req.URL.Path, prefix) {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
|
|
requestID := strings.TrimSpace(req.Header.Get("X-Request-ID"))
|
|
if requestID == "" {
|
|
if token, err := randomIMToken(18); err == nil {
|
|
requestID = "req_" + token
|
|
} else {
|
|
requestID = fmt.Sprintf("req_%d", time.Now().UTC().UnixNano())
|
|
}
|
|
req.Header.Set("X-Request-ID", requestID)
|
|
}
|
|
wrt.Header().Set("X-Request-ID", requestID)
|
|
wrt.Header().Set("X-IM-Schema", "1")
|
|
|
|
if valid, _ := checkAPIKey(strings.TrimSpace(getAPIKey(req))); !valid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
if schema := strings.TrimSpace(req.Header.Get("X-IM-Schema")); schema != "1" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30006, imReasonSchemaUnsupported, "unsupported IM schema", map[string]any{"supported": []int{1}})
|
|
return
|
|
}
|
|
req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true))
|
|
serveIMHTTPIdempotent(wrt, req, next)
|
|
})
|
|
}
|
|
|
|
type imBufferedResponseWriter struct {
|
|
header http.Header
|
|
status int
|
|
wroteHeader bool
|
|
body bytes.Buffer
|
|
}
|
|
|
|
func newIMBufferedResponseWriter() *imBufferedResponseWriter {
|
|
return &imBufferedResponseWriter{header: make(http.Header), status: http.StatusOK}
|
|
}
|
|
|
|
func (w *imBufferedResponseWriter) Header() http.Header { return w.header }
|
|
|
|
func (w *imBufferedResponseWriter) WriteHeader(status int) {
|
|
if w.wroteHeader {
|
|
return
|
|
}
|
|
w.status = status
|
|
w.wroteHeader = true
|
|
}
|
|
|
|
func (w *imBufferedResponseWriter) Write(data []byte) (int, error) {
|
|
if !w.wroteHeader {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
return w.body.Write(data)
|
|
}
|
|
|
|
func (w *imBufferedResponseWriter) flushTo(wrt http.ResponseWriter) {
|
|
for key, values := range w.header {
|
|
for _, value := range values {
|
|
wrt.Header().Add(key, value)
|
|
}
|
|
}
|
|
wrt.WriteHeader(w.status)
|
|
_, _ = wrt.Write(w.body.Bytes())
|
|
}
|
|
|
|
func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http.Handler) {
|
|
if req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodPatch && req.Method != http.MethodDelete {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
if req.Method == http.MethodPost && strings.TrimSpace(req.URL.Path) == "/v1/im/tenants/resolve" {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(strings.TrimSpace(req.Header.Get("Authorization")), "Bearer ") {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
principal, err := resolveIMHTTPBearer(req)
|
|
if err != nil {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if key == "" || len(key) > 128 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(req.Body, 4<<20+1))
|
|
if err != nil || len(body) > 4<<20 {
|
|
writeIMError(wrt, req, http.StatusRequestEntityTooLarge, 30000, imReasonMalformedRequest, "request body too large", nil)
|
|
return
|
|
}
|
|
req.Body = io.NopCloser(bytes.NewReader(body))
|
|
hashInput := append([]byte(req.URL.RawQuery+"\x00"), body...)
|
|
bodyHash := sha256.Sum256(hashInput)
|
|
bodyHashText := fmt.Sprintf("%x", bodyHash[:])
|
|
existing, acquired, enabled, err := beginIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key, bodyHashText)
|
|
if errors.Is(err, errIMHTTPIdempotencyConflict) {
|
|
writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil)
|
|
return
|
|
}
|
|
if err != nil {
|
|
logs.Warn.Printf("im http redis idempotency begin failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
|
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, err)
|
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
|
|
return
|
|
}
|
|
if !enabled {
|
|
logs.Warn.Printf("im http redis idempotency unavailable request_id=%s tenant=%d user=%s method=%s route=%s",
|
|
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path)
|
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
|
|
return
|
|
}
|
|
if !acquired {
|
|
if existing == nil {
|
|
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
buffered := newIMBufferedResponseWriter()
|
|
next.ServeHTTP(buffered, req)
|
|
if buffered.status >= http.StatusInternalServerError {
|
|
if abortErr := abortIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key); abortErr != nil {
|
|
logs.Warn.Printf("im http redis idempotency abort failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
|
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, abortErr)
|
|
}
|
|
buffered.flushTo(wrt)
|
|
return
|
|
}
|
|
if err = completeIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key, bodyHashText, buffered.status, append([]byte(nil), buffered.body.Bytes()...)); err != nil {
|
|
logs.Warn.Printf("im http redis idempotency complete failed request_id=%s tenant=%d user=%s method=%s route=%s status=%d err=%v",
|
|
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, buffered.status, err)
|
|
if abortErr := abortIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key); abortErr != nil {
|
|
logs.Warn.Printf("im http redis idempotency abort after complete failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
|
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, abortErr)
|
|
}
|
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", 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"`
|
|
RegistrationType *int `json:"registration_type,omitempty"`
|
|
Scene string `json:"scene"`
|
|
Phone string `json:"phone"`
|
|
Country string `json:"country,omitempty"`
|
|
}
|
|
|
|
type imVerificationVerifyRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type imPasswordResetConfirmRequest struct {
|
|
ResetToken string `json:"reset_token"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
type imPrincipal struct {
|
|
TenantID types.TenantID
|
|
Uid types.Uid
|
|
}
|
|
|
|
type imSecurityChallengeRequest struct {
|
|
Scene string `json:"scene"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
|
|
type imURLCheckRequest struct {
|
|
URL string `json:"url"`
|
|
Source string `json:"source,omitempty"`
|
|
}
|
|
|
|
type imRealNameSubmissionRequest struct {
|
|
Name string `json:"name"`
|
|
IDType string `json:"id_type"`
|
|
IDNumber string `json:"id_number"`
|
|
}
|
|
|
|
type imRealNameAppealRequest struct {
|
|
Reason string `json:"reason"`
|
|
Attachments []string `json:"attachments,omitempty"`
|
|
}
|
|
|
|
type imIndependentPasswordRequest struct {
|
|
Password string `json:"password"`
|
|
Purpose string `json:"purpose"`
|
|
ActionToken string `json:"action_token,omitempty"`
|
|
}
|
|
|
|
type imIndependentPasswordVerifyRequest struct {
|
|
Password string `json:"password"`
|
|
Purpose string `json:"purpose"`
|
|
Action string `json:"action,omitempty"`
|
|
}
|
|
|
|
type imYouthModeEnableRequest struct {
|
|
PolicyVersion int `json:"policy_version"`
|
|
ActionToken string `json:"action_token"`
|
|
}
|
|
|
|
type imDevicePushTokenRequest struct {
|
|
Provider string `json:"provider"`
|
|
Token string `json:"token"`
|
|
AppVersion string `json:"app_version,omitempty"`
|
|
Platform string `json:"platform,omitempty"`
|
|
SDKVersion string `json:"sdk_version,omitempty"`
|
|
PushEnv string `json:"push_env,omitempty"`
|
|
NotificationPermission string `json:"notification_permission,omitempty"`
|
|
}
|
|
|
|
type imAuthLogoutRequest struct {
|
|
DeviceID string `json:"device_id,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 imMessageBatchRecallRequest struct {
|
|
Items []imMessageRecallItem `json:"items"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type imMessageRecallItem struct {
|
|
Seq int64 `json:"seq"`
|
|
Version int `json:"version,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type imMessageRevisionRequest struct {
|
|
Version int `json:"version"`
|
|
Content any `json:"content"`
|
|
ClientMid string `json:"client_mid,omitempty"`
|
|
Head types.KVMap `json:"head,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type imFavoritesBatchCreateRequest struct {
|
|
Messages []struct {
|
|
Topic string `json:"topic"`
|
|
Seq imForwardSeq `json:"seq"`
|
|
} `json:"messages"`
|
|
}
|
|
|
|
type imFavoritesBatchDeleteRequest struct {
|
|
FavoriteIDs []string `json:"favorite_ids"`
|
|
}
|
|
|
|
type imFavoritesForwardRequest struct {
|
|
FavoriteIDs []string `json:"favorite_ids"`
|
|
TargetTopics []string `json:"target_topics"`
|
|
Comment string `json:"comment,omitempty"`
|
|
}
|
|
|
|
type imMessagesForwardRequest struct {
|
|
Mode string `json:"mode"`
|
|
Sources []struct {
|
|
Topic string `json:"topic"`
|
|
Seq imForwardSeq `json:"seq"`
|
|
} `json:"sources"`
|
|
TargetTopics []string `json:"target_topics"`
|
|
Comment string `json:"comment,omitempty"`
|
|
}
|
|
|
|
type imForwardSeq int64
|
|
|
|
func (s *imForwardSeq) UnmarshalJSON(data []byte) error {
|
|
var number int64
|
|
if err := json.Unmarshal(data, &number); err == nil {
|
|
*s = imForwardSeq(number)
|
|
return nil
|
|
}
|
|
var text string
|
|
if err := json.Unmarshal(data, &text); err != nil {
|
|
return err
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
parsed, err := strconv.ParseInt(text, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*s = imForwardSeq(parsed)
|
|
return nil
|
|
}
|
|
|
|
type imFriendRequestCreateRequest struct {
|
|
TargetUserID string `json:"target_user_id"`
|
|
Message string `json:"message,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
SourceTopic string `json:"source_topic,omitempty"`
|
|
}
|
|
|
|
type imQRCodeCreateRequest struct {
|
|
Purpose string `json:"purpose"`
|
|
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
|
|
}
|
|
|
|
type imQRCodeResolveRequest struct {
|
|
Token string `json:"token"`
|
|
Purpose string `json:"purpose,omitempty"`
|
|
}
|
|
|
|
type imQRCodeClaims struct {
|
|
Version int `json:"v"`
|
|
TenantID types.TenantID `json:"tid"`
|
|
UserID string `json:"uid"`
|
|
Topic string `json:"topic,omitempty"`
|
|
Purpose string `json:"pur"`
|
|
Expires int64 `json:"exp"`
|
|
Nonce string `json:"non"`
|
|
}
|
|
|
|
type imGroupQRCodeCreateRequest struct {
|
|
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
|
|
}
|
|
|
|
type imFriendUpdateRequest struct {
|
|
Remark string `json:"remark"`
|
|
}
|
|
|
|
type imBlockBatchDeleteRequest struct {
|
|
UserIDs []string `json:"user_ids"`
|
|
}
|
|
|
|
type imGroupCreateRequest struct {
|
|
Name string `json:"name"`
|
|
MemberUserIDs []string `json:"member_user_ids,omitempty"`
|
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
type imGroupPatchRequest struct {
|
|
Name *string `json:"name,omitempty"`
|
|
AvatarFileID *string `json:"avatar_file_id,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupMemberSettingsRequest struct {
|
|
Pinned *bool `json:"pinned,omitempty"`
|
|
MuteNotifications *bool `json:"mute_notifications,omitempty"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupMemberProfileUpdateRequest struct {
|
|
Nickname string `json:"nickname"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupInvitationCreateRequest struct {
|
|
UserIDs []string `json:"user_ids"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
type imGroupJoinRequestCreateRequest struct {
|
|
Message string `json:"message,omitempty"`
|
|
QRToken string `json:"qr_token,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
}
|
|
|
|
type imGroupJoinRequestActionRequest struct {
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupFileCreateRequest struct {
|
|
FileID string `json:"file_id"`
|
|
FileName string `json:"file_name,omitempty"`
|
|
MIMEType string `json:"mime_type,omitempty"`
|
|
FileType string `json:"file_type,omitempty"`
|
|
FileSize int64 `json:"file_size,omitempty"`
|
|
SourceTopic string `json:"source_topic,omitempty"`
|
|
SourceSeq int64 `json:"source_seq,omitempty"`
|
|
}
|
|
|
|
type imGroupAnnouncementPutRequest struct {
|
|
Content string `json:"content"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupGovernancePatchRequest struct {
|
|
JoinApproval *bool `json:"join_approval,omitempty"`
|
|
MuteAll *bool `json:"mute_all,omitempty"`
|
|
MemberUploadFiles *bool `json:"member_upload_files,omitempty"`
|
|
MemberPrivateChat *bool `json:"member_private_chat,omitempty"`
|
|
MemberInvite *bool `json:"member_invite,omitempty"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupAdminsBatchRequest struct {
|
|
UserIDs []string `json:"user_ids"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupOwnerTransferRequest struct {
|
|
TargetUserID string `json:"target_user_id"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupDissolveRequest struct {
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type imGroupMuteBatchRequest struct {
|
|
Members []struct {
|
|
UserID string `json:"user_id"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
} `json:"members"`
|
|
UserIDs []string `json:"user_ids,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imSystemMessageActor struct {
|
|
UserID string `json:"user_id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Click map[string]any `json:"click,omitempty"`
|
|
}
|
|
|
|
type imSystemMessageAction struct {
|
|
Type string `json:"type"`
|
|
Label string `json:"label,omitempty"`
|
|
TargetID string `json:"target_id,omitempty"`
|
|
MinRole string `json:"min_role,omitempty"`
|
|
}
|
|
|
|
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
|
|
var body imTenantResolveRequest
|
|
if req.Body != nil {
|
|
dec := json.NewDecoder(req.Body)
|
|
dec.DisallowUnknownFields()
|
|
err := dec.Decode(&body)
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
err = nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
} else if err == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
|
if tenantCode == "" {
|
|
tenantCode = strings.TrimSpace(body.TenantCode)
|
|
}
|
|
if tenantCode == "" || len(tenantCode) > 64 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
|
|
"field": "tenant_code",
|
|
})
|
|
return
|
|
}
|
|
if store.Tenants == nil {
|
|
logs.Err.Println("im tenant resolve: tenant store is not initialized")
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
|
|
tenant, err := store.Tenants.GetByCode(tenantCode)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return
|
|
}
|
|
logs.Warn.Println("im tenant resolve: failed to resolve tenant", tenantCode, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if !tenant.IsActive() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return
|
|
}
|
|
|
|
endpoints := imPublicEndpoints(req)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"tenant": map[string]any{
|
|
"code": tenant.Code,
|
|
"name": tenant.Name,
|
|
"tenant_desc": tenant.TenantDesc,
|
|
"branding": map[string]any{},
|
|
},
|
|
"endpoints": endpoints,
|
|
"auth_methods": []string{"password", "sms"},
|
|
"password_policy": map[string]any{
|
|
"min_length": 8,
|
|
"max_length": 64,
|
|
},
|
|
"app_update": map[string]any{"type": "none"},
|
|
})
|
|
}
|
|
|
|
func serveIMPublicUploads(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if store.Store == nil || store.Store.GetMediaHandler() == nil {
|
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 90000, imReasonInternal, "media handler is not configured", nil)
|
|
return
|
|
}
|
|
uploader, ok := store.Store.GetMediaHandler().(media.PublicUploader)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30000, imReasonUnsupportedMedia, "public upload is not supported", nil)
|
|
return
|
|
}
|
|
req.Body = http.MaxBytesReader(wrt, req.Body, imPublicUploadMaxBytes)
|
|
if err := req.ParseMultipartForm(imPublicUploadMaxBytes); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed multipart form", nil)
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(req.FormValue("purpose"))
|
|
if !isAllowedPublicUploadPurpose(purpose) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
|
|
return
|
|
}
|
|
file, header, err := req.FormFile("file")
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "file is required", map[string]any{"field": "file"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
mimeType, err := detectIMUploadMime(file, header.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed file", nil)
|
|
return
|
|
}
|
|
if !isAllowedPublicUploadMime(purpose, mimeType) {
|
|
writeIMError(wrt, req, http.StatusUnsupportedMediaType, 30000, imReasonUnsupportedMedia, "unsupported media type", map[string]any{"mime_type": mimeType})
|
|
return
|
|
}
|
|
|
|
token, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
publicFile := &media.PublicFileDef{
|
|
ID: "file_" + token,
|
|
Purpose: purpose,
|
|
MimeType: mimeType,
|
|
CreatedAt: now,
|
|
}
|
|
location, size, err := uploader.UploadPublic(publicFile, file)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if location != "" {
|
|
publicFile.Location = location
|
|
}
|
|
upload, err := store.TenantEntry.CreatePublicUpload(&types.PublicUpload{
|
|
FileID: publicFile.ID,
|
|
Purpose: purpose,
|
|
MimeType: mimeType,
|
|
Size: size,
|
|
ETag: publicFile.ETag,
|
|
Location: publicFile.Location,
|
|
ExpiresAt: now.Add(24 * time.Hour),
|
|
CreatedAt: now,
|
|
UploadedAt: now,
|
|
})
|
|
if err != nil {
|
|
_ = store.Store.GetMediaHandler().Delete([]string{publicFile.Location})
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, publicUploadPayload(upload))
|
|
}
|
|
|
|
func serveIMTenantApplications(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
var body imTenantApplicationRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
creditCode := strings.ToUpper(strings.TrimSpace(body.CreditCode))
|
|
email := strings.TrimSpace(body.EnterpriseEmail)
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
if !isValidCreditCode(creditCode) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_CREDIT_CODE", "invalid credit code", map[string]any{"field": "credit_code"})
|
|
return
|
|
}
|
|
if email != "" && !isValidIMEmail(email) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_EMAIL", "invalid email", map[string]any{"field": "enterprise_email"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
app, err := store.TenantEntry.CreateTenantApplication(&types.TenantApplication{
|
|
ApplicationID: "entapp_" + token,
|
|
CreditCode: creditCode,
|
|
EnterpriseEmail: email,
|
|
LicenseFileID: strings.TrimSpace(body.LicenseFileID),
|
|
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
|
|
ClientContext: body.ClientContext,
|
|
IdempotencyKey: idempotencyKey,
|
|
Status: types.TenantApplicationStatusReceived,
|
|
EstimatedReviewHour: 24,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, tenantApplicationPayload(app))
|
|
}
|
|
|
|
func serveIMTenantEntryFeedback(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
var body imTenantEntryFeedbackRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
description := strings.TrimSpace(body.Description)
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
if len([]rune(description)) < 10 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_REQUIRED", "description is required", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
if len([]rune(description)) > 1000 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_TOO_LONG", "description is too long", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
if len(body.ScreenshotFileIDs) > 9 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "TOO_MANY_FILES", "too many files", map[string]any{"field": "screenshot_file_ids"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
feedback, err := store.TenantEntry.CreateTenantEntryFeedback(&types.TenantEntryFeedback{
|
|
FeedbackID: "entryfb_" + token,
|
|
Description: description,
|
|
Contact: strings.TrimSpace(body.Contact),
|
|
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
|
|
ScreenshotFileIDs: cleanStringList(body.ScreenshotFileIDs, 9),
|
|
ClientContext: body.ClientContext,
|
|
IdempotencyKey: idempotencyKey,
|
|
Status: types.TenantEntryFeedbackReceived,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, tenantEntryFeedbackPayload(feedback))
|
|
}
|
|
|
|
func imPublicEndpoints(req *http.Request) map[string]any {
|
|
base := imPublicHTTPBase(req)
|
|
httpBase := strings.TrimRight(base, "/")
|
|
wsBase := base
|
|
if strings.HasPrefix(wsBase, "https://") {
|
|
wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://")
|
|
} else if strings.HasPrefix(wsBase, "http://") {
|
|
wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://")
|
|
}
|
|
wsEndpoint := strings.TrimRight(wsBase, "/") + "/v0/channels"
|
|
if apiKey := strings.TrimSpace(getAPIKey(req)); apiKey != "" {
|
|
wsEndpoint += "?apikey=" + url.QueryEscape(apiKey)
|
|
}
|
|
return map[string]any{
|
|
"websocket": wsEndpoint,
|
|
"api": httpBase + "/v1/im",
|
|
"file": httpBase + "/v0/file",
|
|
"legal": map[string]any{
|
|
"user_terms": httpBase + "/legal/user-terms",
|
|
"privacy_policy": httpBase + "/legal/privacy-policy",
|
|
},
|
|
}
|
|
}
|
|
|
|
func imPublicHTTPBase(req *http.Request) string {
|
|
configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/")
|
|
parsed, _ := url.Parse(configured)
|
|
basePath := ""
|
|
if parsed != nil {
|
|
basePath = strings.TrimRight(parsed.Path, "/")
|
|
if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) {
|
|
return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/")
|
|
}
|
|
}
|
|
if basePath == "" && req != nil && req.URL != nil {
|
|
if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 {
|
|
basePath = strings.TrimRight(req.URL.Path[:idx], "/")
|
|
}
|
|
}
|
|
|
|
scheme := "http"
|
|
if req != nil && req.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
if req != nil {
|
|
if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" {
|
|
scheme = forwarded
|
|
}
|
|
}
|
|
|
|
host := ""
|
|
if req != nil {
|
|
host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host"))
|
|
if host == "" {
|
|
host = strings.TrimSpace(req.Host)
|
|
}
|
|
}
|
|
if host == "" && parsed != nil {
|
|
host = parsed.Host
|
|
}
|
|
if host == "" {
|
|
return configured
|
|
}
|
|
return strings.TrimRight(scheme+"://"+host+basePath, "/")
|
|
}
|
|
|
|
func isPublicEndpointHost(host string) bool {
|
|
host = strings.TrimSpace(host)
|
|
return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:")
|
|
}
|
|
|
|
func firstForwardedHeader(value string) string {
|
|
if idx := strings.Index(value, ","); idx >= 0 {
|
|
value = value[:idx]
|
|
}
|
|
return strings.TrimSpace(strings.ToLower(value))
|
|
}
|
|
|
|
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
|
|
var body imVerificationCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
scene := strings.TrimSpace(body.Scene)
|
|
if !isAllowedVerificationScene(scene) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "scene"})
|
|
return
|
|
}
|
|
phone, err := normalizeIMPhone(body.Phone, body.Country)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
|
|
return
|
|
}
|
|
var tenant *types.Tenant
|
|
var ok bool
|
|
if scene == "register" {
|
|
tenant, ok = resolveIMHTTPRegistrationTenant(wrt, req, body)
|
|
} else {
|
|
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)
|
|
inviteCandidate := isValidIMInviteCode(code)
|
|
if len(code) != imVerificationCodeLength && !inviteCandidate {
|
|
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 inviteCandidate {
|
|
if vc.Scene != "register" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
|
|
return
|
|
}
|
|
if err := validateRegistrationInvite(tenant.ID, code, now); err != nil {
|
|
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
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"verification_mode": "invite_code",
|
|
"invite_accepted": true,
|
|
})
|
|
return
|
|
}
|
|
if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
|
|
nextState := types.VerificationStatePending
|
|
if vc.Attempts+1 >= vc.MaxAttempts {
|
|
nextState = types.VerificationStateLocked
|
|
}
|
|
_ = store.Verifications.Fail(tenant.ID, challengeID, nextState)
|
|
if nextState == types.VerificationStateLocked {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
|
|
return
|
|
}
|
|
|
|
token, err := randomIMToken(32)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err := store.Verifications.MarkVerified(tenant.ID, challengeID, hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
|
|
logs.Warn.Println("im verification verify: mark verified failed", tenant.Code, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"verification_token": token,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
})
|
|
}
|
|
|
|
func serveIMPasswordResetVerify(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
challengeID := passwordResetChallengeIDFromPath(req.URL.Path)
|
|
if challengeID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
|
|
return
|
|
}
|
|
var body imVerificationVerifyRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
|
|
if !ok {
|
|
return
|
|
}
|
|
vc, token, ok := verifyIMCodeChallenge(wrt, req, tenant.ID, challengeID, strings.TrimSpace(body.Code), "reset_password")
|
|
if !ok {
|
|
return
|
|
}
|
|
if vc.TargetUser.IsZero() {
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"reset_token": vc.ChallengeID + "." + token,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
})
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"reset_token": vc.ChallengeID + "." + token,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
})
|
|
}
|
|
|
|
func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
var body imPasswordResetConfirmRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
|
|
if !ok {
|
|
return
|
|
}
|
|
challengeID, token, ok := strings.Cut(strings.TrimSpace(body.ResetToken), ".")
|
|
if !ok || challengeID == "" || token == "" || strings.TrimSpace(body.NewPassword) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
vc, err := store.Verifications.Get(tenant.ID, challengeID)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
if vc.Scene != "reset_password" || vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
tokenHash := hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token)
|
|
if *vc.VerificationTokenHash != tokenHash || !time.Now().UTC().Before(vc.ExpiresAt) {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
if vc.TargetUser.IsZero() {
|
|
_ = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, time.Now().UTC().Round(time.Millisecond))
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
|
|
return
|
|
}
|
|
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser)
|
|
if err != nil || login == "" {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
basicAuth := store.Store.GetLogicalAuthHandler("basic")
|
|
if basicAuth == nil || !basicAuth.IsInitialized() {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if _, err = basicAuth.UpdateRecord(auth.AuthContext{TenantID: tenant.ID}, &auth.Rec{
|
|
TenantID: tenant.ID,
|
|
Uid: vc.TargetUser,
|
|
AuthLevel: authLevel,
|
|
}, []byte(":"+body.NewPassword)); err != nil {
|
|
if err == types.ErrPolicy {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if store.Security == nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if _, err = store.Security.BumpAuthVersion(tenant.ID, vc.TargetUser, now); err != nil {
|
|
logs.Warn.Println("im password reset confirm: bump auth version failed", err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, now); err != nil {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" {
|
|
if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{
|
|
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
|
|
User: vc.TargetUser.String(),
|
|
Method: "tel",
|
|
Value: account.Phone,
|
|
Done: true,
|
|
}); credErr != nil {
|
|
logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr)
|
|
}
|
|
} else if getErr != nil {
|
|
logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr)
|
|
}
|
|
if globals.sessionStore != nil {
|
|
globals.sessionStore.EvictUser(vc.TargetUser, "")
|
|
}
|
|
publishRealtimeEvictUser(tenant.ID, vc.TargetUser, "password_reset", "")
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
|
|
}
|
|
|
|
func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imURLCheckRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
decision, riskLevel, reason := classifyIMURLRisk(strings.TrimSpace(body.URL))
|
|
payload := map[string]any{
|
|
"url": strings.TrimSpace(body.URL),
|
|
"source": strings.TrimSpace(body.Source),
|
|
"decision": decision,
|
|
"risk_level": riskLevel,
|
|
"reason": reason,
|
|
}
|
|
if decision != "allow" {
|
|
challengeID := "risk_" + shortTokenHash(fmt.Sprintf("%d:%s:%d", principal.Uid, strings.ToLower(strings.TrimSpace(body.URL)), time.Now().UnixNano()))
|
|
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(10 * time.Minute)
|
|
if _, err := store.Safety.CreateRiskChallenge(principal.TenantID, &types.RiskChallenge{
|
|
ChallengeID: challengeID,
|
|
UserID: principal.Uid,
|
|
Action: "open_url",
|
|
Level: riskLevel,
|
|
NoticeCode: "url_" + riskLevel,
|
|
NoticeParams: map[string]any{"source": strings.TrimSpace(body.Source)},
|
|
State: types.RiskChallengeStatePending,
|
|
ExpiresAt: expiresAt,
|
|
}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload["risk_challenge_id"] = challengeID
|
|
payload["expires_at"] = expiresAt.Format(time.RFC3339Nano)
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMRiskChallengeConfirm(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
challengeID := riskChallengeIDFromPath(req.URL.Path)
|
|
if challengeID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "risk_challenge_id"})
|
|
return
|
|
}
|
|
var body imRiskChallengeConfirmRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
decision := strings.ToLower(strings.TrimSpace(body.Decision))
|
|
var tokenID string
|
|
var token string
|
|
var expiresAt time.Time
|
|
var err error
|
|
if decision == "continue" {
|
|
token, expiresAt, err = issueIMActionToken(principal.TenantID, principal.Uid, "risk_continue", challengeID)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
tokenID, _, _ = strings.Cut(token, ".")
|
|
}
|
|
challenge, err := store.Safety.ConfirmRiskChallenge(principal.TenantID, principal.Uid, challengeID, decision, tokenID, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "risk challenge not found", nil)
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := riskChallengePayload(challenge)
|
|
if decision == "continue" {
|
|
payload["risk_action_token"] = token
|
|
payload["token_action"] = "risk_continue"
|
|
payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano)
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
eventType := "security.risk." + decision
|
|
eventID := stableIMEventID("risk", challengeID, decision, challenge.State)
|
|
if err = createIMSystemNotification(principal.TenantID, principal.Uid,
|
|
stableIMEventID(eventID, "notification"), eventType, "安全操作确认", decision,
|
|
types.KVMap{"risk_challenge_id": challengeID}); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "user", principal.Uid.UserId(),
|
|
principal.Uid, eventType, map[string]any{"risk_challenge_id": challengeID, "decision": decision}); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type")))
|
|
if !isAllowedIMAbuseTargetType(targetType) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_type"})
|
|
return
|
|
}
|
|
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
|
|
if err != nil {
|
|
logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err)
|
|
reasons = defaultIMAbuseReasons(targetType)
|
|
}
|
|
if len(reasons) == 0 {
|
|
reasons = defaultIMAbuseReasons(targetType)
|
|
}
|
|
items := make([]map[string]any, 0, len(reasons))
|
|
for _, reason := range reasons {
|
|
items = append(items, abuseReasonPayload(reason))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items})
|
|
}
|
|
|
|
func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imAbuseReportRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
targetType, targetUser, targetTopic, targetSeq, snapshot, err := normalizeIMAbuseTarget(principal.TenantID, principal.Uid, body.Target)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
description := strings.TrimSpace(body.Description)
|
|
if len([]rune(description)) > 500 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
|
|
if err != nil || len(reasons) == 0 {
|
|
reasons = defaultIMAbuseReasons(targetType)
|
|
}
|
|
reasonKnown := false
|
|
for _, reason := range reasons {
|
|
if strings.EqualFold(strings.TrimSpace(reason.ReasonCode), strings.TrimSpace(body.ReasonCode)) {
|
|
reasonKnown = true
|
|
if reason.RequiresDescription && description == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "description", "reason_code": reason.ReasonCode})
|
|
return
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !reasonKnown {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason_code"})
|
|
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))
|
|
}
|
|
|
|
// imContactUsDictType is the admin dictionary type backing the mobile
|
|
// "contact us" entries.
|
|
const imContactUsDictType = "im_contact_us"
|
|
|
|
func serveIMContactUs(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if _, ok := authenticateIMHTTPBearer(wrt, req); !ok {
|
|
return
|
|
}
|
|
items, err := store.DictionaryConfig.Get(imContactUsDictType)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrUnsupported) || errors.Is(err, types.ErrNotFound) {
|
|
items = nil
|
|
} else {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
label := strings.TrimSpace(item.Label)
|
|
value := strings.TrimSpace(item.Value)
|
|
if label == "" || value == "" {
|
|
continue
|
|
}
|
|
payload = append(payload, map[string]any{"label": label, "value": value})
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload})
|
|
}
|
|
|
|
func serveIMFeedbackConfig(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
config, err := store.Safety.FeedbackConfig(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, feedbackConfigPayload(config))
|
|
}
|
|
|
|
func serveIMFeedback(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFeedbackRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
content := strings.TrimSpace(body.Content)
|
|
if idempotencyKey == "" || strings.TrimSpace(body.Category) == "" || content == "" || len([]rune(content)) > 1000 || len(body.AttachmentFileIDs) > 5 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
feedback, err := store.Safety.CreateFeedback(principal.TenantID, &types.FeedbackSubmission{
|
|
FeedbackID: "fb_" + token,
|
|
UserID: principal.Uid,
|
|
Category: strings.TrimSpace(body.Category),
|
|
Content: content,
|
|
Contact: strings.TrimSpace(body.Contact),
|
|
AttachmentFileIDs: cleanStringList(body.AttachmentFileIDs, 5),
|
|
ClientContext: body.ClientContext,
|
|
IncludeDiagnostics: body.IncludeDiagnostics,
|
|
}, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback))
|
|
}
|
|
|
|
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imSecurityChallengeRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
action := strings.TrimSpace(body.Scene)
|
|
if !validIMSecurityChallengeScene(action) || strings.TrimSpace(body.Channel) != "sms" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
phone, err := currentUserPhone(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30041, imReasonRealNameRequired, "verified phone required", nil)
|
|
return
|
|
}
|
|
createSensitiveChallenge(wrt, req, principal, action, phone)
|
|
}
|
|
|
|
func serveIMSecurityChallengeVerify(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
challengeID := securityChallengeIDFromPath(req.URL.Path)
|
|
if challengeID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
var body imVerificationVerifyRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
vc, _, ok := verifyIMCodeChallenge(wrt, req, principal.TenantID, challengeID, strings.TrimSpace(body.Code), "sensitive_action")
|
|
if !ok {
|
|
return
|
|
}
|
|
if vc.TargetUser != principal.Uid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, vc.Action, defaultIMActionTarget(vc.Action, vc.ChallengeID))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"action_token": token,
|
|
"action": vc.Action,
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func serveIMRealName(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "unverified", "can_submit": true})
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, realNamePayload(rv))
|
|
}
|
|
|
|
func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
var body imRealNameSubmissionRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
idType := strings.TrimSpace(body.IDType)
|
|
idNumber := strings.ToUpper(strings.TrimSpace(body.IDNumber))
|
|
if !validIMRealName(name, idType, idNumber) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
current, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
|
|
if err == nil && (current.Status == "pending" || current.Status == "verified") {
|
|
writeIMSuccess(wrt, req, realNamePayload(current))
|
|
return
|
|
}
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32]
|
|
status, reviewReason, verifiedAt := initialRealNameSubmissionState(req, principal, name, idType, idNumber)
|
|
rv := &types.RealNameVerification{
|
|
TenantID: principal.TenantID,
|
|
UserID: principal.Uid,
|
|
SubmissionID: submissionID,
|
|
Status: status,
|
|
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),
|
|
ReviewReason: reviewReason,
|
|
VerifiedAt: verifiedAt,
|
|
}
|
|
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
|
|
}
|
|
response := map[string]any{"submission_id": submissionID, "status": status}
|
|
if status == "manual_review" {
|
|
response["status"] = "pending"
|
|
response["review_status"] = "manual_review"
|
|
}
|
|
writeIMSuccess(wrt, req, response)
|
|
}
|
|
|
|
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
|
|
}
|
|
entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
audioCallEnabled, videoCallEnabled := imCallFeatureFlags()
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"features": map[string]any{
|
|
"create_group": true,
|
|
"user_search": true,
|
|
"scan_qr": true,
|
|
"system_notifications": true,
|
|
"message_recall": true,
|
|
"message_edit": true,
|
|
"audio_call": audioCallEnabled,
|
|
"video_call": videoCallEnabled,
|
|
},
|
|
"permissions": map[string]any{
|
|
"create_group": true,
|
|
"user_search": true,
|
|
"scan_qr": true,
|
|
"audio_call": audioCallEnabled,
|
|
"video_call": videoCallEnabled,
|
|
},
|
|
"entry_badges": entryBadges,
|
|
"entries": entries,
|
|
"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 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
|
|
}
|
|
account, err := currentUserAccountPayload(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
profilePayload := userProfilePayload10(profile)
|
|
applyDefaultUserAvatar(profilePayload, principal.Uid.UserId(), profile.Gender)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"user_id": principal.Uid.UserId(),
|
|
"username": account["username"],
|
|
"profile": profilePayload,
|
|
"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
|
|
}
|
|
payload, err := currentUserProfilePayload(principal.TenantID, principal.Uid, profile)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
case http.MethodPatch:
|
|
var body struct {
|
|
AvatarFileID *string `json:"avatar_file_id"`
|
|
Nickname *string `json:"nickname"`
|
|
BirthDate *string `json:"birth_date"`
|
|
Signature *string `json:"signature"`
|
|
Gender *string `json:"gender"`
|
|
Region *string `json:"region"`
|
|
Email *string `json:"email"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.BirthDate != nil && strings.TrimSpace(*body.BirthDate) != "" {
|
|
if date, err := parseIMDate(*body.BirthDate); err != nil || date.After(time.Now().UTC()) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "birth_date"})
|
|
return
|
|
}
|
|
}
|
|
if body.Nickname != nil && !reviewIMHTTPText(wrt, req, principal, moderation.SceneUserNickname, "", *body.Nickname) {
|
|
return
|
|
}
|
|
if body.Signature != nil && !reviewIMHTTPText(wrt, req, principal, moderation.SceneUserSignature, "", *body.Signature) {
|
|
return
|
|
}
|
|
if body.AvatarFileID != nil {
|
|
if avatar := strings.TrimSpace(*body.AvatarFileID); avatar != "" {
|
|
if err := store.Files.LinkAttachments(principal.TenantID, principal.Uid.UserId(), types.ZeroUid, []string{avatar}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{
|
|
AvatarFileID: body.AvatarFileID,
|
|
Nickname: body.Nickname,
|
|
BirthDate: body.BirthDate,
|
|
Signature: body.Signature,
|
|
Gender: body.Gender,
|
|
Region: body.Region,
|
|
Email: body.Email,
|
|
Version: body.Version,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload, err := currentUserProfilePayload(principal.TenantID, principal.Uid, profile)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
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"`
|
|
FriendRequestVerification *bool `json:"friend_request_verification"`
|
|
GroupInviteVerification *bool `json:"group_invite_verification"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{
|
|
AddMeMethods: body.AddMeMethods,
|
|
ProfileVisibility: body.ProfileVisibility,
|
|
LastSeenVisibility: body.LastSeenVisibility,
|
|
ReadReceipts: body.ReadReceipts,
|
|
FriendRequestVerification: body.FriendRequestVerification,
|
|
GroupInviteVerification: body.GroupInviteVerification,
|
|
Version: body.Version,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
|
|
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel")))
|
|
currentVersion := strings.TrimSpace(req.URL.Query().Get("current_version"))
|
|
if channel == "" {
|
|
channel = "stable"
|
|
}
|
|
if platform != "ios" && platform != "android" && platform != "web" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
|
|
return
|
|
}
|
|
currentParts, err := parseNumericAppVersion(currentVersion)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "current_version"})
|
|
return
|
|
}
|
|
release, err := store.AppReleases.Latest(platform, channel)
|
|
if err != nil {
|
|
if platform == "web" {
|
|
logs.Warn.Println("im app release latest: using web fallback", err)
|
|
release = &types.AppRelease{
|
|
Platform: platform,
|
|
Channel: channel,
|
|
LatestVersion: currentVersion,
|
|
MinSupportedVersion: currentVersion,
|
|
UpdateType: "none",
|
|
ReleaseNotes: "当前已是最新版本。",
|
|
PublishedAt: time.Now().UTC(),
|
|
}
|
|
} else {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
}
|
|
latestParts, latestErr := parseNumericAppVersion(release.LatestVersion)
|
|
minimumParts, minimumErr := parseNumericAppVersion(release.MinSupportedVersion)
|
|
if latestErr != nil || minimumErr != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 50000, imReasonInternal, "invalid app release configuration", nil)
|
|
return
|
|
}
|
|
hasUpdate := compareNumericAppVersion(currentParts, latestParts) < 0
|
|
updateType := "none"
|
|
if hasUpdate {
|
|
updateType = strings.ToLower(strings.TrimSpace(release.UpdateType))
|
|
if compareNumericAppVersion(currentParts, minimumParts) < 0 {
|
|
updateType = "force"
|
|
}
|
|
if updateType != "force" && updateType != "recommended" {
|
|
updateType = "none"
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, appReleasePayload(release, currentVersion, hasUpdate, updateType))
|
|
}
|
|
|
|
func serveIMYouthMode(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
settings, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, youthModePayload(settings))
|
|
}
|
|
|
|
func serveIMYouthModeEnable(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imYouthModeEnableRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.PolicyVersion <= 0 || strings.TrimSpace(body.ActionToken) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "enable_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, true, body.PolicyVersion, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, youthModePayload(settings))
|
|
}
|
|
|
|
func serveIMYouthModeDisable(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
actionToken := strings.TrimSpace(req.Header.Get("X-Action-Token"))
|
|
if actionToken == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "X-Action-Token"})
|
|
return
|
|
}
|
|
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, "close_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
current, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, false, current.PolicyVersion, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, youthModePayload(settings))
|
|
}
|
|
|
|
func serveIMDevicePushToken(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
deviceID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/v1/im/devices/"))
|
|
deviceID = strings.TrimSuffix(deviceID, "/push-token")
|
|
if deviceID == "" || strings.Contains(deviceID, "/") {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"})
|
|
return
|
|
}
|
|
var body imDevicePushTokenRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
token, err := store.DevicePushTokens.Upsert(principal.TenantID, &types.DevicePushToken{
|
|
UserID: principal.Uid,
|
|
DeviceID: deviceID,
|
|
Provider: body.Provider,
|
|
Token: body.Token,
|
|
AppVersion: body.AppVersion,
|
|
Platform: body.Platform,
|
|
SDKVersion: body.SDKVersion,
|
|
PushEnv: body.PushEnv,
|
|
NotificationPermission: body.NotificationPermission,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, devicePushTokenPayload(token))
|
|
}
|
|
|
|
func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
|
|
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
start := 0
|
|
if cursor != "" {
|
|
parsed, err := strconv.Atoi(cursor)
|
|
if err != nil || parsed < 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
start = parsed
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
nextIndex := start
|
|
for i := start; i < len(subs); i++ {
|
|
if query != "" && !conversationMatches(subs[i], query) {
|
|
continue
|
|
}
|
|
items = append(items, conversationPayload(subs[i]))
|
|
nextIndex = i + 1
|
|
if len(items) == limit {
|
|
break
|
|
}
|
|
}
|
|
nextCursor := ""
|
|
if nextIndex < len(subs) {
|
|
nextCursor = strconv.Itoa(nextIndex)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMP2PConversation(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imP2PConversationRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
peer := types.ParseUserId(strings.TrimSpace(body.PeerUserID))
|
|
if peer.IsZero() || peer == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "peer_user_id"})
|
|
return
|
|
}
|
|
topic, err := store.Conversations.EnsureP2P(principal.TenantID, principal.Uid, peer)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
|
}
|
|
|
|
func serveIMForwardTargets(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("query")))
|
|
if query == "" {
|
|
query = strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
|
|
}
|
|
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
start := 0
|
|
if cursor != "" {
|
|
parsed, err := strconv.Atoi(cursor)
|
|
if err != nil || parsed < 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
start = parsed
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
if start == 0 && forwardTargetMatches("slf", "文件助手", query) {
|
|
items = append(items, map[string]any{
|
|
"topic": "slf",
|
|
"type": "file_assistant",
|
|
"display_name": "文件助手",
|
|
"avatar_file_id": "",
|
|
"can_receive_forward": true,
|
|
})
|
|
}
|
|
nextIndex := start
|
|
for i := start; i < len(subs); i++ {
|
|
target := forwardTargetPayload(subs[i])
|
|
if target == nil {
|
|
nextIndex = i + 1
|
|
continue
|
|
}
|
|
if query != "" && !forwardTargetMatches(target["topic"].(string), target["display_name"].(string), query) && !conversationMatches(subs[i], query) {
|
|
continue
|
|
}
|
|
items = append(items, target)
|
|
nextIndex = i + 1
|
|
if len(items) == limit {
|
|
break
|
|
}
|
|
}
|
|
nextCursor := ""
|
|
if nextIndex < len(subs) {
|
|
nextCursor = strconv.Itoa(nextIndex)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
|
|
}
|
|
|
|
func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
items, nextCursor, err := store.Security.ListSystemNotifications(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": systemNotificationPayloads(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)
|
|
}
|
|
notification, err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt)
|
|
if 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
|
|
}
|
|
payload := systemNotificationPayload(*notification)
|
|
payload["status"] = "ok"
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
serveIMFavoriteList(wrt, req)
|
|
case http.MethodPost:
|
|
serveIMFavoriteBatchCreate(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Favorites.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
itemPayload, ok := favoritePayloadForPrincipal(principal, item)
|
|
if ok {
|
|
payload = append(payload, itemPayload)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesBatchCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.Messages) == 0 || len(body.Messages) > 100 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "messages"})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
created, existing := 0, 0
|
|
failed := make([]map[string]any, 0)
|
|
for _, src := range body.Messages {
|
|
sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic)
|
|
seq := int64(src.Seq)
|
|
if !sourceOK || seq <= 0 {
|
|
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": imReasonMalformedRequest})
|
|
continue
|
|
}
|
|
if err := requireTopicReader(principal, sourceTopic); err != nil {
|
|
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, seq)
|
|
if err != nil {
|
|
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, seq)
|
|
fav := &types.FavoriteItem{
|
|
TenantID: principal.TenantID,
|
|
FavoriteID: "fav_" + store.Store.GetUidString(),
|
|
UserID: principal.Uid,
|
|
ItemType: imMessageType(msg),
|
|
SourceTopic: sourceTopic,
|
|
SourceSeq: seq,
|
|
Snapshot: favoriteSnapshot(msg),
|
|
AttachmentRefs: favoriteAttachmentRefs(msg),
|
|
Availability: messageAvailability(*msg),
|
|
IdempotencyKey: idempotencyKey,
|
|
CollectedAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
item, err := store.Favorites.Create(principal.TenantID, fav)
|
|
if err != nil {
|
|
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
if item.FavoriteID == fav.FavoriteID {
|
|
created++
|
|
} else {
|
|
existing++
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"created_count": created, "existing_count": existing, "failed": failed})
|
|
}
|
|
|
|
func serveIMFavoriteResource(wrt http.ResponseWriter, req *http.Request) {
|
|
const prefix = "/v1/im/favorites/"
|
|
idx := strings.Index(req.URL.Path, prefix)
|
|
if idx < 0 {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "favorite route not found", nil)
|
|
return
|
|
}
|
|
tail := strings.Trim(strings.TrimPrefix(req.URL.Path[idx:], prefix), "/")
|
|
switch {
|
|
case tail == "batch":
|
|
serveIMFavoriteBatchCreate(wrt, req)
|
|
case tail == "batch-delete":
|
|
serveIMFavoriteBatchDelete(wrt, req)
|
|
case tail == "forward":
|
|
serveIMFavoriteForward(wrt, req)
|
|
default:
|
|
serveIMFavoriteDetail(wrt, req, tail)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload, ok := favoritePayloadForPrincipal(principal, *item)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
case http.MethodDelete:
|
|
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"favorite_id": favoriteID, "deleted": true})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteBatchDelete(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesBatchDeleteRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.FavoriteIDs))
|
|
for _, favoriteID := range body.FavoriteIDs {
|
|
favoriteID = strings.TrimSpace(favoriteID)
|
|
item := map[string]any{"favorite_id": favoriteID}
|
|
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
|
|
item["ok"] = false
|
|
item["reason"] = relationErrorReason(err)
|
|
} else {
|
|
item["ok"] = true
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": results})
|
|
}
|
|
|
|
func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesForwardRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.FavoriteIDs) == 0 || len(body.FavoriteIDs) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs))
|
|
comment := strings.TrimSpace(body.Comment)
|
|
for _, target := range body.TargetTopics {
|
|
rawTarget := strings.TrimSpace(target)
|
|
target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
|
|
if target == "" || !targetOK {
|
|
results = append(results, map[string]any{"target_topic": rawTarget, "status": "failed", "reason": imReasonMalformedRequest})
|
|
continue
|
|
}
|
|
if err := requireTopicWriter(principal, target); err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
var commentResult map[string]any
|
|
commentSaved := false
|
|
ensureComment := func() map[string]any {
|
|
if commentSaved || comment == "" {
|
|
return commentResult
|
|
}
|
|
commentSaved = true
|
|
commentResult = saveForwardCommentMessage(req.Context(), principal, target, comment, forwardClientMid(req.Header.Get("Idempotency-Key"), "favorite-comment", target))
|
|
return commentResult
|
|
}
|
|
for _, favoriteID := range body.FavoriteIDs {
|
|
favoriteID = strings.TrimSpace(favoriteID)
|
|
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "favorite", target, favoriteID)
|
|
head, content := favoriteForwardMessageHeadContent(item, clientMid)
|
|
msg := &types.Message{
|
|
TenantID: principal.TenantID,
|
|
Topic: target,
|
|
From: principal.Uid.String(),
|
|
Head: head,
|
|
Content: content,
|
|
}
|
|
if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
commentResult = ensureComment()
|
|
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true)
|
|
if err != nil {
|
|
if saved != nil && saved.Pending {
|
|
result := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
|
|
result["favorite_id"] = favoriteID
|
|
result["client_mid"] = clientMid
|
|
mergeForwardCommentResult(result, commentResult)
|
|
results = append(results, result)
|
|
continue
|
|
}
|
|
if msg.SeqId > 0 && relationErrorReason(err) == imReasonInternal {
|
|
result := forwardResultUnknownPayload(target, msg.SeqId, relationErrorReason(err))
|
|
result["favorite_id"] = favoriteID
|
|
result["client_mid"] = clientMid
|
|
mergeForwardCommentResult(result, commentResult)
|
|
results = append(results, result)
|
|
continue
|
|
}
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
if saved != nil && saved.Pending {
|
|
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"}
|
|
result["client_mid"] = clientMid
|
|
mergeForwardCommentResult(result, commentResult)
|
|
results = append(results, result)
|
|
continue
|
|
}
|
|
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId, "client_mid": clientMid}
|
|
mergeForwardCommentResult(result, commentResult)
|
|
results = append(results, result)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": results})
|
|
}
|
|
|
|
func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imMessagesForwardRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
mode := strings.TrimSpace(body.Mode)
|
|
if mode == "" {
|
|
mode = "separate"
|
|
}
|
|
if mode != "separate" && mode != "bundle" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "mode"})
|
|
return
|
|
}
|
|
if len(body.Sources) == 0 || len(body.Sources) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
sourceReadable := make(map[string]error)
|
|
targetWritable := make(map[string]error)
|
|
items := make([]map[string]any, 0, len(body.TargetTopics)*len(body.Sources))
|
|
failed := make([]map[string]any, 0)
|
|
for _, rawTarget := range body.TargetTopics {
|
|
target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
|
|
if target == "" {
|
|
for _, source := range body.Sources {
|
|
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), strings.TrimSpace(rawTarget), imReasonMalformedRequest))
|
|
}
|
|
continue
|
|
}
|
|
if !targetOK {
|
|
for _, source := range body.Sources {
|
|
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), target, imReasonMalformedRequest))
|
|
}
|
|
continue
|
|
}
|
|
targetErr, ok := targetWritable[target]
|
|
if !ok {
|
|
targetErr = requireTopicWriter(principal, target)
|
|
targetWritable[target] = targetErr
|
|
}
|
|
if targetErr != nil {
|
|
for _, source := range body.Sources {
|
|
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), target, relationErrorReason(targetErr)))
|
|
}
|
|
continue
|
|
}
|
|
comment := strings.TrimSpace(body.Comment)
|
|
var commentResult map[string]any
|
|
commentSaved := false
|
|
ensureComment := func() map[string]any {
|
|
if commentSaved || comment == "" {
|
|
return commentResult
|
|
}
|
|
commentSaved = true
|
|
commentResult = saveForwardCommentMessage(req.Context(), principal, target, comment, forwardClientMid(req.Header.Get("Idempotency-Key"), "message-comment", target))
|
|
return commentResult
|
|
}
|
|
if mode == "bundle" {
|
|
bundle, sourceFailures := buildForwardBundle(principal, body.Sources, target, sourceReadable, "")
|
|
if len(sourceFailures) > 0 {
|
|
failed = append(failed, sourceFailures...)
|
|
continue
|
|
}
|
|
clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "bundle", target, len(body.Sources), body.Comment)
|
|
forwarded := &types.Message{
|
|
TenantID: principal.TenantID,
|
|
Topic: target,
|
|
From: principal.Uid.String(),
|
|
Head: forwardBundleMessageHead(clientMid),
|
|
Content: bundle,
|
|
}
|
|
if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil {
|
|
failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
commentResult = ensureComment()
|
|
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
|
|
if err != nil {
|
|
if saved != nil && saved.Pending {
|
|
forwarded.SeqId = saved.SeqId
|
|
item := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
|
|
item["sources"] = bundle["source_refs"]
|
|
item["client_mid"] = clientMid
|
|
item["mode"] = "bundle"
|
|
item["bundle_id"] = bundle["bundle_id"]
|
|
mergeForwardCommentResult(item, commentResult)
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
if forwarded.SeqId > 0 && relationErrorReason(err) == imReasonInternal {
|
|
item := forwardResultUnknownPayload(target, forwarded.SeqId, relationErrorReason(err))
|
|
item["sources"] = bundle["source_refs"]
|
|
item["client_mid"] = clientMid
|
|
item["mode"] = "bundle"
|
|
item["bundle_id"] = bundle["bundle_id"]
|
|
mergeForwardCommentResult(item, commentResult)
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
if saved != nil && saved.Pending {
|
|
item := map[string]any{
|
|
"sources": bundle["source_refs"],
|
|
"target_topic": target,
|
|
"client_mid": clientMid,
|
|
"seq": saved.SeqId,
|
|
"status": "pending",
|
|
"reason": "MESSAGE_PERSIST_PENDING",
|
|
"mode": "bundle",
|
|
"bundle_id": bundle["bundle_id"],
|
|
}
|
|
mergeForwardCommentResult(item, commentResult)
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
forwarded.SeqId = saved.SeqId
|
|
broadcastHTTPForwardMessage(principal.TenantID, forwarded)
|
|
item := map[string]any{
|
|
"sources": bundle["source_refs"],
|
|
"target_topic": target,
|
|
"client_mid": clientMid,
|
|
"seq": saved.SeqId,
|
|
"status": "sent",
|
|
"mode": "bundle",
|
|
"bundle_id": bundle["bundle_id"],
|
|
}
|
|
mergeForwardCommentResult(item, commentResult)
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
for _, source := range body.Sources {
|
|
sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic)
|
|
if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK {
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, imReasonMalformedRequest))
|
|
continue
|
|
}
|
|
readErr, ok := sourceReadable[sourceTopic]
|
|
if !ok {
|
|
readErr = requireTopicReader(principal, sourceTopic)
|
|
sourceReadable[sourceTopic] = readErr
|
|
}
|
|
if readErr != nil {
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(readErr)))
|
|
continue
|
|
}
|
|
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, int64(source.Seq))
|
|
if err != nil {
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
if msg == nil {
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "NOT_FOUND"))
|
|
continue
|
|
}
|
|
if messageAvailability(*msg) != "available" {
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "MESSAGE_UNAVAILABLE"))
|
|
continue
|
|
}
|
|
clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "separate", target, sourceTopic, int64(source.Seq))
|
|
forwarded := &types.Message{
|
|
TenantID: principal.TenantID,
|
|
Topic: target,
|
|
From: principal.Uid.String(),
|
|
Head: forwardedMessageHead(msg, clientMid),
|
|
Content: forwardedMessageContent(msg, ""),
|
|
}
|
|
if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil {
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
commentResult = ensureComment()
|
|
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
|
|
if err != nil {
|
|
if saved != nil && saved.Pending {
|
|
forwarded.SeqId = saved.SeqId
|
|
item := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
|
|
item["source"] = map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}
|
|
item["client_mid"] = clientMid
|
|
mergeForwardCommentResult(item, commentResult)
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
if forwarded.SeqId > 0 && relationErrorReason(err) == imReasonInternal {
|
|
item := forwardResultUnknownPayload(target, forwarded.SeqId, relationErrorReason(err))
|
|
item["source"] = map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}
|
|
item["client_mid"] = clientMid
|
|
mergeForwardCommentResult(item, commentResult)
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
if saved != nil && saved.Pending {
|
|
item := map[string]any{
|
|
"source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)},
|
|
"target_topic": target,
|
|
"client_mid": clientMid,
|
|
"seq": saved.SeqId,
|
|
"status": "pending",
|
|
"reason": "MESSAGE_PERSIST_PENDING",
|
|
}
|
|
mergeForwardCommentResult(item, commentResult)
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
forwarded.SeqId = saved.SeqId
|
|
broadcastHTTPForwardMessage(principal.TenantID, forwarded)
|
|
item := map[string]any{
|
|
"source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)},
|
|
"target_topic": target,
|
|
"client_mid": clientMid,
|
|
"seq": saved.SeqId,
|
|
"status": "sent",
|
|
}
|
|
mergeForwardCommentResult(item, commentResult)
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "failed": failed})
|
|
}
|
|
|
|
func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(req.URL.Path, "/draft"):
|
|
serveIMTopicDraft(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/calls"):
|
|
serveIMTopicCalls(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/message-mutations"):
|
|
serveIMMessageMutations(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/messages/search"):
|
|
serveIMMessageSearch(wrt, req)
|
|
case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"):
|
|
serveIMMessageContext(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/media"):
|
|
serveIMTopicMedia(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/receipts"):
|
|
serveIMMessageReceipts(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/messages/recall"):
|
|
serveIMMessageBatchRecall(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/recall"):
|
|
serveIMMessageRecall(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/revisions"):
|
|
serveIMMessageRevision(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message action route not found", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMTopicDraft(wrt http.ResponseWriter, req *http.Request) {
|
|
topic := topicFromDraftPath(req.URL.Path)
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
draft, err := store.Conversations.GetDraft(principal.TenantID, principal.Uid, topic)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "content": "", "entities": []any{}, "version": 0})
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, topicDraftPayload(draft))
|
|
case http.MethodPut:
|
|
var body imTopicDraftRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
encoded, err := json.Marshal(body.Entities)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "entities"})
|
|
return
|
|
}
|
|
if int64(len([]byte(body.Content))) > globals.maxMessageSize {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
|
|
return
|
|
}
|
|
draft, err := store.Conversations.PutDraft(principal.TenantID, principal.Uid, topic, body.Content, encoded, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, topicDraftPayload(draft))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMCalls(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, "", strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMTopicCalls(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromCallsPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessagesSearchPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, messageSearchPayload(msg, strings.TrimSpace(req.URL.Query().Get("q"))))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageMutations(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessageMutationsPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
sinceVersion, err := parseIMMutationSince(req.URL.Query().Get("since_version"), req.URL.Query().Get("since"))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "since_version"})
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
mutations, nextCursor, hasMore, mutationVersion, err := store.Messages.Mutations(principal.TenantID, topic, principal.Uid, sinceVersion, limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(mutations))
|
|
for _, mutation := range mutations {
|
|
items = append(items, messageMutationEventPayload(mutation))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"items": items,
|
|
"next_cursor": nextCursor,
|
|
"has_more": hasMore,
|
|
"mutation_version": mutationVersion,
|
|
"expired": false,
|
|
})
|
|
}
|
|
|
|
func enqueueIMP2PMessageMutationEvents(tenantID types.TenantID, topic string, actor types.Uid, seq int64, version int, mutationEvent string) error {
|
|
if tenantID.IsZero() || topic == "" || actor.IsZero() || seq <= 0 || version <= 0 || mutationEvent == "" {
|
|
return types.ErrMalformed
|
|
}
|
|
uid1, uid2, err := types.ParseP2P(topic)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
payload := map[string]any{
|
|
"topic": topic,
|
|
"seq": seq,
|
|
"version": version,
|
|
"event": mutationEvent,
|
|
}
|
|
for _, uid := range []types.Uid{uid1, uid2} {
|
|
if uid.IsZero() {
|
|
continue
|
|
}
|
|
eventID := stableIMEventID("message-mutation", topic, seq, version, mutationEvent, uid.UserId())
|
|
if err := enqueueIMBusinessEvent(tenantID, eventID, "user", uid.UserId(), actor, "message.mutation", payload); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
topic, seq, ok := messageContextPath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
|
|
return
|
|
}
|
|
principal, normalizedTopic, ok := authenticateTopicReader(wrt, req, topic)
|
|
if !ok {
|
|
return
|
|
}
|
|
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
|
|
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
|
|
msgs, err := store.Messages.GetAll(principal.TenantID, normalizedTopic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, messageContextPayload(msg))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": normalizedTopic, "anchor_seq": seq, "items": items})
|
|
}
|
|
|
|
func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMediaPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
mediaType := strings.TrimSpace(req.URL.Query().Get("type"))
|
|
if mediaType == "" {
|
|
mediaType = "image"
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), mediaType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, mediaMessagePayload(msg))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageReceiptsPath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message receipt route not found", nil)
|
|
return
|
|
}
|
|
if seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "seq"})
|
|
return
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 100, 200)
|
|
cursor, _ := strconv.Atoi(req.URL.Query().Get("cursor"))
|
|
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: limit + 1})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
readItems := make([]map[string]any, 0, limit)
|
|
deliveredItems := make([]map[string]any, 0, limit)
|
|
nextCursor := 0
|
|
for i, member := range members {
|
|
if i < cursor {
|
|
continue
|
|
}
|
|
if len(readItems) >= limit && len(deliveredItems) >= limit {
|
|
nextCursor = i
|
|
break
|
|
}
|
|
item := receiptMemberPayload(member)
|
|
readReceipts := false
|
|
memberID := types.ZeroUid
|
|
if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 {
|
|
memberID = types.Uid(decoded)
|
|
} else {
|
|
memberID = types.ParseUid(member.User)
|
|
}
|
|
if !memberID.IsZero() {
|
|
if privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, memberID); privacyErr == nil {
|
|
readReceipts = privacy.ReadReceipts
|
|
}
|
|
}
|
|
if readReceipts && member.ReadSeqId >= seq && len(readItems) < limit {
|
|
readItems = append(readItems, item)
|
|
}
|
|
if member.RecvSeqId >= seq && len(deliveredItems) < limit {
|
|
deliveredItems = append(deliveredItems, item)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"seq": seq,
|
|
"read": readItems,
|
|
"delivered": deliveredItems,
|
|
"next_cursor": nextCursor,
|
|
})
|
|
}
|
|
|
|
// broadcastIMMessageRecalled pushes the native {pres what:"del" delseq} frame to
|
|
// the sessions currently attached to the topic so peers learn about a recall in
|
|
// real time (the REST response alone reaches only the caller). The wire record
|
|
// carries "low" only (no "hi"): client SDKs then flush the message from their
|
|
// local cache and leave a content-less tombstone behind, which the webapp
|
|
// renders as the usual "recalled" placeholder. Routed through the hub so the
|
|
// p2p topic rewrites the frame's topic name to each recipient's own view.
|
|
func broadcastIMMessageRecalled(tenantID types.TenantID, topic string, seqs []int64, actor types.Uid) {
|
|
if globals.hub == nil || topic == "" || actor.IsZero() {
|
|
return
|
|
}
|
|
delseq := make([]MsgRange, 0, len(seqs))
|
|
for _, seq := range seqs {
|
|
if seq > 0 {
|
|
delseq = append(delseq, MsgRange{LowId: seq})
|
|
}
|
|
}
|
|
if len(delseq) == 0 {
|
|
return
|
|
}
|
|
// P2P topics are loaded under their canonical name with xoriginal cleared,
|
|
// so the presence frame must carry an empty topic name to pass the
|
|
// handlePresence xoriginal check; any other category uses the topic name.
|
|
presTopic := topic
|
|
if types.GetTopicCat(topic) == types.TopicCatP2P {
|
|
presTopic = ""
|
|
}
|
|
select {
|
|
case globals.hub.routeSrv <- &ServerComMessage{
|
|
TenantID: tenantID,
|
|
RcptTo: topic,
|
|
Pres: &MsgServerPres{
|
|
Topic: presTopic,
|
|
What: "del",
|
|
Src: actor.UserId(),
|
|
DelSeq: delseq,
|
|
FilterIn: int(types.ModeRead),
|
|
},
|
|
}:
|
|
default:
|
|
logs.Warn.Printf("im message recall: hub presence queue full topic=%s count=%d", topic, len(delseq))
|
|
}
|
|
}
|
|
|
|
func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageActionPath(req.URL.Path, "recall")
|
|
if !ok || seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
var body imMessageRecallRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, true)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !authorized {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required", nil)
|
|
return
|
|
}
|
|
if time.Since(msg.CreatedAt) > 120*time.Second {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired", nil)
|
|
return
|
|
}
|
|
version := body.Version
|
|
if version <= 0 {
|
|
version = msg.Version
|
|
}
|
|
recalledAt := time.Now().UTC().Round(time.Millisecond)
|
|
updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, version, recalledAt)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
eventID := stableIMEventID("message", topic, seq, updated.Version, "recalled")
|
|
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
|
|
"message.recalled", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
|
|
logs.Warn.Printf("im message recall: failed to enqueue business event tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
|
|
} else if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, seq, updated.Version, "message.recalled"); err != nil {
|
|
logs.Warn.Printf("im message recall: failed to enqueue p2p mutation events tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
|
|
}
|
|
}
|
|
broadcastIMMessageRecalled(principal.TenantID, updated.Topic, []int64{seq}, principal.Uid)
|
|
payload := messageMutationPayload(updated, "recalled")
|
|
if err != nil {
|
|
payload["event_status"] = "enqueue_failed"
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMMessageBatchRecall(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, ok := topicMessagesActionPath(req.URL.Path, "recall")
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
var body imMessageBatchRecallRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.Items) == 0 || len(body.Items) > 50 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "items must contain 1-50 messages", nil)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(body.Items))
|
|
recalledSeqs := make([]int64, 0, len(body.Items))
|
|
recalledAt := time.Now().UTC().Round(time.Millisecond)
|
|
for _, item := range body.Items {
|
|
if item.Seq <= 0 {
|
|
items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request"))
|
|
continue
|
|
}
|
|
msg, authorized, err := authorizeIMMessageMutation(principal, topic, item.Seq, true)
|
|
if err != nil {
|
|
items = append(items, messageMutationErrorItem(item.Seq, err))
|
|
continue
|
|
}
|
|
if !authorized {
|
|
items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required"))
|
|
continue
|
|
}
|
|
if time.Since(msg.CreatedAt) > 120*time.Second {
|
|
items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired"))
|
|
continue
|
|
}
|
|
version := item.Version
|
|
if version <= 0 {
|
|
version = msg.Version
|
|
}
|
|
updated, err := store.Messages.Recall(principal.TenantID, topic, item.Seq, principal.Uid, version, recalledAt)
|
|
if err != nil {
|
|
items = append(items, messageMutationErrorItem(item.Seq, err))
|
|
continue
|
|
}
|
|
result := messageMutationPayload(updated, "recalled")
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
eventID := stableIMEventID("message", topic, item.Seq, updated.Version, "recalled")
|
|
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
|
|
"message.recalled", map[string]any{"topic": topic, "seq": item.Seq, "version": updated.Version}); err != nil {
|
|
result["event_status"] = "enqueue_failed"
|
|
} else if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, item.Seq, updated.Version, "message.recalled"); err != nil {
|
|
result["event_status"] = "enqueue_failed"
|
|
}
|
|
}
|
|
items = append(items, result)
|
|
recalledSeqs = append(recalledSeqs, item.Seq)
|
|
}
|
|
broadcastIMMessageRecalled(principal.TenantID, topic, recalledSeqs, principal.Uid)
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items})
|
|
}
|
|
|
|
func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageActionPath(req.URL.Path, "revisions")
|
|
if !ok || seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
var body imMessageRevisionRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.Version <= 0 || body.Content == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version/content"})
|
|
return
|
|
}
|
|
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, false)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !authorized {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit permission required", nil)
|
|
return
|
|
}
|
|
if time.Since(msg.CreatedAt) > 300*time.Second {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit window expired", nil)
|
|
return
|
|
}
|
|
if !isIMEditableTextMessage(msg) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "only text messages can be edited", nil)
|
|
return
|
|
}
|
|
head := body.Head
|
|
if head == nil {
|
|
head = types.KVMap{}
|
|
}
|
|
head["type"] = "text"
|
|
if body.ClientMid != "" {
|
|
head["x-im-client-mid"] = body.ClientMid
|
|
}
|
|
scene := moderation.ScenePrivateMessage
|
|
if strings.HasPrefix(topic, "grp") {
|
|
scene = moderation.SceneGroupMessage
|
|
}
|
|
if !reviewIMHTTPText(wrt, req, principal, scene, topic, moderation.ExtractText(body.Content)) {
|
|
return
|
|
}
|
|
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 {
|
|
logs.Warn.Printf("im message edit: failed to enqueue business event tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
|
|
} else if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, seq, updated.Version, "message.edited"); err != nil {
|
|
logs.Warn.Printf("im message edit: failed to enqueue p2p mutation events tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
|
|
}
|
|
}
|
|
payload := messageMutationPayload(updated, "edited")
|
|
if err != nil {
|
|
payload["event_status"] = "enqueue_failed"
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, tail := conversationResourceFromPath(req.URL.Path)
|
|
if topic == "settings" && tail == "" {
|
|
serveIMConversationSettingsBatch(wrt, req, principal)
|
|
return
|
|
}
|
|
if tail == "settings" {
|
|
serveIMConversationSettings(wrt, req, principal, topic)
|
|
return
|
|
}
|
|
if tail == "messages" {
|
|
serveIMConversationMessages(wrt, req, principal, topic)
|
|
return
|
|
}
|
|
if tail != "context" {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "conversation route not found", nil)
|
|
return
|
|
}
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
topic, peer, err := resolveConversationContextTopic(principal, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if topic == "" || peer.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
|
}
|
|
|
|
func serveIMConversationSettingsBatch(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal) {
|
|
var topics []string
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
for _, part := range strings.Split(req.URL.Query().Get("topics"), ",") {
|
|
if value := strings.TrimSpace(part); value != "" {
|
|
topics = append(topics, value)
|
|
}
|
|
}
|
|
case http.MethodPost:
|
|
var body struct {
|
|
Topics []string `json:"topics"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
topics = body.Topics
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
topics = cleanStringList(topics, 100)
|
|
if len(topics) == 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topics"})
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(topics))
|
|
for _, rawTopic := range topics {
|
|
topic := normalizeConversationSettingsTopic(rawTopic, principal.Uid)
|
|
if topic == "" {
|
|
continue
|
|
}
|
|
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items = append(items, conversationSettingsPayload(settings))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items})
|
|
}
|
|
|
|
func serveIMConversationMessages(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false})
|
|
case http.MethodDelete:
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonForbidden, "permission denied", nil)
|
|
return
|
|
}
|
|
top, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if top == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "conversation not found", nil)
|
|
return
|
|
}
|
|
delID := top.DelId + 1
|
|
if delID <= 0 {
|
|
delID = int(time.Now().Unix())
|
|
}
|
|
var ranges []types.Range
|
|
if top.SeqId > 0 {
|
|
ranges = []types.Range{{Low: 1, Hi: top.SeqId + 1}}
|
|
}
|
|
if err = store.Messages.DeleteList(principal.TenantID, topic, delID, principal.Uid, 0, ranges); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "cleared": true, "deleted_id": delID})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
|
|
case http.MethodPatch:
|
|
var body imConversationSettingsRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
muted := body.Muted
|
|
if muted == nil {
|
|
muted = body.MuteNotifications
|
|
}
|
|
patch := types.ConversationSettingsPatch{
|
|
Pinned: body.Pinned,
|
|
Muted: muted,
|
|
Remark: body.Remark,
|
|
MessageRetention: body.MessageRetention,
|
|
Version: body.Version,
|
|
}
|
|
settings, err := store.Conversations.UpdateSettings(principal.TenantID, principal.Uid, topic, patch)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
serveIMFriendRequestList(wrt, req)
|
|
case http.MethodPost:
|
|
serveIMFriendRequestCreate(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFriendRequestList(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
items, nextCursor, err := store.Relations.ListFriendRequests(principal.TenantID, principal.Uid,
|
|
strings.TrimSpace(req.URL.Query().Get("direction")),
|
|
strings.TrimSpace(req.URL.Query().Get("status")),
|
|
strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
payload = append(payload, friendRequestListItemPayload(item))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFriendRequestCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
target := types.ParseUserId(body.TargetUserID)
|
|
message := strings.TrimSpace(body.Message)
|
|
if target.IsZero() || target == principal.Uid || len([]rune(message)) > 50 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
relation, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, target, principal.Uid.P2PName(target))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if relation == nil || relation.Relationship != types.RelationshipNone {
|
|
reason := "FRIEND_REQUEST_CONFLICT"
|
|
if relation != nil && (relation.Relationship == types.RelationshipBlockedByMe || relation.Relationship == types.RelationshipBlockedByPeer) {
|
|
reason = imReasonPermissionDenied
|
|
}
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, reason, "friend request is not allowed for current relationship", nil)
|
|
return
|
|
}
|
|
source := strings.TrimSpace(body.Source)
|
|
sourceTopic := strings.TrimSpace(body.SourceTopic)
|
|
switch source {
|
|
case "search", "qr":
|
|
if sourceTopic != "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "source_topic is only valid for group source", nil)
|
|
return
|
|
}
|
|
case "group":
|
|
if types.GetTopicCat(sourceTopic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "valid group source_topic is required", nil)
|
|
return
|
|
}
|
|
requesterSub, requesterErr := store.Subs.Get(principal.TenantID, sourceTopic, principal.Uid, false)
|
|
targetSub, targetErr := store.Subs.Get(principal.TenantID, sourceTopic, target, false)
|
|
if requesterErr != nil || targetErr != nil {
|
|
if requesterErr != nil {
|
|
writeIMRelationError(wrt, req, requesterErr)
|
|
} else {
|
|
writeIMRelationError(wrt, req, targetErr)
|
|
}
|
|
return
|
|
}
|
|
if requesterSub == nil || targetSub == nil ||
|
|
!(requesterSub.ModeGiven & requesterSub.ModeWant).IsJoiner() || !(targetSub.ModeGiven & targetSub.ModeWant).IsJoiner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "group membership required", nil)
|
|
return
|
|
}
|
|
settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, sourceTopic)
|
|
if settingsErr != nil {
|
|
writeIMRelationError(wrt, req, settingsErr)
|
|
return
|
|
}
|
|
if settings == nil || !settings.MemberPrivateChat {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member private chat is disabled", nil)
|
|
return
|
|
}
|
|
default:
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil)
|
|
return
|
|
}
|
|
if decision := imFriendAddPolicyDecision(principal.TenantID, "", principal.Uid, source); !decision.Allowed {
|
|
writeIMPolicyDecision(wrt, req, decision)
|
|
return
|
|
}
|
|
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !imPrivacyAllowsAdd(privacy, body.Source) {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "user does not accept friend requests from this source", nil)
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
requestID := "frq_" + store.Store.GetUidString()
|
|
created, err := store.Relations.CreateFriendRequest(principal.TenantID, &types.FriendRequest{
|
|
TenantID: principal.TenantID,
|
|
RequestID: requestID,
|
|
ApplicantID: principal.Uid,
|
|
TargetID: target,
|
|
Message: message,
|
|
Source: source,
|
|
SourceTopic: sourceTopic,
|
|
Status: types.FriendRequestPending,
|
|
IdempotencyKey: idempotencyKey,
|
|
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !privacy.FriendRequestVerification {
|
|
accepted, acceptErr := store.Relations.ResolveFriendRequest(principal.TenantID, created.RequestID, target, types.FriendRequestAccepted, now)
|
|
if acceptErr != nil {
|
|
writeIMRelationError(wrt, req, acceptErr)
|
|
return
|
|
}
|
|
created = accepted
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
eventType := "friend.request.created"
|
|
title := "新的好友申请"
|
|
content := message
|
|
if created != nil && created.Status == types.FriendRequestAccepted {
|
|
eventType = "friend.request.accepted"
|
|
title = "已成为好友"
|
|
content = "你们已成为好友,现在可以开始聊天了。"
|
|
publishIMConversationSystemNotice(principal.TenantID, principal.Uid.P2PName(target),
|
|
"system_notice", "friend_added", content,
|
|
[]imSystemMessageActor{
|
|
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
|
|
{UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}},
|
|
}, nil, map[string]any{"request_id": created.RequestID})
|
|
}
|
|
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, target, created.RequestID,
|
|
eventType, title, content); err != nil {
|
|
logs.Warn.Printf("im friend request create: relation event failed request_id=%s friend_request_id=%s target=%s: %v",
|
|
req.Header.Get("X-Request-ID"), created.RequestID, target.UserId(), err)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, friendRequestPayload(created))
|
|
}
|
|
|
|
func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid,
|
|
strings.TrimSpace(req.URL.Query().Get("query")),
|
|
strings.TrimSpace(req.URL.Query().Get("initial")),
|
|
strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
if item.Friendship.PeerID == principal.Uid {
|
|
continue
|
|
}
|
|
payload = append(payload, friendListItemPayload(principal.TenantID, principal.Uid, item))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMGroups(wrt http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
serveIMGroupList(wrt, req)
|
|
case http.MethodPost:
|
|
serveIMGroupCreate(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
category := strings.TrimSpace(req.URL.Query().Get("category"))
|
|
switch category {
|
|
case "", "owned", "managed", "joined":
|
|
default:
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "category"})
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
query := strings.TrimSpace(req.URL.Query().Get("query"))
|
|
subs, err := store.Users.GetSubs(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
seen := 0
|
|
nextCursor := ""
|
|
for _, sub := range subs {
|
|
if types.GetTopicCat(sub.Topic) != types.TopicCatGrp {
|
|
continue
|
|
}
|
|
item := groupListItemPayload(sub)
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, sub.Topic); err == nil && settings != nil {
|
|
if settings.GroupStatus == types.GroupStatusDissolved {
|
|
// Defensive check: hide dissolved groups even if their subscription cleanup lagged.
|
|
continue
|
|
}
|
|
item = mergeGroupListSettingsPayload(item, settings)
|
|
}
|
|
if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category {
|
|
continue
|
|
}
|
|
if query != "" && !groupListItemMatches(item, query) {
|
|
continue
|
|
}
|
|
if seen < cursor {
|
|
seen++
|
|
continue
|
|
}
|
|
if len(items) == limit {
|
|
nextCursor = strconv.Itoa(seen)
|
|
break
|
|
}
|
|
items = append(items, item)
|
|
seen++
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
|
|
}
|
|
|
|
func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imGroupCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if len([]rune(name)) < 1 || len([]rune(name)) > 32 || len(body.MemberUserIDs) > 99 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
description := strings.TrimSpace(body.Description)
|
|
if len([]rune(description)) > 200 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupName, "", name) {
|
|
return
|
|
}
|
|
topicName := genTopicName()
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
public := map[string]any{"name": name, "fn": name, "version": 1}
|
|
if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" {
|
|
public["avatar_file_id"] = avatar
|
|
}
|
|
if description != "" {
|
|
public["description"] = description
|
|
}
|
|
topic := &types.Topic{
|
|
ObjHeader: types.ObjHeader{Id: topicName},
|
|
TenantID: principal.TenantID,
|
|
State: types.StateOK,
|
|
Access: types.DefaultAccess{Auth: types.ModeCPublic, Anon: types.ModeNone},
|
|
Public: public,
|
|
}
|
|
topic.GiveAccess(principal.Uid, types.ModeCFull, types.ModeCFull)
|
|
settings := &types.GroupSettings{
|
|
Topic: topicName,
|
|
OwnerUserID: principal.Uid,
|
|
Name: name,
|
|
AvatarFileID: strings.TrimSpace(body.AvatarFileID),
|
|
Description: description,
|
|
GroupStatus: types.GroupStatusActive,
|
|
MemberUploadFiles: true,
|
|
MemberPrivateChat: true,
|
|
MemberInvite: true,
|
|
MaxAdminCount: groupMaxAdminCount,
|
|
MemberCount: 1,
|
|
Version: 1,
|
|
CreatedBy: principal.Uid,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if settings.AvatarFileID != "" {
|
|
if err := linkIMGroupAvatarAttachment(principal.TenantID, topicName, settings.AvatarFileID); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"})
|
|
return
|
|
}
|
|
}
|
|
memberIDs := make([]types.Uid, 0, len(body.MemberUserIDs))
|
|
seen := map[types.Uid]bool{principal.Uid: true}
|
|
for _, rawID := range body.MemberUserIDs {
|
|
uid := types.ParseUserId(strings.TrimSpace(rawID))
|
|
if uid.IsZero() || seen[uid] {
|
|
continue
|
|
}
|
|
seen[uid] = true
|
|
memberIDs = append(memberIDs, uid)
|
|
}
|
|
memberPrivate := groupMemberPrivate("", false, false, 1, now)
|
|
if err := store.GroupBusiness.CreateGroup(principal.TenantID, topic, principal.Uid, memberPrivate,
|
|
settings, memberIDs, memberPrivate, now); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topicName, principal.Uid, "group.created",
|
|
map[string]any{"topic": topicName, "member_user_ids": userIDs(memberIDs)})
|
|
created, err := store.Topics.Get(principal.TenantID, topicName)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
selfSub, err := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub))
|
|
}
|
|
|
|
func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
|
|
topic, tail, ok := groupResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
|
return
|
|
}
|
|
if tail == "settings" {
|
|
serveIMGroupSettings(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "member-settings" || tail == "members/me/settings" {
|
|
serveIMGroupMemberSettings(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "members/me/profile" {
|
|
serveIMGroupMyProfile(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "leave" {
|
|
serveIMGroupLeave(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "qrcodes" {
|
|
serveIMGroupQRCodes(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "invitations" {
|
|
serveIMGroupInvitations(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "join-requests" {
|
|
serveIMGroupJoinRequests(wrt, req, topic)
|
|
return
|
|
}
|
|
if strings.HasPrefix(tail, "join-requests/") {
|
|
serveIMGroupJoinRequestAction(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "summary" {
|
|
serveIMGroupSummary(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "announcements/latest" || tail == "announcements/current" || strings.HasPrefix(tail, "announcements/") {
|
|
serveIMGroupAnnouncements(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "files" || strings.HasPrefix(tail, "files/") {
|
|
serveIMGroupFiles(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "governance" {
|
|
serveIMGroupGovernance(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "role-limits" {
|
|
serveIMGroupRoleLimits(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "admins/batch" || strings.HasPrefix(tail, "admins/") {
|
|
serveIMGroupAdmins(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "owner-transfer" {
|
|
serveIMGroupOwnerTransfer(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "mutes" || tail == "mutes/batch" || strings.HasPrefix(tail, "mutes/") {
|
|
serveIMGroupMutes(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "members" {
|
|
serveIMGroupMembers(wrt, req, topic)
|
|
return
|
|
}
|
|
if strings.HasPrefix(tail, "members/") && !strings.HasSuffix(tail, "/profile") {
|
|
memberID := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "members/")))
|
|
if memberID.IsZero() {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
|
return
|
|
}
|
|
serveIMGroupMember(wrt, req, topic, memberID)
|
|
return
|
|
}
|
|
if strings.HasPrefix(tail, "members/") && strings.HasSuffix(tail, "/profile") {
|
|
memberID, ok := groupMemberProfilePath(tail)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
|
return
|
|
}
|
|
serveIMGroupMemberProfile(wrt, req, topic, memberID)
|
|
return
|
|
}
|
|
if tail == "" {
|
|
serveIMGroupDetail(wrt, req, topic)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
|
}
|
|
|
|
func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
selfAccess := types.ModeNone
|
|
if selfSub != nil {
|
|
selfAccess = selfSub.ModeGiven & selfSub.ModeWant
|
|
}
|
|
if selfSub == nil || !selfAccess.IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
|
|
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
|
|
if cursor != "" {
|
|
if _, err := strconv.ParseInt(cursor, 10, 64); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
}
|
|
if parsed, err := parseIMNonNegativeCursor(cursor); err != nil || parsed < 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
query := strings.TrimSpace(req.URL.Query().Get("query"))
|
|
mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable"))
|
|
roleFilter := strings.TrimSpace(req.URL.Query().Get("role"))
|
|
eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role"))
|
|
muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible"))
|
|
members, nextCursor, err := store.GroupBusiness.ListMembers(principal.TenantID, topic, types.GroupMemberListOptions{
|
|
Role: roleFilter,
|
|
EligibleRole: eligibleRole,
|
|
Query: query,
|
|
Cursor: cursor,
|
|
Limit: limit,
|
|
Mentionable: mentionableOnly,
|
|
})
|
|
if err != nil {
|
|
logs.Warn.Printf("im http: list group members failed tenant=%d topic=%s uid=%s limit=%d cursor=%q query=%q role=%q mentionable=%t err=%v",
|
|
principal.TenantID, topic, principal.Uid.UserId(), limit, cursor, query, roleFilter, mentionableOnly, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
remarks := groupMemberRemarkMap(principal.TenantID, principal.Uid, members)
|
|
hideFriendEntrypoint := !imFriendGlobalDisabledDecision(principal.TenantID, "", "group").Allowed
|
|
items := make([]map[string]any, 0, limit)
|
|
for _, member := range members {
|
|
access := groupMemberAccess(member)
|
|
item := groupBusinessMemberPayload(member, remarks[member.UserID])
|
|
if hideFriendEntrypoint && member.UserID != principal.Uid {
|
|
maskIMGroupMemberProfileEntrypoint(item)
|
|
}
|
|
if eligibleRole != "" {
|
|
eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole)
|
|
item["eligible"] = eligible
|
|
if reason != "" {
|
|
item["ineligible_reason"] = reason
|
|
}
|
|
}
|
|
if muteEligible {
|
|
eligible, reason := groupMuteEligibility(selfAccess, access)
|
|
item["eligible"] = eligible
|
|
if reason != "" {
|
|
item["ineligible_reason"] = reason
|
|
}
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
permissions := map[string]any{
|
|
"can_mention_all": selfAccess.IsAdmin(),
|
|
}
|
|
payload := map[string]any{
|
|
"topic": topic,
|
|
"items": items,
|
|
"next_cursor": nextCursor,
|
|
"sync_mode": "full",
|
|
"permissions": permissions,
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) {
|
|
if req.Method != http.MethodGet && req.Method != http.MethodDelete {
|
|
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).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
member, err := store.GroupBusiness.GetMember(principal.TenantID, topic, memberID)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if member == nil || member.MemberStatus != types.GroupMemberStatusActive {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
|
return
|
|
}
|
|
if req.Method == http.MethodDelete {
|
|
if ok, reason := canRemoveIMGroupMember(selfSub, principal.Uid, *member); !ok {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": memberID.UserId(), "reason": reason})
|
|
return
|
|
}
|
|
if err := store.GroupBusiness.Leave(principal.TenantID, topic, memberID, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.left",
|
|
map[string]any{"user_id": memberID.UserId(), "removed_by": principal.Uid.UserId(), "member_sync": "single"})
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "user_id": memberID.UserId(), "status": "left"})
|
|
return
|
|
}
|
|
memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
|
return
|
|
}
|
|
member.ModeWant = memberSub.ModeWant
|
|
member.ModeGiven = memberSub.ModeGiven
|
|
if user, userErr := store.Users.Get(principal.TenantID, memberID); userErr == nil && user != nil {
|
|
member.Public = user.Public
|
|
member.Trusted = user.Trusted
|
|
}
|
|
remarks := groupMemberRemarkMap(principal.TenantID, principal.Uid, []types.GroupMember{*member})
|
|
payload := groupBusinessMemberPayload(*member, remarks[member.UserID])
|
|
payload["topic"] = topic
|
|
payload["sync_mode"] = "single"
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
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
|
|
}
|
|
if req.Method == http.MethodGet {
|
|
if writeIMGroupDissolvedIfGone(wrt, req, principal.TenantID, 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
|
|
}
|
|
if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if group == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
payload := groupDetailPayload(group, selfSub)
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
|
|
if settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
|
|
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
|
|
return
|
|
}
|
|
payload = mergeGroupSettingsPayload(payload, settings)
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
case http.MethodPatch:
|
|
access := selfSub.ModeGiven & selfSub.ModeWant
|
|
if !access.IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupPatchRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if group == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
public := groupPublicMap(group.Public)
|
|
currentVersion := intFromAny(public["version"])
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil && settings != nil && settings.Version > 0 {
|
|
currentVersion = settings.Version
|
|
}
|
|
if currentVersion <= 0 {
|
|
currentVersion = 1
|
|
}
|
|
if body.Version > 0 && body.Version != currentVersion {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
|
|
return
|
|
}
|
|
if body.Name != nil {
|
|
name := strings.TrimSpace(*body.Name)
|
|
if len([]rune(name)) < 1 || len([]rune(name)) > 32 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "name"})
|
|
return
|
|
}
|
|
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupName, topic, name) {
|
|
return
|
|
}
|
|
public["name"] = name
|
|
public["fn"] = name
|
|
}
|
|
if body.AvatarFileID != nil {
|
|
avatar := strings.TrimSpace(*body.AvatarFileID)
|
|
if avatar != "" {
|
|
if err := linkIMGroupAvatarAttachment(principal.TenantID, topic, avatar); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"})
|
|
return
|
|
}
|
|
}
|
|
public["avatar_file_id"] = avatar
|
|
}
|
|
if body.Description != nil {
|
|
description := strings.TrimSpace(*body.Description)
|
|
if len([]rune(description)) > 200 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
public["description"] = description
|
|
}
|
|
public["version"] = currentVersion + 1
|
|
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": public}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
patch := types.GroupSettingsPatch{Name: body.Name, AvatarFileID: body.AvatarFileID, Description: body.Description, Version: body.Version}
|
|
settingsUpdated := true
|
|
if _, err = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch); err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
settingsUpdated = false
|
|
logs.Warn.Printf("group[%s]: business settings missing while patching group profile; kept topic public update", topic)
|
|
} else {
|
|
if rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil {
|
|
logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr)
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.settings.updated",
|
|
map[string]any{"topic": topic})
|
|
updated, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
payload := groupDetailPayload(updated, selfSub)
|
|
if settingsUpdated {
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
|
|
payload = mergeGroupSettingsPayload(payload, settings)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
case http.MethodDelete:
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
if !consumeIMActionTokenHeader(wrt, req, principal, "group_dissolve") {
|
|
return
|
|
}
|
|
var body imGroupDissolveRequest
|
|
if req.Body != nil && req.Body != http.NoBody {
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
}
|
|
settings, err := store.GroupBusiness.Dissolve(principal.TenantID, topic, principal.Uid, strings.TrimSpace(body.Reason), time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.dissolved",
|
|
map[string]any{"topic": topic})
|
|
payload := map[string]any{"topic": topic, "deleted": true}
|
|
if settings != nil {
|
|
payload["status"] = settings.GroupStatus
|
|
payload["version"] = settings.Version
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func writeIMGroupDissolvedIfGone(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) bool {
|
|
if settings, err := store.GroupBusiness.GetSettings(tenantID, topic); err == nil && settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
|
|
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
|
|
return true
|
|
}
|
|
group, err := store.Topics.Get(tenantID, topic)
|
|
if err != nil || group == nil || group.State != types.StateDeleted {
|
|
return false
|
|
}
|
|
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
|
|
return true
|
|
}
|
|
|
|
func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
payload := groupSettingsPayload(group, selfSub)
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
|
|
payload = mergeGroupSettingsPayload(payload, settings)
|
|
payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub)
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPatch {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imGroupMemberSettingsRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
private := groupPrivateMap(selfSub.Private)
|
|
currentVersion := intFromAny(private["version"])
|
|
if currentVersion <= 0 {
|
|
currentVersion = 1
|
|
}
|
|
if body.Version > 0 && body.Version != currentVersion {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
|
|
return
|
|
}
|
|
if body.Pinned != nil {
|
|
private["pinned"] = *body.Pinned
|
|
}
|
|
if body.MuteNotifications != nil {
|
|
private["mute_notifications"] = *body.MuteNotifications
|
|
}
|
|
private["version"] = currentVersion + 1
|
|
private["updated_at"] = time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano)
|
|
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Pinned: body.Pinned, MuteNotifications: body.MuteNotifications}); err != nil {
|
|
if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil {
|
|
logs.Warn.Printf("group[%s]: failed to roll back member settings: %v", topic, rollbackErr)
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated",
|
|
map[string]any{"user_id": principal.Uid.UserId()})
|
|
selfSub.Private = private
|
|
writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub))
|
|
}
|
|
|
|
func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPut && req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
private := groupPrivateMap(selfSub.Private)
|
|
currentVersion := intFromAny(private["profile_version"])
|
|
if currentVersion <= 0 {
|
|
currentVersion = 1
|
|
}
|
|
if req.Method == http.MethodGet {
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"nickname": stringFromAny(private["nickname"]),
|
|
"version": currentVersion,
|
|
"updated_at": stringFromAny(private["profile_updated_at"]),
|
|
})
|
|
return
|
|
}
|
|
var body imGroupMemberProfileUpdateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
nickname := strings.TrimSpace(body.Nickname)
|
|
if len([]rune(nickname)) > 32 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"})
|
|
return
|
|
}
|
|
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupNickname, topic, nickname) {
|
|
return
|
|
}
|
|
if body.Version > 0 && body.Version != currentVersion {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
|
|
return
|
|
}
|
|
private["nickname"] = nickname
|
|
private["profile_version"] = currentVersion + 1
|
|
updatedAt := time.Now().UTC().Round(time.Millisecond)
|
|
private["profile_updated_at"] = updatedAt.Format(time.RFC3339Nano)
|
|
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId())
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated",
|
|
map[string]any{"user_id": principal.Uid.UserId()})
|
|
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
|
|
return
|
|
}
|
|
if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil {
|
|
logs.Warn.Printf("group[%s]: failed to roll back member profile: %v", topic, rollbackErr)
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated",
|
|
map[string]any{"user_id": principal.Uid.UserId()})
|
|
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
|
|
}
|
|
|
|
func serveIMGroupLeave(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if (selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "owner must transfer or dismiss group before leaving", nil)
|
|
return
|
|
}
|
|
if err := store.GroupBusiness.Leave(principal.TenantID, topic, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.left",
|
|
map[string]any{"user_id": principal.Uid.UserId()})
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"})
|
|
}
|
|
|
|
func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsSharer() && !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupQRCodeCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
expiresIn := body.ExpiresInSeconds
|
|
if expiresIn <= 0 {
|
|
expiresIn = imQRCodeDefaultExpiresInSeconds
|
|
}
|
|
if expiresIn > imQRCodeMaxExpiresInSeconds {
|
|
expiresIn = imQRCodeMaxExpiresInSeconds
|
|
}
|
|
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
|
|
token, err := generateIMQRCodeTokenForTopic(principal.TenantID, principal.Uid, topic, "join_group", expiresAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
groupPayload := groupDetailPayload(group, selfSub)
|
|
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": groupPayload,
|
|
"group_snapshot": groupPayload,
|
|
})
|
|
}
|
|
|
|
func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
access := selfSub.ModeGiven & selfSub.ModeWant
|
|
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !access.IsAdmin() {
|
|
if settings == nil || !settings.MemberInvite {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member invitation is disabled", nil)
|
|
return
|
|
}
|
|
}
|
|
if !access.IsSharer() && !access.IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupInvitationCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.UserIDs))
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
for _, rawID := range body.UserIDs {
|
|
uid := types.ParseUserId(strings.TrimSpace(rawID))
|
|
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
|
|
if uid.IsZero() || uid == principal.Uid {
|
|
item["status"] = "failed"
|
|
item["reason"] = imReasonMalformedRequest
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
item["user_id"] = uid.UserId()
|
|
targetSub, err := store.Subs.Get(principal.TenantID, topic, uid, false)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
item["status"] = "failed"
|
|
item["reason"] = relationErrorReason(err)
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
if targetSub != nil && targetSub.DeletedAt == nil {
|
|
item["status"] = "already_member"
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
targetRequiresApproval := settings != nil && settings.JoinApproval
|
|
if !targetRequiresApproval && store.Profiles != nil {
|
|
privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, uid)
|
|
if privacyErr != nil {
|
|
if errors.Is(privacyErr, types.ErrNotFound) || errors.Is(privacyErr, types.ErrUnsupported) {
|
|
privacyErr = nil
|
|
}
|
|
}
|
|
if privacyErr != nil {
|
|
item["status"] = "failed"
|
|
item["reason"] = relationErrorReason(privacyErr)
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
targetRequiresApproval = privacy != nil && privacy.GroupInviteVerification
|
|
}
|
|
joinRequestID := ""
|
|
invitationID := ""
|
|
if targetRequiresApproval {
|
|
invitationID = "ginv_" + store.Store.GetUidString()
|
|
joinRequestID = "gjr_" + store.Store.GetUidString()
|
|
}
|
|
if !targetRequiresApproval {
|
|
joined, err := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, uid,
|
|
strings.TrimSpace(body.Message), strings.TrimSpace(req.Header.Get("Idempotency-Key")),
|
|
groupMemberPrivate("", false, false, 1, now), now)
|
|
if err != nil {
|
|
item["status"] = "failed"
|
|
item["reason"] = relationErrorReason(err)
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
if joined {
|
|
item["status"] = types.GroupInvitationStatusJoined
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.joined",
|
|
map[string]any{"user_id": uid.UserId(), "joined": true, "source": "direct_invitation"})
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
publishIMGroupMemberJoinedSystemNotice(principal.TenantID, topic, principal.Uid, uid,
|
|
"有成员通过邀请加入了群聊。",
|
|
map[string]any{"source": "direct_invitation"})
|
|
}
|
|
} else {
|
|
item["status"] = "already_member"
|
|
}
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey != "" {
|
|
idempotencyKey += ":" + uid.UserId()
|
|
} else {
|
|
idempotencyKey = invitationID
|
|
}
|
|
inv, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{
|
|
InvitationID: invitationID,
|
|
Topic: topic,
|
|
InviterUserID: principal.Uid,
|
|
TargetUserID: uid,
|
|
Message: strings.TrimSpace(body.Message),
|
|
Status: types.GroupInvitationStatusPending,
|
|
Result: "",
|
|
JoinRequestID: joinRequestID,
|
|
IdempotencyKey: idempotencyKey,
|
|
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
item["status"] = "failed"
|
|
item["reason"] = relationErrorReason(err)
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
item["invitation_id"] = inv.InvitationID
|
|
item["status"] = groupInvitationDisplayStatus(inv)
|
|
item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano)
|
|
if inv.JoinRequestID != "" {
|
|
item["join_request_id"] = inv.JoinRequestID
|
|
}
|
|
publishIMGroupInvitationNotification(principal.TenantID, inv)
|
|
results = append(results, item)
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.invitations.updated",
|
|
map[string]any{"topic": topic, "items": results})
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results})
|
|
}
|
|
|
|
func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
access := selfSub.ModeGiven & selfSub.ModeWant
|
|
switch {
|
|
case tail == "files" && req.Method == http.MethodGet:
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.GroupBusiness.ListFiles(principal.TenantID, topic,
|
|
strings.TrimSpace(req.URL.Query().Get("type")),
|
|
strings.TrimSpace(req.URL.Query().Get("q")),
|
|
strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"items": groupFilePayloads(items),
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
})
|
|
case tail == "files" && req.Method == http.MethodPost:
|
|
if !access.IsAdmin() {
|
|
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if settings == nil || !settings.MemberUploadFiles {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member file upload is disabled", nil)
|
|
return
|
|
}
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imGroupFileCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
fileID := normalizeIMUploadedFileID(body.FileID)
|
|
if fileID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
|
|
return
|
|
}
|
|
fileName := strings.TrimSpace(body.FileName)
|
|
mimeType := strings.TrimSpace(body.MIMEType)
|
|
fileType := normalizeIMFileType(body.FileType)
|
|
if fileName == "" || len([]rune(fileName)) > 255 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_name", "max_length": 255})
|
|
return
|
|
}
|
|
if len([]rune(mimeType)) > 128 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "mime_type", "max_length": 128})
|
|
return
|
|
}
|
|
if body.FileSize < 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_size"})
|
|
return
|
|
}
|
|
uploaded, err := store.Files.Get(principal.TenantID, fileID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if uploaded == nil || uploaded.Status != types.UploadCompleted {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonFileUnavailable, "file unavailable", map[string]any{"field": "file_id"})
|
|
return
|
|
}
|
|
if body.FileSize > 0 && uploaded.Size > 0 && body.FileSize != uploaded.Size {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_size"})
|
|
return
|
|
}
|
|
if globals.maxFileUploadSize > 0 && uploaded.Size > globals.maxFileUploadSize {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30046, "FILE_TOO_LARGE", "file too large", map[string]any{"field": "file_size", "max_size": globals.maxFileUploadSize})
|
|
return
|
|
}
|
|
if mimeType == "" {
|
|
mimeType = uploaded.MimeType
|
|
}
|
|
fileSize := body.FileSize
|
|
if fileSize == 0 {
|
|
fileSize = uploaded.Size
|
|
}
|
|
file, err := store.GroupBusiness.CreateFile(principal.TenantID, &types.GroupFile{
|
|
Topic: topic,
|
|
FileID: fileID,
|
|
SourceTopic: strings.TrimSpace(body.SourceTopic),
|
|
SourceSeq: body.SourceSeq,
|
|
UploaderID: principal.Uid,
|
|
FileName: fileName,
|
|
MIMEType: mimeType,
|
|
FileType: fileType,
|
|
FileSize: fileSize,
|
|
Status: "active",
|
|
IdempotencyKey: idempotencyKey,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileCreate, file, principal.Uid); err != nil {
|
|
logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err)
|
|
}
|
|
writeIMSuccess(wrt, req, groupFilePayload(file))
|
|
case strings.HasPrefix(tail, "files/") && req.Method == http.MethodDelete:
|
|
groupFileID := strings.TrimSpace(strings.TrimPrefix(tail, "files/"))
|
|
if groupFileID == "" || strings.Contains(groupFileID, "/") {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
|
|
return
|
|
}
|
|
canManage := access.IsAdmin() || access.IsOwner()
|
|
file, err := store.GroupBusiness.DeleteFile(principal.TenantID, topic, groupFileID, principal.Uid, canManage, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileDelete, file, principal.Uid); err != nil {
|
|
logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err)
|
|
}
|
|
writeIMSuccess(wrt, req, groupFilePayload(file))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request) {
|
|
invitationID, tail, ok := groupInvitationResourcePath(req.URL.Path)
|
|
if !ok || tail != "accept" {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group invitation route not found", nil)
|
|
return
|
|
}
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
inv, err := store.GroupBusiness.AcceptInvitation(principal.TenantID, invitationID, principal.Uid, groupMemberPrivate("", false, false, 1, now), now)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
publishIMConversationSystemNotice(principal.TenantID, inv.Topic,
|
|
"system_action", "group_member_joined", "有成员通过邀请加入了群聊。",
|
|
[]imSystemMessageActor{
|
|
{UserID: inv.InviterUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.InviterUserID.UserId()}},
|
|
{UserID: inv.TargetUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.TargetUserID.UserId()}},
|
|
},
|
|
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: inv.Topic}},
|
|
map[string]any{"invitation_id": inv.InvitationID, "source": "invitation"})
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, inv.Topic, principal.Uid, "group.member.joined",
|
|
map[string]any{"user_id": principal.Uid.UserId(), "invitation_id": inv.InvitationID})
|
|
writeIMSuccess(wrt, req, groupInvitationPayload(inv))
|
|
}
|
|
|
|
func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method == http.MethodPost {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
} else {
|
|
writeIMRelationError(wrt, req, err)
|
|
}
|
|
return
|
|
}
|
|
if settings == nil || settings.GroupStatus != types.GroupStatusActive {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
if !settings.JoinApproval {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
joined, joinErr := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, principal.Uid,
|
|
"", strings.TrimSpace(req.Header.Get("Idempotency-Key")), groupMemberPrivate("", false, false, 1, now), now)
|
|
if joinErr != nil {
|
|
writeIMRelationError(wrt, req, joinErr)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.joined",
|
|
map[string]any{"user_id": principal.Uid.UserId(), "joined": joined, "source": "open_group"})
|
|
if joined && imReliableHTTPEventsEnabled(req) {
|
|
publishIMGroupMemberJoinedSystemNotice(principal.TenantID, topic, principal.Uid, principal.Uid,
|
|
"有成员加入了群聊。",
|
|
map[string]any{"source": "open_group"})
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "joined", "joined": joined, "source": "open_group"})
|
|
return
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if sub != nil && (sub.ModeGiven & sub.ModeWant).IsJoiner() {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "ALREADY_GROUP_MEMBER", "already a group member", nil)
|
|
return
|
|
}
|
|
var body imGroupJoinRequestCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
requestID := "gjr_" + store.Store.GetUidString()
|
|
item, err := store.GroupBusiness.CreateJoinRequest(principal.TenantID, &types.GroupJoinRequest{
|
|
RequestID: requestID, Topic: topic, ApplicantUserID: principal.Uid,
|
|
Source: defaultString(strings.TrimSpace(body.Source), "manual"), Message: strings.TrimSpace(body.Message),
|
|
Status: types.GroupJoinRequestStatusPending, IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
|
|
Version: 1, CreatedAt: now, UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.join_request.created",
|
|
map[string]any{"request_id": item.RequestID, "user_id": principal.Uid.UserId()})
|
|
writeIMSuccess(wrt, req, groupJoinRequestPayload(item))
|
|
return
|
|
}
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if req.Method == http.MethodGet {
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
status := strings.TrimSpace(req.URL.Query().Get("status"))
|
|
if status == "" {
|
|
status = "pending"
|
|
}
|
|
items, nextCursor, err := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, status, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
|
|
if err != nil {
|
|
if !errors.Is(err, types.ErrNotFound) {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items = nil
|
|
}
|
|
items = dedupePendingGroupJoinRequests(items, status)
|
|
payloads, err := groupJoinRequestPayloadsWithUsers(principal.TenantID, items)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": payloads, "next_cursor": nextCursor})
|
|
}
|
|
}
|
|
|
|
func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
requestID, action, ok := groupJoinRequestActionPath(tail)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request route not found", nil)
|
|
return
|
|
}
|
|
var body imGroupJoinRequestActionRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
updated, err := store.GroupBusiness.ResolveJoinRequest(principal.TenantID, topic, requestID, principal.Uid,
|
|
action, "", body.Version, groupMemberPrivate("", false, false, 1, now), now)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if updated.Status == types.GroupJoinRequestStatusAccepted && imReliableHTTPEventsEnabled(req) {
|
|
publishIMConversationSystemNotice(principal.TenantID, topic,
|
|
"system_action", "group_member_joined", "有成员通过审批加入了群聊。",
|
|
[]imSystemMessageActor{
|
|
{UserID: updated.ApplicantUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantUserID.UserId()}},
|
|
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
|
|
},
|
|
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}},
|
|
map[string]any{"request_id": updated.RequestID, "source": "approval"})
|
|
}
|
|
if updated.Status == types.GroupJoinRequestStatusAccepted {
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.joined",
|
|
map[string]any{"request_id": updated.RequestID, "user_id": updated.ApplicantUserID.UserId(), "joined": true, "source": "approval"})
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.join_request."+updated.Status,
|
|
map[string]any{"request_id": updated.RequestID, "user_id": updated.ApplicantUserID.UserId(), "status": updated.Status})
|
|
writeIMSuccess(wrt, req, groupJoinRequestPayload(updated))
|
|
}
|
|
|
|
func serveIMGroupSummary(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
// 已解散的群按文档约定对客户端表现为不存在(RESOURCE_NOT_FOUND),不暴露解散状态。
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil &&
|
|
settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "group not found", nil)
|
|
return
|
|
}
|
|
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
access := types.ModeNone
|
|
if selfSub != nil {
|
|
access = selfSub.ModeGiven & selfSub.ModeWant
|
|
}
|
|
if selfSub == nil || !access.IsJoiner() || !access.IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
|
|
return
|
|
}
|
|
requests, _, _ := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, types.GroupJoinRequestStatusPending, "", 100)
|
|
announcement, _ := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"pending_join_request_count": len(requests),
|
|
"announcement": groupAnnouncementPayload(announcement),
|
|
"permissions": groupPermissions(access),
|
|
})
|
|
}
|
|
|
|
func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "announcements/latest" && req.Method == http.MethodGet:
|
|
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
logs.Warn.Println("im group announcement latest: returning empty after store error", principal.TenantID, topic, err)
|
|
ann = nil
|
|
}
|
|
writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, ann, selfSub))
|
|
case tail == "announcements/current" && req.Method == http.MethodPut:
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupAnnouncementPutRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
content := strings.TrimSpace(body.Content)
|
|
if content == "" || len([]rune(content)) > 2000 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
|
|
return
|
|
}
|
|
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupAnnouncement, topic, content) {
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
announcement, err := store.GroupBusiness.PutAnnouncement(principal.TenantID, &types.GroupAnnouncement{
|
|
AnnouncementID: "ann_" + store.Store.GetUidString(),
|
|
Topic: topic,
|
|
AuthorUserID: principal.Uid,
|
|
Content: content,
|
|
Status: types.GroupAnnouncementStatusCurrent,
|
|
PublishedAt: now,
|
|
}, body.Version)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if err = publishGroupAnnouncementTextMessage(req.Context(), principal.TenantID, topic, principal.Uid, announcement); err != nil {
|
|
logs.Warn.Println("im group announcement: failed to publish text message", principal.TenantID, topic, announcement.AnnouncementID, err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.announcement.updated",
|
|
map[string]any{"announcement_id": announcement.AnnouncementID, "topic": topic})
|
|
writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, announcement, selfSub))
|
|
case strings.HasPrefix(tail, "announcements/") && strings.HasSuffix(tail, "/read") && req.Method == http.MethodPost:
|
|
announcementID := strings.TrimSuffix(strings.TrimPrefix(tail, "announcements/"), "/read")
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if err := store.GroupBusiness.ReadAnnouncement(principal.TenantID, topic, announcementID, principal.Uid, now); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)})
|
|
case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet:
|
|
announcementID := strings.TrimSpace(strings.TrimPrefix(tail, "announcements/"))
|
|
if announcementID == "" || strings.Contains(announcementID, "/") {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "announcement_id"})
|
|
return
|
|
}
|
|
ann, err := store.GroupBusiness.GetAnnouncement(principal.TenantID, topic, announcementID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, ann, selfSub))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func publishGroupAnnouncementTextMessage(ctx context.Context, tenantID types.TenantID, topic string, author types.Uid, ann *types.GroupAnnouncement) error {
|
|
if ann == nil || strings.TrimSpace(topic) == "" || author.IsZero() {
|
|
return types.ErrMalformed
|
|
}
|
|
content := strings.TrimSpace(ann.Content)
|
|
if content == "" {
|
|
return types.ErrMalformed
|
|
}
|
|
if store.Messages == nil || store.Topics == nil {
|
|
return types.ErrMalformed
|
|
}
|
|
topicRecord, err := store.Topics.Get(tenantID, topic)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if topicRecord == nil {
|
|
return types.ErrNotFound
|
|
}
|
|
now := ann.PublishedAt
|
|
if now.IsZero() {
|
|
now = time.Now().UTC().Round(time.Millisecond)
|
|
}
|
|
msg := &types.Message{
|
|
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
|
|
TenantID: tenantID,
|
|
SeqId: topicRecord.SeqId + 1,
|
|
Topic: topic,
|
|
From: author.String(),
|
|
Head: types.KVMap{
|
|
"mime": "application/json",
|
|
"x-im-schema": 1,
|
|
"x-im-type": "text",
|
|
"x-im-client-mid": "group_announcement_" + strings.TrimSpace(ann.AnnouncementID),
|
|
},
|
|
Content: map[string]any{"text": "群公告\n" + content},
|
|
}
|
|
saved, err := saveMessageMaybeKafka(ctx, tenantID, msg, nil, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if saved != nil && saved.SeqId > 0 {
|
|
msg.SeqId = saved.SeqId
|
|
}
|
|
broadcastHTTPForwardMessage(tenantID, msg)
|
|
return nil
|
|
}
|
|
|
|
func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if settings != nil {
|
|
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
|
|
return
|
|
}
|
|
group, _ := store.Topics.Get(principal.TenantID, topic)
|
|
writeIMSuccess(wrt, req, groupGovernancePayload(group, selfSub))
|
|
case http.MethodPatch:
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupGovernancePatchRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
settings, err := store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{
|
|
JoinApproval: body.JoinApproval,
|
|
MuteAll: body.MuteAll,
|
|
MemberUploadFiles: body.MemberUploadFiles,
|
|
MemberPrivateChat: body.MemberPrivateChat,
|
|
MemberInvite: body.MemberInvite,
|
|
Version: body.Version,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if body.JoinApproval != nil && imReliableHTTPEventsEnabled(req) {
|
|
template := "group_invite_confirm_disabled"
|
|
text := "群聊邀请确认已关闭,成员邀请好友可直接进群。"
|
|
if *body.JoinApproval {
|
|
template = "group_invite_confirm_enabled"
|
|
text = "群聊邀请确认已启用,群成员邀请好友进群需要群主或管理员确认。"
|
|
}
|
|
publishIMConversationSystemNotice(principal.TenantID, topic,
|
|
"system_action", template, text,
|
|
[]imSystemMessageActor{
|
|
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
|
|
},
|
|
[]imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic, MinRole: "admin"}},
|
|
map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version})
|
|
}
|
|
if body.MemberPrivateChat != nil && imReliableHTTPEventsEnabled(req) {
|
|
template := "group_member_private_chat_disabled"
|
|
text := "群成员私信权限已关闭,群成员头像和名片不可点击查看。"
|
|
if *body.MemberPrivateChat {
|
|
template = "group_member_private_chat_enabled"
|
|
text = "群成员私信权限已开启,群成员头像和名片可点击查看。"
|
|
}
|
|
publishIMConversationSystemNotice(principal.TenantID, topic,
|
|
"system_notice", template, text,
|
|
[]imSystemMessageActor{
|
|
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
|
|
},
|
|
nil,
|
|
map[string]any{"member_private_chat": *body.MemberPrivateChat, "version": settings.Version})
|
|
}
|
|
if body.MuteAll != nil && imReliableHTTPEventsEnabled(req) {
|
|
template := "group_mute_all_disabled"
|
|
text := "全体禁言已关闭,普通成员将能发送任何消息。"
|
|
if *body.MuteAll {
|
|
template = "group_mute_all_enabled"
|
|
text = "全体禁言已开启,普通成员将不能发送任何消息。"
|
|
}
|
|
publishIMConversationSystemNotice(principal.TenantID, topic,
|
|
"system_notice", template, text,
|
|
[]imSystemMessageActor{
|
|
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
|
|
},
|
|
nil,
|
|
map[string]any{"mute_all": *body.MuteAll, "version": settings.Version})
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.settings.updated",
|
|
map[string]any{"topic": topic, "version": settings.Version})
|
|
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupRoleLimits(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"max_admin_count": groupMaxAdminCount, "current_admin_count": countGroupAdmins(members)})
|
|
}
|
|
|
|
func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "admins/batch" && req.Method == http.MethodPost:
|
|
var body imGroupAdminsBatchRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.UserIDs) == 0 || len(body.UserIDs) > groupMaxAdminCount {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
|
|
return
|
|
}
|
|
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if countGroupAdmins(members)+len(body.UserIDs) > groupMaxAdminCount {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "GROUP_ADMIN_LIMIT_EXCEEDED", "group admin limit exceeded", map[string]any{"max": groupMaxAdminCount, "current": countGroupAdmins(members), "requested": len(body.UserIDs)})
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.UserIDs))
|
|
for _, rawID := range body.UserIDs {
|
|
uid := types.ParseUserId(strings.TrimSpace(rawID))
|
|
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
|
|
if uid.IsZero() || uid == principal.Uid {
|
|
item["ok"] = false
|
|
item["reason"] = imReasonMalformedRequest
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
item["user_id"] = uid.UserId()
|
|
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
|
|
types.GroupMemberRoleAdmin, types.ModeCPublic|types.ModeApprove, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
item["ok"] = false
|
|
item["reason"] = relationErrorReason(err)
|
|
} else {
|
|
item["ok"] = true
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
members, err = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.role.updated",
|
|
map[string]any{"topic": topic, "results": results})
|
|
writeIMSuccess(wrt, req, map[string]any{"succeeded": results, "admins": groupAdminItems(members), "version": body.Version + 1, "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
|
|
case strings.HasPrefix(tail, "admins/") && req.Method == http.MethodDelete:
|
|
userID := strings.TrimPrefix(tail, "admins/")
|
|
uid := types.ParseUserId(strings.TrimSpace(userID))
|
|
if uid.IsZero() || uid == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
|
|
types.GroupMemberRoleMember, types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.role.updated",
|
|
map[string]any{"user_id": uid.UserId(), "role": "member"})
|
|
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "role": "member", "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupOwnerTransfer(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupOwnerTransferRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if !consumeIMActionTokenHeader(wrt, req, principal, "group_owner_transfer") {
|
|
return
|
|
}
|
|
target := types.ParseUserId(strings.TrimSpace(body.TargetUserID))
|
|
if target.IsZero() || target == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_user_id"})
|
|
return
|
|
}
|
|
targetSub, err := store.Subs.Get(principal.TenantID, topic, target, false)
|
|
if err != nil || targetSub == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
|
return
|
|
}
|
|
if err = store.GroupBusiness.TransferOwner(principal.TenantID, topic, principal.Uid, target,
|
|
body.Version, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
if errors.Is(err, types.ErrInvalidResponse) {
|
|
currentVersion := 0
|
|
if settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, topic); settingsErr == nil && settings != nil {
|
|
currentVersion = settings.Version
|
|
}
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.owner.transferred",
|
|
map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId()})
|
|
writeIMSuccess(wrt, req, map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId(), "version": body.Version + 1})
|
|
}
|
|
|
|
func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
isAdmin := (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
|
|
switch {
|
|
case tail == "mutes" && req.Method == http.MethodGet:
|
|
var items []types.GroupMute
|
|
nextCursor := ""
|
|
if isAdmin {
|
|
var err error
|
|
items, nextCursor, err = store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
} else {
|
|
item, err := store.GroupBusiness.GetMute(principal.TenantID, topic, principal.Uid)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if item != nil {
|
|
items = append(items, *item)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
|
|
case (tail == "mutes" || tail == "mutes/batch") && req.Method == http.MethodPost:
|
|
if !isAdmin {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupMuteBatchRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.Members) == 0 || len(body.Members) > 100 {
|
|
for _, userID := range cleanStringList(body.UserIDs, 100) {
|
|
body.Members = append(body.Members, struct {
|
|
UserID string `json:"user_id"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}{UserID: userID})
|
|
}
|
|
if len(body.Members) == 0 || len(body.Members) > 100 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "members"})
|
|
return
|
|
}
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
mutes := make([]types.GroupMute, 0, len(body.Members))
|
|
for _, member := range body.Members {
|
|
uid := types.ParseUserId(strings.TrimSpace(member.UserID))
|
|
if uid.IsZero() {
|
|
continue
|
|
}
|
|
if ok, reason := canMuteIMGroupMember(principal.TenantID, topic, selfSub, principal.Uid, uid); !ok {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": uid.UserId(), "reason": reason})
|
|
return
|
|
}
|
|
var expiresAt *time.Time
|
|
if raw := strings.TrimSpace(member.ExpiresAt); raw != "" {
|
|
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
|
expiresAt = &parsed
|
|
}
|
|
}
|
|
mute, err := store.GroupBusiness.UpsertMute(principal.TenantID, &types.GroupMute{Topic: topic, UserID: uid, MutedBy: principal.Uid, MutedAt: now, ExpiresAt: expiresAt, Reason: strings.TrimSpace(body.Reason), Status: types.GroupMuteStatusActive})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
mutes = append(mutes, *mute)
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.mute.updated",
|
|
map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
|
|
case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete:
|
|
if !isAdmin {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/")))
|
|
if uid.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
if ok, reason := canMuteIMGroupMember(principal.TenantID, topic, selfSub, principal.Uid, uid); !ok {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": uid.UserId(), "reason": reason})
|
|
return
|
|
}
|
|
if err := store.GroupBusiness.DeleteMute(principal.TenantID, topic, uid, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.mute.updated",
|
|
map[string]any{"user_id": uid.UserId(), "muted": false})
|
|
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "muted": false})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
|
|
"field": "topic",
|
|
"expected": "group topic, e.g. grp...",
|
|
"actual": topic,
|
|
})
|
|
return
|
|
}
|
|
if memberID.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
if memberID != principal.Uid {
|
|
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "group"); !decision.Allowed {
|
|
writeIMPolicyDecision(wrt, req, decision)
|
|
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
|
|
}
|
|
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed {
|
|
writeIMPolicyDecision(wrt, req, decision)
|
|
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"
|
|
}
|
|
groupPayload := groupDetailPayload(group, selfSub)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"purpose": claims.Purpose,
|
|
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
|
|
"group": groupPayload,
|
|
"group_snapshot": groupPayload,
|
|
"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
|
|
}
|
|
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed {
|
|
writeIMPolicyDecision(wrt, req, decision)
|
|
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))
|
|
friendSearchAllowed := imFriendGlobalDisabledDecision(principal.TenantID, "", "search").Allowed
|
|
for _, item := range items {
|
|
data := userSearchItemPayload(item)
|
|
if !friendSearchAllowed && item.Relationship == types.RelationshipNone {
|
|
data["can_add"] = false
|
|
data["action"] = "none"
|
|
}
|
|
payload = append(payload, data)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) {
|
|
identifier, tail, ok := userResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "profile":
|
|
serveIMUserProfile(wrt, req, identifier)
|
|
case "qrcodes":
|
|
serveIMUserQRCodes(wrt, req, identifier)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, err := resolveIMUserIdentifier(principal, identifier)
|
|
if err != nil {
|
|
writeIMResolveUserError(wrt, req, err)
|
|
return
|
|
}
|
|
if userID.IsZero() || userID == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, userID)
|
|
if err != nil {
|
|
logs.Warn.Printf("im user profile: load user failed request_id=%s viewer=%s target=%s: %v",
|
|
req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID))
|
|
if err != nil {
|
|
logs.Warn.Printf("im user profile: relationship context failed request_id=%s viewer=%s target=%s: %v",
|
|
req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID)
|
|
if err != nil {
|
|
logs.Warn.Printf("im user profile: privacy load failed request_id=%s viewer=%s target=%s: %v",
|
|
req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) {
|
|
if ctx.Relationship == types.RelationshipBlockedByMe {
|
|
writeIMSuccess(wrt, req, blockedUserProfilePayload(user, ctx))
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil)
|
|
return
|
|
}
|
|
if ctx.Relationship == types.RelationshipNone {
|
|
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "profile"); !decision.Allowed {
|
|
writeIMPolicyDecision(wrt, req, decision)
|
|
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
|
|
}
|
|
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed {
|
|
writeIMPolicyDecision(wrt, req, decision)
|
|
return
|
|
}
|
|
}
|
|
expiresIn := body.ExpiresInSeconds
|
|
if expiresIn <= 0 {
|
|
expiresIn = imQRCodeDefaultExpiresInSeconds
|
|
}
|
|
if expiresIn > imQRCodeMaxExpiresInSeconds {
|
|
expiresIn = imQRCodeMaxExpiresInSeconds
|
|
}
|
|
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
|
|
token, err := generateIMQRCodeToken(principal.TenantID, userID, purpose, expiresAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"qr_token": token,
|
|
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose),
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
"display_profile": userSummaryPayload(user, ""),
|
|
})
|
|
}
|
|
|
|
func resolveIMUserIdentifier(principal *imPrincipal, identifier string) (types.Uid, error) {
|
|
identifier = strings.TrimSpace(identifier)
|
|
if identifier == "" {
|
|
return types.ZeroUid, types.ErrMalformed
|
|
}
|
|
if userID := types.ParseUserId(identifier); !userID.IsZero() {
|
|
return userID, nil
|
|
}
|
|
if store.Relations == nil {
|
|
return types.ZeroUid, types.ErrUnsupported
|
|
}
|
|
items, _, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, identifier, "exact_account", "", 2)
|
|
if err != nil {
|
|
return types.ZeroUid, err
|
|
}
|
|
for _, item := range items {
|
|
if item.User == nil {
|
|
continue
|
|
}
|
|
if userID := item.User.Uid(); !userID.IsZero() {
|
|
return userID, nil
|
|
}
|
|
}
|
|
return types.ZeroUid, types.ErrNotFound
|
|
}
|
|
|
|
func writeIMResolveUserError(wrt http.ResponseWriter, req *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, types.ErrMalformed):
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
case errors.Is(err, types.ErrNotFound):
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
default:
|
|
writeIMRelationError(wrt, req, err)
|
|
}
|
|
}
|
|
|
|
func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
requestID, action := friendRequestActionFromPath(req.URL.Path)
|
|
status := ""
|
|
switch action {
|
|
case "accept":
|
|
status = types.FriendRequestAccepted
|
|
case "reject":
|
|
status = types.FriendRequestRejected
|
|
default:
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "friend request action not found", nil)
|
|
return
|
|
}
|
|
updated, err := store.Relations.ResolveFriendRequest(principal.TenantID, requestID, principal.Uid, status, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) {
|
|
publishIMConversationSystemNotice(principal.TenantID, updated.ApplicantID.P2PName(updated.TargetID),
|
|
"system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。",
|
|
[]imSystemMessageActor{
|
|
{UserID: updated.ApplicantID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantID.UserId()}},
|
|
{UserID: updated.TargetID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.TargetID.UserId()}},
|
|
}, nil, map[string]any{"request_id": updated.RequestID})
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
eventType := "friend.request." + status
|
|
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, updated.ApplicantID,
|
|
updated.RequestID+":"+status, eventType, "好友申请已处理", status); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
|
|
updated.RequestID+":"+status+":self", eventType, "", ""); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, friendRequestPayload(updated))
|
|
}
|
|
|
|
func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) {
|
|
if strings.TrimSuffix(req.URL.Path, "/") == "/v1/im/blocks/batch-delete" {
|
|
serveIMBlocksBatchDelete(wrt, req)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
peer := userIDFromTail(req.URL.Path, "/v1/im/blocks/")
|
|
if peer.IsZero() || peer == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodPut:
|
|
if _, err := store.Relations.BlockUser(principal.TenantID, principal.Uid, peer, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
|
|
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.blocked", "", ""); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
case http.MethodDelete:
|
|
if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
|
|
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.unblocked", "", ""); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
|
}
|
|
|
|
func serveIMBlocks(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Relations.ListBlocks(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
payload = append(payload, blockListItemPayload(item))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMBlocksBatchDelete(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imBlockBatchDeleteRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.UserIDs))
|
|
for _, rawID := range body.UserIDs {
|
|
peer := types.ParseUserId(strings.TrimSpace(rawID))
|
|
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
|
|
if peer.IsZero() || peer == principal.Uid {
|
|
item["ok"] = false
|
|
item["reason"] = imReasonMalformedRequest
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
item["user_id"] = peer.UserId()
|
|
if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil {
|
|
item["ok"] = false
|
|
item["reason"] = relationErrorReason(err)
|
|
} else {
|
|
item["ok"] = true
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": results})
|
|
}
|
|
|
|
func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
peer := userIDFromTail(req.URL.Path, "/v1/im/friends/")
|
|
if peer.IsZero() || peer == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
switch req.Method {
|
|
case http.MethodDelete:
|
|
if err := store.Relations.DeleteFriend(principal.TenantID, principal.Uid, peer, now); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
stableID := stableIMEventID(req.Header.Get("Idempotency-Key"), principal.Uid.UserId(), peer.UserId())
|
|
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, peer, stableID,
|
|
"friend.deleted", "好友关系已解除", ""); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableID+":self",
|
|
"friend.deleted", "", ""); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
case http.MethodPatch:
|
|
var body imFriendUpdateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if _, err := store.Relations.UpdateFriendRemark(principal.TenantID, principal.Uid, peer, strings.TrimSpace(body.Remark), now); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
|
|
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "friend.remark.updated", "", ""); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
|
}
|
|
|
|
func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imIndependentPasswordRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(body.Purpose)
|
|
if purpose == "" {
|
|
purpose = "youth_mode"
|
|
}
|
|
if !validIMIndependentPassword(body.Password) || purpose != "youth_mode" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
|
|
return
|
|
}
|
|
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", purpose, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err := store.Security.UpsertIndependentPassword(principal.TenantID, &types.IndependentPassword{
|
|
TenantID: principal.TenantID,
|
|
UserID: principal.Uid,
|
|
Purpose: purpose,
|
|
PasswordHash: hash,
|
|
}); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
enableToken, enableExpiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, "enable_youth_mode", purpose)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"status": "ok",
|
|
"password_set": true,
|
|
"purpose": purpose,
|
|
"enable_action_token": enableToken,
|
|
"enable_action_expires_at": enableExpiresAt.Format(time.RFC3339Nano),
|
|
"next_action": "enable_youth_mode",
|
|
})
|
|
}
|
|
|
|
func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imIndependentPasswordVerifyRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(body.Purpose)
|
|
if purpose == "" {
|
|
purpose = "youth_mode"
|
|
}
|
|
action := strings.TrimSpace(body.Action)
|
|
if action == "" {
|
|
action = "close_youth_mode"
|
|
}
|
|
pass, err := store.Security.GetIndependentPassword(principal.TenantID, principal.Uid, purpose)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if pass.LockedUntil != nil && now.Before(*pass.LockedUntil) {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
|
|
"retry_after_seconds": int(time.Until(*pass.LockedUntil).Seconds()) + 1,
|
|
})
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword(pass.PasswordHash, []byte(body.Password)) != nil {
|
|
attempts := pass.FailedAttempts + 1
|
|
var lockedUntil *time.Time
|
|
if attempts >= imIndependentPasswordMaxAttempts {
|
|
until := now.Add(imIndependentPasswordLockSeconds * time.Second)
|
|
lockedUntil = &until
|
|
}
|
|
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, attempts, lockedUntil, now)
|
|
if lockedUntil != nil {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
|
|
"retry_after_seconds": imIndependentPasswordLockSeconds,
|
|
})
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
|
|
return
|
|
}
|
|
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, 0, nil, now)
|
|
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, action, purpose)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"action_token": token,
|
|
"action": action,
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, challengeID, code, scene string) (*types.VerificationCode, string, bool) {
|
|
if len(code) != imVerificationCodeLength {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
|
|
return nil, "", false
|
|
}
|
|
vc, err := store.Verifications.Get(tenantID, challengeID)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return nil, "", false
|
|
}
|
|
logs.Warn.Println("im verification verify: lookup failed", err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, "", false
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if vc.Scene != scene || vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
|
|
return nil, "", false
|
|
}
|
|
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
|
|
_ = store.Verifications.Fail(tenantID, challengeID, types.VerificationStateExpired)
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return nil, "", false
|
|
}
|
|
if vc.CodeHash != hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
|
|
nextState := types.VerificationStatePending
|
|
if vc.Attempts+1 >= vc.MaxAttempts {
|
|
nextState = types.VerificationStateLocked
|
|
}
|
|
_ = store.Verifications.Fail(tenantID, challengeID, nextState)
|
|
if nextState == types.VerificationStateLocked {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
|
|
return nil, "", false
|
|
}
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
|
|
return nil, "", false
|
|
}
|
|
token, err := randomIMToken(32)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, "", false
|
|
}
|
|
if err := store.Verifications.MarkVerified(tenantID, challengeID, hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
|
|
logs.Warn.Println("im verification verify: mark verified failed", err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, "", false
|
|
}
|
|
return vc, token, true
|
|
}
|
|
|
|
func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPrincipal, bool) {
|
|
principal, err := resolveIMHTTPBearer(req)
|
|
if err != nil {
|
|
logs.Warn.Printf("im http bearer auth failed request_id=%s method=%s path=%s err=%v", req.Header.Get("X-Request-ID"), req.Method, req.URL.Path, err)
|
|
if imHTTPBearerAuthUnauthorized(err) {
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
|
|
} else {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
}
|
|
return nil, false
|
|
}
|
|
return principal, true
|
|
}
|
|
|
|
func imHTTPBearerAuthUnauthorized(err error) bool {
|
|
return errors.Is(err, types.ErrPermissionDenied) ||
|
|
errors.Is(err, types.ErrMalformed) ||
|
|
errors.Is(err, types.ErrFailed) ||
|
|
errors.Is(err, types.ErrExpired)
|
|
}
|
|
|
|
func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) {
|
|
principal, _, _, err := resolveIMHTTPBearerToken(req)
|
|
return principal, err
|
|
}
|
|
|
|
func resolveIMHTTPBearerToken(req *http.Request) (*imPrincipal, []byte, time.Duration, error) {
|
|
authz := strings.TrimSpace(req.Header.Get("Authorization"))
|
|
token, ok := strings.CutPrefix(authz, "Bearer ")
|
|
if !ok || strings.TrimSpace(token) == "" || store.Store == nil {
|
|
return nil, nil, 0, types.ErrPermissionDenied
|
|
}
|
|
handler := store.Store.GetLogicalAuthHandler("token")
|
|
if handler == nil || !handler.IsInitialized() {
|
|
return nil, nil, 0, types.ErrUnsupported
|
|
}
|
|
secret := []byte(strings.TrimSpace(token))
|
|
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil {
|
|
secret = decoded
|
|
}
|
|
rec, _, err := handler.Authenticate(auth.AuthContext{}, secret)
|
|
if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
|
|
if err != nil {
|
|
return nil, nil, 0, err
|
|
}
|
|
return nil, nil, 0, types.ErrPermissionDenied
|
|
}
|
|
return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, secret, time.Duration(rec.Lifetime), nil
|
|
}
|
|
|
|
func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30000, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
deviceID, ok := imLogoutDeviceID(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
principal, token, ttl, err := resolveIMHTTPBearerToken(req)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
|
|
return
|
|
}
|
|
if ttl < time.Second {
|
|
ttl = time.Second
|
|
}
|
|
enabled, err := store.RevokeLoginToken(principal.TenantID, token, ttl)
|
|
if err != nil || !enabled {
|
|
logs.Warn.Println("im logout: token revocation failed", principal.TenantID, principal.Uid, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
pushUnbound := false
|
|
if deviceID != "" {
|
|
if deleter, ok := store.DevicePushTokens.(interface {
|
|
Delete(types.TenantID, types.Uid, string) error
|
|
}); ok {
|
|
if err = deleter.Delete(principal.TenantID, principal.Uid, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
logs.Warn.Println("im logout: push token unbind failed", principal.TenantID, principal.Uid, deviceID, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
pushUnbound = err == nil
|
|
}
|
|
if store.Devices != nil {
|
|
if err = store.Devices.Delete(principal.TenantID, principal.Uid, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
logs.Warn.Println("im logout: legacy device unbind failed", principal.TenantID, principal.Uid, deviceID, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "device_id": deviceID, "push_unbound": pushUnbound})
|
|
}
|
|
|
|
func imLogoutDeviceID(wrt http.ResponseWriter, req *http.Request) (string, bool) {
|
|
deviceID := strings.TrimSpace(req.Header.Get("X-Device-ID"))
|
|
if deviceID == "" {
|
|
deviceID = strings.TrimSpace(req.Header.Get("X-IM-Device-ID"))
|
|
}
|
|
if req.Body != nil && req.Body != http.NoBody {
|
|
var body imAuthLogoutRequest
|
|
dec := json.NewDecoder(req.Body)
|
|
if err := dec.Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "body"})
|
|
return "", false
|
|
}
|
|
if strings.TrimSpace(body.DeviceID) != "" {
|
|
deviceID = strings.TrimSpace(body.DeviceID)
|
|
}
|
|
}
|
|
if strings.Contains(deviceID, "/") {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"})
|
|
return "", false
|
|
}
|
|
return deviceID, true
|
|
}
|
|
|
|
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
|
|
user, err := store.Users.Get(tenantID, uid)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if user == nil || strings.TrimSpace(user.Phone) == "" {
|
|
return "", types.ErrNotFound
|
|
}
|
|
return strings.TrimSpace(user.Phone), nil
|
|
}
|
|
|
|
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
|
|
if imSensitiveChallengeRateLimitEnabled {
|
|
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if active != nil && now.Before(active.ResendAfter) {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
"challenge_id": active.ChallengeID,
|
|
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
|
|
})
|
|
return
|
|
}
|
|
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
|
|
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
|
|
} else if !allowed {
|
|
retryAfter := int(ttl.Seconds())
|
|
if retryAfter < 1 {
|
|
retryAfter = int(imVerificationCreateRateWindow.Seconds())
|
|
}
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
"retry_after_seconds": retryAfter,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
challengeID, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
code := "123456"
|
|
vc := &types.VerificationCode{
|
|
TenantID: principal.TenantID,
|
|
ChallengeID: challengeID,
|
|
Scene: "sensitive_action",
|
|
Action: action,
|
|
PhoneHash: phoneHash,
|
|
PhoneMask: maskIMPhone(phone),
|
|
TargetUser: principal.Uid,
|
|
CodeHash: hashIMSecret(principal.TenantID, "sensitive_action", challengeID, phoneHash, code),
|
|
State: types.VerificationStatePending,
|
|
MaxAttempts: imVerificationMaxAttempts,
|
|
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
|
|
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
|
|
}
|
|
if err := store.Verifications.Create(principal.TenantID, vc); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil {
|
|
logs.Warn.Println("im security verification delivery failed; keeping fixed test code active:", action, vc.PhoneMask, err)
|
|
}
|
|
logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"challenge_id": vc.ChallengeID,
|
|
"phone_mask": vc.PhoneMask,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
"resend_after_seconds": imVerificationResendAfterSeconds,
|
|
})
|
|
}
|
|
|
|
func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target string) (string, time.Time, error) {
|
|
if action == "" {
|
|
return "", time.Time{}, types.ErrMalformed
|
|
}
|
|
tokenID, err := randomIMToken(12)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
secret, err := randomIMToken(32)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(imActionTokenExpiresInSeconds * time.Second)
|
|
if err := store.Security.CreateActionToken(tenantID, &types.ActionToken{
|
|
TenantID: tenantID,
|
|
UserID: uid,
|
|
TokenID: tokenID,
|
|
Action: action,
|
|
Target: target,
|
|
TokenHash: hashIMActionToken(tenantID, uid, action, target, tokenID, secret),
|
|
State: 0,
|
|
ExpiresAt: expiresAt,
|
|
}); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
return tokenID + "." + secret, expiresAt, nil
|
|
}
|
|
|
|
func defaultIMActionTarget(action, fallback string) string {
|
|
switch action {
|
|
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password":
|
|
return "youth_mode"
|
|
case "group_owner_transfer", "group_dissolve":
|
|
return action
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func consumeIMActionTokenHeader(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action string) bool {
|
|
actionToken := strings.TrimSpace(req.Header.Get("X-Action-Token"))
|
|
if actionToken == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "X-Action-Token"})
|
|
return false
|
|
}
|
|
if store.Security == nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return false
|
|
}
|
|
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, action, defaultIMActionTarget(action, action), time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string {
|
|
h := sha256.New()
|
|
h.Write([]byte(fmt.Sprintf("%d", tenantID)))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(fmt.Sprintf("%d", store.DecodeUid(uid))))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(action))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(target))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(tokenID))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(secret))
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
func realNamePayload(rv *types.RealNameVerification) map[string]any {
|
|
canSubmit := rv.Status == "unverified" || rv.Status == "rejected"
|
|
status := rv.Status
|
|
if status == "manual_review" {
|
|
status = "pending"
|
|
}
|
|
data := map[string]any{
|
|
"status": status,
|
|
"submission_id": rv.SubmissionID,
|
|
"masked_name": rv.MaskedName,
|
|
"masked_id_number": rv.MaskedIDNumber,
|
|
"can_submit": canSubmit,
|
|
}
|
|
if rv.Status == "manual_review" {
|
|
data["review_status"] = rv.Status
|
|
}
|
|
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 initialRealNameSubmissionState(req *http.Request, principal *imPrincipal, name, idType, idNumber string) (string, *string, *time.Time) {
|
|
status := "pending"
|
|
policy, err := store.RealNameConfig.Policy(principal.TenantID)
|
|
if err != nil || policy == nil {
|
|
logs.Warn.Printf("realname policy lookup failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
|
|
return status, nil, nil
|
|
}
|
|
if policy.ReviewPolicy != types.RealNameReviewPolicyAutoManual {
|
|
return status, nil, nil
|
|
}
|
|
result := reviewIMRealNameAutomatically(req, principal, policy, name, idType, idNumber)
|
|
switch result.status {
|
|
case "verified":
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
return "verified", nil, &now
|
|
default:
|
|
reason := strings.TrimSpace(result.reason)
|
|
if reason == "" {
|
|
reason = "自动审核未通过,已转人工审核"
|
|
}
|
|
return "manual_review", &reason, nil
|
|
}
|
|
}
|
|
|
|
type imRealNameAutoReviewResult struct {
|
|
status string
|
|
reason string
|
|
}
|
|
|
|
func reviewIMRealNameAutomatically(req *http.Request, principal *imPrincipal, policy *types.RealNamePolicy, name, idType, idNumber string) imRealNameAutoReviewResult {
|
|
values, err := store.ThirdPartyConfig.Get("im_realname",
|
|
"realname_provider",
|
|
"aliyun_region_id",
|
|
"aliyun_endpoint",
|
|
"aliyun_access_key_id",
|
|
"aliyun_access_key_secret",
|
|
"aliyun_timeout_ms",
|
|
"mock_enabled",
|
|
"mock_result")
|
|
if err != nil {
|
|
logs.Warn.Printf("realname auto review config lookup failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
|
|
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核配置读取失败,已转人工审核"}
|
|
}
|
|
if parseIMBool(values["mock_enabled"]) {
|
|
switch strings.ToLower(strings.TrimSpace(values["mock_result"])) {
|
|
case "verified", "pass", "passed", "success":
|
|
return imRealNameAutoReviewResult{status: "verified"}
|
|
default:
|
|
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核未通过,已转人工审核"}
|
|
}
|
|
}
|
|
if strings.TrimSpace(values["realname_provider"]) == "" ||
|
|
strings.TrimSpace(values["aliyun_access_key_id"]) == "" ||
|
|
strings.TrimSpace(values["aliyun_access_key_secret"]) == "" {
|
|
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核配置未完成,已转人工审核"}
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(values["realname_provider"]), "aliyun_cloudauth") {
|
|
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核供应商暂不支持,已转人工审核"}
|
|
}
|
|
client, err := realnamealiyun.NewID2MetaClient(realnamealiyun.Config{
|
|
AccessKeyID: values["aliyun_access_key_id"],
|
|
AccessKeySecret: values["aliyun_access_key_secret"],
|
|
RegionID: values["aliyun_region_id"],
|
|
Endpoint: values["aliyun_endpoint"],
|
|
TimeoutMS: realnamealiyun.ParseTimeoutMS(values["aliyun_timeout_ms"], 1500),
|
|
})
|
|
if err != nil {
|
|
logs.Warn.Printf("realname auto review client init failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
|
|
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核客户端初始化失败,已转人工审核"}
|
|
}
|
|
result, err := client.Verify(name, idNumber)
|
|
if err != nil {
|
|
logs.Warn.Printf("realname auto review call failed: tenant=%d uid=%s trace=%s err=%v", principal.TenantID, principal.Uid, req.Header.Get("X-Request-ID"), err)
|
|
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核调用失败,已转人工审核"}
|
|
}
|
|
if result.Passed {
|
|
logs.Info.Printf("realname auto review passed: tenant=%d uid=%s request_id=%s latency=%s", principal.TenantID, principal.Uid, result.RequestID, result.Latency)
|
|
return imRealNameAutoReviewResult{status: "verified"}
|
|
}
|
|
logs.Info.Printf("realname auto review mismatch: tenant=%d uid=%s request_id=%s biz_code=%s latency=%s", principal.TenantID, principal.Uid, result.RequestID, result.BizCode, result.Latency)
|
|
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核未通过,已转人工审核"}
|
|
}
|
|
|
|
func groupInvitationPayload(inv *types.GroupInvitation) map[string]any {
|
|
if inv == nil {
|
|
return map[string]any{}
|
|
}
|
|
data := map[string]any{
|
|
"invitation_id": inv.InvitationID,
|
|
"topic": inv.Topic,
|
|
"inviter_id": inv.InviterUserID.UserId(),
|
|
"target_user_id": inv.TargetUserID.UserId(),
|
|
"status": groupInvitationDisplayStatus(inv),
|
|
"result": inv.Result,
|
|
"expires_at": inv.ExpiresAt.Format(time.RFC3339Nano),
|
|
}
|
|
if inv.JoinRequestID != "" {
|
|
data["join_request_id"] = inv.JoinRequestID
|
|
}
|
|
if inv.HandledAt != nil {
|
|
data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupInvitationDisplayStatus(inv *types.GroupInvitation) string {
|
|
if inv == nil {
|
|
return ""
|
|
}
|
|
if inv.Status == types.GroupInvitationStatusPending && strings.TrimSpace(inv.JoinRequestID) != "" {
|
|
return types.GroupInvitationStatusPendingApproval
|
|
}
|
|
return inv.Status
|
|
}
|
|
|
|
func groupFilePayloads(files []types.GroupFile) []map[string]any {
|
|
items := make([]map[string]any, 0, len(files))
|
|
for i := range files {
|
|
items = append(items, groupFilePayload(&files[i]))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func groupFilePayload(file *types.GroupFile) map[string]any {
|
|
if file == nil {
|
|
return map[string]any{}
|
|
}
|
|
data := map[string]any{
|
|
"group_file_id": file.GroupFileID,
|
|
"topic": file.Topic,
|
|
"file_id": file.FileID,
|
|
"file_name": file.FileName,
|
|
"mime_type": file.MIMEType,
|
|
"file_type": file.FileType,
|
|
"file_size": file.FileSize,
|
|
"uploader_id": file.UploaderID.UserId(),
|
|
"status": file.Status,
|
|
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if file.SourceTopic != "" {
|
|
data["source_topic"] = file.SourceTopic
|
|
}
|
|
if file.SourceSeq > 0 {
|
|
data["source_seq"] = file.SourceSeq
|
|
}
|
|
if file.DeletedAt != nil {
|
|
data["deleted_at"] = file.DeletedAt.UTC().Format(time.RFC3339Nano)
|
|
data["deleted_by"] = file.DeletedBy.UserId()
|
|
}
|
|
return data
|
|
}
|
|
|
|
func normalizeIMFileType(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "image", "video", "audio", "document", "file":
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
default:
|
|
return "file"
|
|
}
|
|
}
|
|
|
|
func normalizeIMUploadedFileID(raw string) string {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if cut, _, ok := strings.Cut(value, "?"); ok {
|
|
value = cut
|
|
}
|
|
if cut, _, ok := strings.Cut(value, "#"); ok {
|
|
value = cut
|
|
}
|
|
if strings.Contains(value, "/") && store.Store != nil && store.Store.GetMediaHandler() != nil {
|
|
if fid := store.Store.GetMediaHandler().GetIdFromUrl(value); !fid.IsZero() {
|
|
return fid.String()
|
|
}
|
|
}
|
|
if fid := types.ParseUid(value); !fid.IsZero() {
|
|
return fid.String()
|
|
}
|
|
if dot := strings.IndexByte(value, '.'); dot > 0 {
|
|
if fid := types.ParseUid(value[:dot]); !fid.IsZero() {
|
|
return fid.String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func linkIMGroupAvatarAttachment(tenantID types.TenantID, topic, avatarFileID string) error {
|
|
fileID := normalizeIMUploadedFileID(avatarFileID)
|
|
if fileID == "" {
|
|
return types.ErrMalformed
|
|
}
|
|
return store.Files.LinkAttachments(tenantID, topic, types.ZeroUid, []string{fileID})
|
|
}
|
|
|
|
func parseIMLimit(raw string, fallback, max int) int {
|
|
limit := fallback
|
|
if raw != "" {
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
if limit > max {
|
|
limit = max
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func parseIMTime(raw string) (time.Time, error) {
|
|
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return parsed.UTC().Round(time.Millisecond), nil
|
|
}
|
|
|
|
func parseIMDate(raw string) (time.Time, error) {
|
|
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.UTC)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func 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 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", "on", "enabled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func conversationMatches(sub types.Subscription, query string) bool {
|
|
if strings.Contains(strings.ToLower(sub.Topic), query) {
|
|
return true
|
|
}
|
|
encoded, _ := json.Marshal(sub.GetPublic())
|
|
return strings.Contains(strings.ToLower(string(encoded)), query)
|
|
}
|
|
|
|
func conversationPayload(sub types.Subscription) map[string]any {
|
|
unread := sub.GetSeqId() - sub.ReadSeqId
|
|
if unread < 0 {
|
|
unread = 0
|
|
}
|
|
return map[string]any{
|
|
"topic": sub.Topic,
|
|
"public": sub.GetPublic(),
|
|
"private": sub.Private,
|
|
"updated_at": sub.LastModified().UTC().Format(time.RFC3339Nano),
|
|
"seq": sub.GetSeqId(),
|
|
"read": sub.ReadSeqId,
|
|
"recv": sub.RecvSeqId,
|
|
"unread": unread,
|
|
"with": sub.GetWith(),
|
|
}
|
|
}
|
|
|
|
func conversationSettingsPayload(settings *types.ConversationSettings) map[string]any {
|
|
if settings == nil {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{
|
|
"topic": settings.Topic,
|
|
"pinned": settings.Pinned,
|
|
"muted": settings.Muted,
|
|
"mute_notifications": settings.Muted,
|
|
"remark": settings.Remark,
|
|
"message_retention": settings.MessageRetention,
|
|
"version": settings.Version,
|
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"permissions": map[string]any{"can_update": true},
|
|
}
|
|
}
|
|
|
|
func topicDraftPayload(draft *types.TopicDraft) map[string]any {
|
|
if draft == nil {
|
|
return map[string]any{}
|
|
}
|
|
var entities any = []any{}
|
|
if len(draft.EntitiesJSON) > 0 {
|
|
_ = json.Unmarshal(draft.EntitiesJSON, &entities)
|
|
}
|
|
return map[string]any{
|
|
"topic": draft.Topic,
|
|
"content": draft.Content,
|
|
"entities": entities,
|
|
"version": draft.Version,
|
|
"updated_at": draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func forwardTargetPayload(sub types.Subscription) map[string]any {
|
|
topicType := ""
|
|
switch types.GetTopicCat(sub.Topic) {
|
|
case types.TopicCatGrp:
|
|
topicType = "group"
|
|
case types.TopicCatP2P:
|
|
topicType = "user"
|
|
default:
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"topic": sub.Topic,
|
|
"type": topicType,
|
|
"display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic),
|
|
"avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()),
|
|
"can_receive_forward": true,
|
|
}
|
|
}
|
|
|
|
func forwardTargetMatches(topic, displayName, query string) bool {
|
|
if query == "" {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(topic), query) || strings.Contains(strings.ToLower(displayName), query)
|
|
}
|
|
|
|
func imDisplayNameFromPublic(public any, fallback string) string {
|
|
if value, ok := imPublicString(public, "fn"); ok {
|
|
return value
|
|
}
|
|
if value, ok := imPublicString(public, "name"); ok {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func imAvatarFileIDFromPublic(public any) string {
|
|
if value, ok := imPublicString(public, "avatar_file_id"); ok {
|
|
return value
|
|
}
|
|
if value, ok := imPublicString(public, "photo"); ok {
|
|
return value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func imPublicString(public any, key string) (string, bool) {
|
|
values, ok := public.(map[string]any)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
value, ok := values[key].(string)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
return value, value != ""
|
|
}
|
|
|
|
func receiptMemberPayload(sub types.Subscription) map[string]any {
|
|
return map[string]any{
|
|
"user_id": sub.User,
|
|
"public": sub.GetPublic(),
|
|
"read": sub.ReadSeqId,
|
|
"recv": sub.RecvSeqId,
|
|
}
|
|
}
|
|
|
|
func groupListItemPayload(sub types.Subscription) map[string]any {
|
|
public := groupPublicMap(sub.GetPublic())
|
|
access := sub.ModeGiven & sub.ModeWant
|
|
return map[string]any{
|
|
"topic": sub.Topic,
|
|
"name": groupName(public, sub.Topic),
|
|
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
|
|
"member_count": sub.GetSubCnt(),
|
|
"my_role": groupMemberRole(access),
|
|
"updated_at": sub.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func mergeGroupListSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
|
|
if payload == nil {
|
|
payload = map[string]any{}
|
|
}
|
|
if settings == nil {
|
|
return payload
|
|
}
|
|
payload["topic"] = settings.Topic
|
|
payload["name"] = settings.Name
|
|
payload["avatar_file_id"] = settings.AvatarFileID
|
|
payload["updated_at"] = settings.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
|
return payload
|
|
}
|
|
|
|
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
public := map[string]any{}
|
|
topic := ""
|
|
memberCount := 0
|
|
ownerID := ""
|
|
if group != nil {
|
|
public = groupPublicMap(group.Public)
|
|
topic = group.Id
|
|
memberCount = group.SubCnt
|
|
if owner := types.ParseUid(group.Owner); !owner.IsZero() {
|
|
ownerID = owner.UserId()
|
|
}
|
|
}
|
|
version := intFromAny(public["version"])
|
|
if version <= 0 {
|
|
version = 1
|
|
}
|
|
access := types.ModeNone
|
|
if selfSub != nil {
|
|
access = selfSub.ModeGiven & selfSub.ModeWant
|
|
if topic == "" {
|
|
topic = selfSub.Topic
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"topic": topic,
|
|
"name": groupName(public, topic),
|
|
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
|
|
"description": stringFromAny(public["description"]),
|
|
"owner": ownerID,
|
|
"member_count": memberCount,
|
|
"version": version,
|
|
"my_role": groupMemberRole(access),
|
|
"permissions": groupPermissions(access),
|
|
}
|
|
}
|
|
|
|
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
data := groupDetailPayload(group, selfSub)
|
|
private := map[string]any{}
|
|
if selfSub != nil {
|
|
private = groupPrivateMap(selfSub.Private)
|
|
}
|
|
data["pinned"] = boolFromAny(private["pinned"])
|
|
data["mute_notifications"] = boolFromAny(private["mute_notifications"])
|
|
data["message_retention"] = stringFromAny(private["message_retention"])
|
|
if data["message_retention"] == "" {
|
|
data["message_retention"] = "forever"
|
|
}
|
|
data["governance"] = defaultGroupGovernance()
|
|
return data
|
|
}
|
|
|
|
func groupMemberSettingsPayload(sub *types.Subscription) map[string]any {
|
|
private := map[string]any{}
|
|
if sub != nil {
|
|
private = groupPrivateMap(sub.Private)
|
|
}
|
|
return map[string]any{
|
|
"pinned": boolFromAny(private["pinned"]),
|
|
"mute_notifications": boolFromAny(private["mute_notifications"]),
|
|
"version": intFromAny(private["version"]),
|
|
"updated_at": stringFromAny(private["updated_at"]),
|
|
}
|
|
}
|
|
|
|
func groupMemberPayload(sub types.Subscription, mentionable bool, remark string) map[string]any {
|
|
access := sub.ModeGiven & sub.ModeWant
|
|
user := &types.User{ObjHeader: types.ObjHeader{Id: sub.User}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()}
|
|
data := userSummaryPayload(user, remark)
|
|
copyUserNickname(data, sub.GetPublic())
|
|
if private := groupPrivateMap(sub.Private); stringFromAny(private["nickname"]) != "" {
|
|
data["group_nickname"] = stringFromAny(private["nickname"])
|
|
}
|
|
data["role"] = groupMemberRole(access)
|
|
data["mentionable"] = mentionable
|
|
data["acs"] = map[string]any{
|
|
"mode": access.String(),
|
|
"want": sub.ModeWant.String(),
|
|
"given": sub.ModeGiven.String(),
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupBusinessMemberPayload(member types.GroupMember, remark string) map[string]any {
|
|
access := groupMemberAccess(member)
|
|
user := &types.User{ObjHeader: types.ObjHeader{Id: member.UserID.String()}, Public: member.Public, Trusted: member.Trusted}
|
|
data := userSummaryPayload(user, remark)
|
|
copyUserNickname(data, member.Public)
|
|
if nickname := strings.TrimSpace(member.Nickname); nickname != "" {
|
|
data["group_nickname"] = nickname
|
|
}
|
|
data["role"] = groupMemberRole(access)
|
|
data["member_status"] = member.MemberStatus
|
|
data["version"] = member.Version
|
|
data["joined_at"] = member.JoinedAt.UTC().Format(time.RFC3339Nano)
|
|
data["updated_at"] = member.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
|
data["mentionable"] = access.IsJoiner() && access.IsReader()
|
|
data["acs"] = map[string]any{
|
|
"mode": access.String(),
|
|
"want": member.ModeWant.String(),
|
|
"given": member.ModeGiven.String(),
|
|
}
|
|
return data
|
|
}
|
|
|
|
func maskIMGroupMemberProfileEntrypoint(data map[string]any) {
|
|
for _, key := range []string{"avatar_file_id", "public_account", "signature", "gender", "region"} {
|
|
delete(data, key)
|
|
}
|
|
data["can_add"] = false
|
|
data["action"] = "none"
|
|
}
|
|
|
|
func copyUserNickname(data map[string]any, public any) {
|
|
var fields map[string]any
|
|
if value, ok := public.(map[string]any); ok {
|
|
fields = value
|
|
} else if value, ok := public.(types.KVMap); ok {
|
|
fields = map[string]any(value)
|
|
}
|
|
if fields == nil {
|
|
return
|
|
}
|
|
if nickname := strings.TrimSpace(fmt.Sprint(fields["nickname"])); nickname != "" {
|
|
data["nickname"] = nickname
|
|
}
|
|
}
|
|
|
|
func groupMemberRemarkMap(tenantID types.TenantID, viewer types.Uid, members []types.GroupMember) map[types.Uid]string {
|
|
remarks := make(map[types.Uid]string)
|
|
for _, member := range members {
|
|
if member.UserID.IsZero() || member.UserID == viewer {
|
|
continue
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, member.UserID, viewer.P2PName(member.UserID))
|
|
if err != nil {
|
|
logs.Warn.Printf("group member[%s:%s]: failed to resolve friend remark for viewer %s: %v", member.Topic, member.UserID.UserId(), viewer.UserId(), err)
|
|
continue
|
|
}
|
|
if ctx != nil && ctx.Remark != "" {
|
|
remarks[member.UserID] = ctx.Remark
|
|
}
|
|
}
|
|
return remarks
|
|
}
|
|
|
|
func groupMemberAccess(member types.GroupMember) types.AccessMode {
|
|
access := member.ModeGiven & member.ModeWant
|
|
if access != types.ModeNone {
|
|
return access
|
|
}
|
|
switch member.Role {
|
|
case types.GroupMemberRoleOwner:
|
|
return types.ModeCFull
|
|
case types.GroupMemberRoleAdmin:
|
|
return types.ModeCPublic | types.ModeApprove
|
|
default:
|
|
return types.ModeCPublic
|
|
}
|
|
}
|
|
|
|
func canMuteIMGroupMember(tenantID types.TenantID, topic string, selfSub *types.Subscription, actor, target types.Uid) (bool, string) {
|
|
if actor.IsZero() || target.IsZero() {
|
|
return false, "invalid_user"
|
|
}
|
|
if actor == target {
|
|
return false, "self"
|
|
}
|
|
actorAccess := types.ModeNone
|
|
if selfSub != nil {
|
|
actorAccess = selfSub.ModeGiven & selfSub.ModeWant
|
|
}
|
|
if !actorAccess.IsAdmin() {
|
|
return false, "not_admin"
|
|
}
|
|
targetRole := ""
|
|
if member, err := store.GroupBusiness.GetMember(tenantID, topic, target); err == nil && member != nil {
|
|
targetRole = groupMemberRole(groupMemberAccess(*member))
|
|
} else if sub, err := store.Subs.Get(tenantID, topic, target, false); err == nil && sub != nil {
|
|
targetRole = groupMemberRole(sub.ModeGiven & sub.ModeWant)
|
|
}
|
|
switch targetRole {
|
|
case "owner":
|
|
return false, "owner"
|
|
case "admin":
|
|
if !actorAccess.IsOwner() {
|
|
return false, "admin"
|
|
}
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
func canRemoveIMGroupMember(selfSub *types.Subscription, actor types.Uid, target types.GroupMember) (bool, string) {
|
|
if actor.IsZero() || target.UserID.IsZero() {
|
|
return false, "invalid_user"
|
|
}
|
|
if actor == target.UserID {
|
|
return false, "self"
|
|
}
|
|
actorAccess := types.ModeNone
|
|
if selfSub != nil {
|
|
actorAccess = selfSub.ModeGiven & selfSub.ModeWant
|
|
}
|
|
if !actorAccess.IsAdmin() {
|
|
return false, "not_admin"
|
|
}
|
|
switch groupMemberRole(groupMemberAccess(target)) {
|
|
case "owner":
|
|
return false, "owner"
|
|
case "admin":
|
|
if !actorAccess.IsOwner() {
|
|
return false, "admin"
|
|
}
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
|
|
data := groupMemberPayload(*sub, true, ctx.Remark)
|
|
data["topic"] = topic
|
|
data["relationship"] = ctx.Relationship
|
|
data["capabilities"] = map[string]any{
|
|
"can_send": ctx.CanSend,
|
|
"can_call": imCanCall(ctx.CanSend),
|
|
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
|
}
|
|
if ctx.RequestID != "" {
|
|
data["request_id"] = ctx.RequestID
|
|
}
|
|
if user != nil {
|
|
for key, value := range userSummaryPayload(user, ctx.Remark) {
|
|
if key == "avatar_file_id" || key == "display_name" {
|
|
data[key] = value
|
|
}
|
|
}
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyOptionalProfileFields(data, public)
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
copyOptionalProfileFields(data, map[string]any(public))
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupMemberRole(access types.AccessMode) string {
|
|
switch {
|
|
case access.IsOwner():
|
|
return "owner"
|
|
case access.IsAdmin():
|
|
return "admin"
|
|
default:
|
|
return "member"
|
|
}
|
|
}
|
|
|
|
func groupCategory(role string) string {
|
|
switch role {
|
|
case "owner":
|
|
return "owned"
|
|
case "admin":
|
|
return "managed"
|
|
default:
|
|
return "joined"
|
|
}
|
|
}
|
|
|
|
func groupPermissions(access types.AccessMode) map[string]any {
|
|
return map[string]any{
|
|
"can_edit_profile": access.IsAdmin(),
|
|
"can_invite_members": access.IsSharer() || access.IsAdmin(),
|
|
"can_manage_members": access.IsAdmin(),
|
|
"can_manage_settings": access.IsAdmin(),
|
|
"can_leave": !access.IsOwner() && access.IsJoiner(),
|
|
}
|
|
}
|
|
|
|
func defaultGroupGovernance() map[string]any {
|
|
return map[string]any{
|
|
"join_approval": false,
|
|
"mute_all": false,
|
|
"member_upload_files": true,
|
|
"member_private_chat": true,
|
|
"member_invite": true,
|
|
"version": 1,
|
|
}
|
|
}
|
|
|
|
func groupGovernanceMap(value any) map[string]any {
|
|
governance := defaultGroupGovernance()
|
|
for key, value := range groupPrivateMap(value) {
|
|
governance[key] = value
|
|
}
|
|
return governance
|
|
}
|
|
|
|
func groupGovernancePayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
aux := map[string]any{}
|
|
if group != nil {
|
|
aux = groupAuxMap(group.Aux)
|
|
}
|
|
governance := groupGovernanceMap(aux["governance"])
|
|
editable := false
|
|
if selfSub != nil {
|
|
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
|
|
}
|
|
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
|
|
governance[key+"_editable"] = editable
|
|
}
|
|
return governance
|
|
}
|
|
|
|
func groupGovernancePayloadFromSettings(settings *types.GroupSettings, selfSub *types.Subscription) map[string]any {
|
|
governance := defaultGroupGovernance()
|
|
if settings != nil {
|
|
governance["join_approval"] = settings.JoinApproval
|
|
governance["mute_all"] = settings.MuteAll
|
|
governance["member_upload_files"] = settings.MemberUploadFiles
|
|
governance["member_private_chat"] = settings.MemberPrivateChat
|
|
governance["member_invite"] = settings.MemberInvite
|
|
governance["version"] = settings.Version
|
|
}
|
|
editable := false
|
|
if selfSub != nil {
|
|
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
|
|
}
|
|
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
|
|
governance[key+"_editable"] = editable
|
|
}
|
|
return governance
|
|
}
|
|
|
|
func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
|
|
if payload == nil {
|
|
payload = map[string]any{}
|
|
}
|
|
if settings == nil {
|
|
return payload
|
|
}
|
|
payload["topic"] = settings.Topic
|
|
payload["name"] = settings.Name
|
|
payload["avatar_file_id"] = settings.AvatarFileID
|
|
payload["description"] = settings.Description
|
|
payload["owner"] = settings.OwnerUserID.UserId()
|
|
payload["status"] = settings.GroupStatus
|
|
payload["version"] = settings.Version
|
|
return payload
|
|
}
|
|
|
|
func groupJoinRequestPayload(req *types.GroupJoinRequest) map[string]any {
|
|
if req == nil {
|
|
return map[string]any{}
|
|
}
|
|
data := map[string]any{
|
|
"request_id": req.RequestID,
|
|
"user_id": req.ApplicantUserID.UserId(),
|
|
"inviter_id": req.InviterUserID.UserId(),
|
|
"source": req.Source,
|
|
"message": req.Message,
|
|
"status": req.Status,
|
|
"version": req.Version,
|
|
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": req.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if req.HandledBy != types.ZeroUid {
|
|
data["handled_by"] = req.HandledBy.UserId()
|
|
}
|
|
if req.HandledAt != nil {
|
|
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
if req.RejectReason != "" {
|
|
data["reject_reason"] = req.RejectReason
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupJoinRequestPayloads(items []types.GroupJoinRequest) []map[string]any {
|
|
out := make([]map[string]any, 0, len(items))
|
|
for i := range items {
|
|
out = append(out, groupJoinRequestPayload(&items[i]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupePendingGroupJoinRequests(items []types.GroupJoinRequest, status string) []types.GroupJoinRequest {
|
|
if len(items) < 2 || status != types.GroupJoinRequestStatusPending {
|
|
return items
|
|
}
|
|
seen := make(map[string]struct{}, len(items))
|
|
deduped := make([]types.GroupJoinRequest, 0, len(items))
|
|
for i := len(items) - 1; i >= 0; i-- {
|
|
key := items[i].Topic + ":" + items[i].ApplicantUserID.UserId()
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
deduped = append(deduped, items[i])
|
|
}
|
|
for left, right := 0, len(deduped)-1; left < right; left, right = left+1, right-1 {
|
|
deduped[left], deduped[right] = deduped[right], deduped[left]
|
|
}
|
|
return deduped
|
|
}
|
|
|
|
func groupJoinRequestPayloadsWithUsers(tenantID types.TenantID, items []types.GroupJoinRequest) ([]map[string]any, error) {
|
|
payloads := groupJoinRequestPayloads(items)
|
|
if len(items) == 0 || store.Users == nil {
|
|
return payloads, nil
|
|
}
|
|
seen := make(map[types.Uid]struct{}, len(items)*2)
|
|
uids := make([]types.Uid, 0, len(items)*2)
|
|
for i := range items {
|
|
for _, uid := range []types.Uid{items[i].ApplicantUserID, items[i].InviterUserID} {
|
|
if uid.IsZero() {
|
|
continue
|
|
}
|
|
if _, ok := seen[uid]; ok {
|
|
continue
|
|
}
|
|
seen[uid] = struct{}{}
|
|
uids = append(uids, uid)
|
|
}
|
|
}
|
|
if len(uids) == 0 {
|
|
return payloads, nil
|
|
}
|
|
users, err := store.Users.GetAll(tenantID, uids...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byID := make(map[types.Uid]*types.User, len(users))
|
|
for i := range users {
|
|
uid := types.ParseUid(users[i].Id)
|
|
if uid.IsZero() {
|
|
uid = types.ParseUserId(users[i].Id)
|
|
}
|
|
if uid.IsZero() {
|
|
continue
|
|
}
|
|
byID[uid] = &users[i]
|
|
}
|
|
for i := range items {
|
|
enrichGroupJoinRequestUserPayload(payloads[i], "user", "", items[i].ApplicantUserID, byID)
|
|
enrichGroupJoinRequestUserPayload(payloads[i], "inviter", "inviter_", items[i].InviterUserID, byID)
|
|
}
|
|
return payloads, nil
|
|
}
|
|
|
|
func enrichGroupJoinRequestUserPayload(data map[string]any, objectKey, fieldPrefix string, uid types.Uid, users map[types.Uid]*types.User) {
|
|
if data == nil || uid.IsZero() {
|
|
return
|
|
}
|
|
user := users[uid]
|
|
summary := map[string]any{"user_id": uid.UserId(), "display_name": uid.UserId()}
|
|
if user != nil {
|
|
summary = userSummaryPayload(user, "")
|
|
}
|
|
data[objectKey] = summary
|
|
if displayName, ok := summary["display_name"]; ok {
|
|
data[fieldPrefix+"display_name"] = displayName
|
|
}
|
|
if avatarFileID, ok := summary["avatar_file_id"]; ok {
|
|
data[fieldPrefix+"avatar_file_id"] = avatarFileID
|
|
}
|
|
}
|
|
|
|
func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any {
|
|
if ann == nil {
|
|
return map[string]any{
|
|
"announcement_id": "",
|
|
"content": "",
|
|
"author": "",
|
|
"version": 0,
|
|
"published_at": "",
|
|
"read_count": 0,
|
|
"member_count": 0,
|
|
"read_summary": map[string]any{"read_count": 0, "member_count": 0},
|
|
}
|
|
}
|
|
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),
|
|
"read_count": ann.ReadCount,
|
|
"member_count": ann.MemberCount,
|
|
"read_summary": map[string]any{"read_count": ann.ReadCount, "member_count": ann.MemberCount},
|
|
}
|
|
}
|
|
|
|
func groupAnnouncementPayloadWithPermissions(topic string, ann *types.GroupAnnouncement, selfSub *types.Subscription) map[string]any {
|
|
data := groupAnnouncementPayload(ann)
|
|
data["topic"] = topic
|
|
access := types.ModeNone
|
|
if selfSub != nil {
|
|
access = selfSub.ModeGiven & selfSub.ModeWant
|
|
}
|
|
data["permissions"] = map[string]any{
|
|
"can_edit": access.IsAdmin(),
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupMutePayload(mute types.GroupMute) map[string]any {
|
|
data := map[string]any{
|
|
"user_id": mute.UserID.UserId(),
|
|
"muted_by": mute.MutedBy.UserId(),
|
|
"muted_at": mute.MutedAt.UTC().Format(time.RFC3339Nano),
|
|
"reason": mute.Reason,
|
|
"status": mute.Status,
|
|
"version": mute.Version,
|
|
}
|
|
if mute.ExpiresAt != nil {
|
|
data["expires_at"] = mute.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupMutePayloads(items []types.GroupMute) []map[string]any {
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
out = append(out, groupMutePayload(item))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func groupAuxMap(value any) map[string]any {
|
|
switch aux := value.(type) {
|
|
case map[string]any:
|
|
return copyStringAnyMap(aux)
|
|
case types.KVMap:
|
|
return copyStringAnyMap(map[string]any(aux))
|
|
default:
|
|
return map[string]any{}
|
|
}
|
|
}
|
|
|
|
func groupPublicMap(value any) map[string]any {
|
|
switch public := value.(type) {
|
|
case map[string]any:
|
|
return copyStringAnyMap(public)
|
|
case types.KVMap:
|
|
return copyStringAnyMap(map[string]any(public))
|
|
default:
|
|
return map[string]any{}
|
|
}
|
|
}
|
|
|
|
func groupJoinRequests(group *types.Topic) []map[string]any {
|
|
if group == nil {
|
|
return nil
|
|
}
|
|
return sliceOfMaps(groupAuxMap(group.Aux)["join_requests"])
|
|
}
|
|
|
|
func filterGroupJoinRequests(items []map[string]any, status string) []map[string]any {
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
if status == "" || stringFromAny(item["status"]) == status {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func resolveGroupJoinRequest(items []map[string]any, requestID, action string, actor types.Uid, version int) (map[string]any, bool) {
|
|
now := time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano)
|
|
for i := range items {
|
|
if stringFromAny(items[i]["request_id"]) != requestID {
|
|
continue
|
|
}
|
|
currentVersion := intFromAny(items[i]["version"])
|
|
if version > 0 && currentVersion > 0 && version != currentVersion {
|
|
items[i]["status"] = "conflict"
|
|
return items[i], true
|
|
}
|
|
switch action {
|
|
case "accept":
|
|
items[i]["status"] = "accepted"
|
|
case "reject":
|
|
items[i]["status"] = "rejected"
|
|
default:
|
|
return nil, false
|
|
}
|
|
items[i]["handled_by"] = actor.UserId()
|
|
items[i]["handled_at"] = now
|
|
items[i]["updated_at"] = now
|
|
items[i]["version"] = currentVersion + 1
|
|
return items[i], true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func latestGroupAnnouncement(group *types.Topic) map[string]any {
|
|
if group == nil {
|
|
return map[string]any{}
|
|
}
|
|
return groupPrivateMap(groupAuxMap(group.Aux)["announcement"])
|
|
}
|
|
|
|
func groupMuteItems(aux map[string]any) []map[string]any {
|
|
return sliceOfMaps(aux["mutes"])
|
|
}
|
|
|
|
func upsertGroupMute(items []map[string]any, item map[string]any) []map[string]any {
|
|
userID := stringFromAny(item["user_id"])
|
|
for i := range items {
|
|
if stringFromAny(items[i]["user_id"]) == userID {
|
|
items[i] = item
|
|
return items
|
|
}
|
|
}
|
|
return append(items, item)
|
|
}
|
|
|
|
func removeGroupMute(items []map[string]any, userID string) []map[string]any {
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
if stringFromAny(item["user_id"]) != userID {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sliceOfMaps(value any) []map[string]any {
|
|
raw, ok := value.([]any)
|
|
if !ok {
|
|
if typed, ok := value.([]map[string]any); ok {
|
|
return typed
|
|
}
|
|
return nil
|
|
}
|
|
out := make([]map[string]any, 0, len(raw))
|
|
for _, item := range raw {
|
|
if m, ok := item.(map[string]any); ok {
|
|
out = append(out, copyStringAnyMap(m))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func applyBoolPatch(target map[string]any, key string, value *bool) {
|
|
if value != nil {
|
|
target[key] = *value
|
|
}
|
|
}
|
|
|
|
func groupRoleEligibility(actorAccess, memberAccess types.AccessMode, role string) (bool, string) {
|
|
if !actorAccess.IsOwner() {
|
|
return false, "owner_required"
|
|
}
|
|
switch role {
|
|
case "admin":
|
|
if memberAccess.IsOwner() {
|
|
return false, "owner"
|
|
}
|
|
if memberAccess.IsAdmin() {
|
|
return false, "already_admin"
|
|
}
|
|
return true, ""
|
|
case "owner":
|
|
if memberAccess.IsOwner() {
|
|
return false, "current_owner"
|
|
}
|
|
return memberAccess.IsJoiner(), ""
|
|
default:
|
|
return false, "unsupported_role"
|
|
}
|
|
}
|
|
|
|
func groupMuteEligibility(actorAccess, memberAccess types.AccessMode) (bool, string) {
|
|
if !actorAccess.IsAdmin() {
|
|
return false, "admin_required"
|
|
}
|
|
if memberAccess.IsOwner() {
|
|
return false, "owner"
|
|
}
|
|
return memberAccess.IsJoiner(), ""
|
|
}
|
|
|
|
func countGroupAdmins(members []types.Subscription) int {
|
|
count := 0
|
|
for _, member := range members {
|
|
access := member.ModeGiven & member.ModeWant
|
|
if access.IsAdmin() && !access.IsOwner() {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func groupAdminItems(members []types.Subscription) []map[string]any {
|
|
items := make([]map[string]any, 0)
|
|
for _, member := range members {
|
|
access := member.ModeGiven & member.ModeWant
|
|
if access.IsAdmin() && !access.IsOwner() {
|
|
items = append(items, groupMemberPayload(member, true, ""))
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func groupPrivateMap(value any) map[string]any {
|
|
switch private := value.(type) {
|
|
case map[string]any:
|
|
return copyStringAnyMap(private)
|
|
case types.KVMap:
|
|
return copyStringAnyMap(map[string]any(private))
|
|
default:
|
|
return map[string]any{}
|
|
}
|
|
}
|
|
|
|
func groupMemberPrivate(nickname string, pinned, mute bool, version int, updatedAt time.Time) map[string]any {
|
|
return map[string]any{
|
|
"nickname": nickname,
|
|
"pinned": pinned,
|
|
"mute_notifications": mute,
|
|
"version": version,
|
|
"profile_version": 1,
|
|
"updated_at": updatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func groupName(public map[string]any, fallback string) string {
|
|
for _, key := range []string{"name", "display_name", "fn"} {
|
|
value := strings.TrimSpace(fmt.Sprint(public[key]))
|
|
if value != "" && value != "<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 int, cursor string, mentionableOnly, muteEligible bool) string {
|
|
return strings.Join([]string{
|
|
topic,
|
|
"v3",
|
|
uid.UserId(),
|
|
access.String(),
|
|
strings.ToLower(strings.TrimSpace(query)),
|
|
strings.TrimSpace(roleFilter),
|
|
strings.TrimSpace(eligibleRole),
|
|
strconv.Itoa(limit),
|
|
strings.TrimSpace(cursor),
|
|
strconv.FormatBool(mentionableOnly),
|
|
strconv.FormatBool(muteEligible),
|
|
}, "|")
|
|
}
|
|
|
|
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, allowAdmin bool) (*types.Message, bool, error) {
|
|
if principal == nil {
|
|
return nil, false, types.ErrPermissionDenied
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
return nil, false, types.ErrPermissionDenied
|
|
}
|
|
msg, err := store.Messages.GetOne(principal.TenantID, topic, seq)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if msg == nil {
|
|
return nil, false, types.ErrNotFound
|
|
}
|
|
if msg.RecalledAt != nil || msg.DeletedAt != nil {
|
|
return msg, false, types.ErrDuplicate
|
|
}
|
|
from := types.ParseUid(msg.From)
|
|
access := sub.ModeGiven & sub.ModeWant
|
|
authorized := from == principal.Uid || (allowAdmin && access.IsAdmin())
|
|
return msg, authorized, nil
|
|
}
|
|
|
|
func isIMEditableTextMessage(msg *types.Message) bool {
|
|
if msg == nil {
|
|
return false
|
|
}
|
|
if msg.Head == nil {
|
|
return true
|
|
}
|
|
msgType, _ := msg.Head["type"].(string)
|
|
return msgType == "" || msgType == "text"
|
|
}
|
|
|
|
func writeIMMessageMutationError(wrt http.ResponseWriter, req *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found", nil)
|
|
case errors.Is(err, types.ErrDuplicate):
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict", nil)
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
case errors.Is(err, types.ErrMalformed):
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
}
|
|
}
|
|
|
|
func messageMutationErrorItem(seq int64, err error) map[string]any {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
return messageBatchRecallErrorItem(seq, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found")
|
|
case errors.Is(err, types.ErrDuplicate):
|
|
return messageBatchRecallErrorItem(seq, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict")
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
return messageBatchRecallErrorItem(seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required")
|
|
case errors.Is(err, types.ErrMalformed):
|
|
return messageBatchRecallErrorItem(seq, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request")
|
|
default:
|
|
return messageBatchRecallErrorItem(seq, http.StatusInternalServerError, 90000, imReasonInternal, "internal error")
|
|
}
|
|
}
|
|
|
|
func messageBatchRecallErrorItem(seq int64, status, code int, reason, message string) map[string]any {
|
|
return map[string]any{
|
|
"seq": seq,
|
|
"status": "failed",
|
|
"http": status,
|
|
"code": code,
|
|
"reason": reason,
|
|
"message": message,
|
|
}
|
|
}
|
|
|
|
func messageMutationPayload(msg *types.Message, status string) map[string]any {
|
|
data := map[string]any{
|
|
"status": status,
|
|
"topic": msg.Topic,
|
|
"seq": msg.SeqId,
|
|
"version": msg.Version,
|
|
"updated_at": msg.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if msg.RecalledAt != nil {
|
|
data["recalled_at"] = msg.RecalledAt.UTC().Format(time.RFC3339Nano)
|
|
data["recalled_by"] = msg.RecalledBy
|
|
}
|
|
if status == "edited" {
|
|
data["head"] = msg.Head
|
|
data["content"] = msg.Content
|
|
}
|
|
return data
|
|
}
|
|
|
|
func favoritePayload(item types.FavoriteItem) map[string]any {
|
|
return map[string]any{
|
|
"favorite_id": item.FavoriteID,
|
|
"type": item.ItemType,
|
|
"snapshot": item.Snapshot,
|
|
"source": favoriteSourcePayload(item.SourceTopic, item.SourceSeq, types.ZeroUid),
|
|
"collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano),
|
|
"availability": item.Availability,
|
|
}
|
|
}
|
|
|
|
func favoritePayloadForPrincipal(principal *imPrincipal, item types.FavoriteItem) (map[string]any, bool) {
|
|
if principal == nil {
|
|
return nil, false
|
|
}
|
|
sourceTopic, ok := normalizeIMFavoriteSourceTopic(principal.Uid, item.SourceTopic)
|
|
if !ok {
|
|
if legacyFavoriteSnapshotOnly(item) {
|
|
if item.Availability == "" {
|
|
item.Availability = "available"
|
|
}
|
|
return favoritePayload(item), true
|
|
}
|
|
return nil, false
|
|
}
|
|
if err := requireTopicReader(principal, sourceTopic); err != nil {
|
|
return nil, false
|
|
}
|
|
item.SourceTopic = sourceTopic
|
|
if store.Messages != nil {
|
|
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, item.SourceSeq)
|
|
if err == nil && msg != nil {
|
|
item.Availability = messageAvailability(*msg)
|
|
if item.Availability == "available" {
|
|
item.ItemType = imMessageType(msg)
|
|
item.Snapshot = favoriteSnapshot(msg)
|
|
item.AttachmentRefs = favoriteAttachmentRefs(msg)
|
|
}
|
|
} else if errors.Is(err, types.ErrNotFound) {
|
|
item.Availability = "removed"
|
|
}
|
|
}
|
|
return favoritePayloadForViewer(principal.Uid, item), true
|
|
}
|
|
|
|
func favoritePayloadForViewer(viewer types.Uid, item types.FavoriteItem) map[string]any {
|
|
payload := favoritePayload(item)
|
|
payload["source"] = favoriteSourcePayload(item.SourceTopic, item.SourceSeq, viewer)
|
|
return payload
|
|
}
|
|
|
|
func favoriteSourcePayload(topic string, seq int64, viewer types.Uid) map[string]any {
|
|
source := map[string]any{"topic": topic, "seq": seq}
|
|
if viewer.IsZero() || types.GetTopicCat(topic) != types.TopicCatP2P {
|
|
return source
|
|
}
|
|
peerTopic, err := types.P2PNameForUser(viewer, topic)
|
|
if err != nil || strings.TrimSpace(peerTopic) == "" {
|
|
return source
|
|
}
|
|
source["peer_user_id"] = peerTopic
|
|
source["display_topic"] = peerTopic
|
|
return source
|
|
}
|
|
|
|
func legacyFavoriteSnapshotOnly(item types.FavoriteItem) bool {
|
|
source := strings.TrimSpace(item.SourceTopic)
|
|
return strings.HasPrefix(source, "favSeed") && item.Snapshot != nil
|
|
}
|
|
|
|
func messageSearchPayload(msg types.Message, query string) map[string]any {
|
|
text := messageText(msg)
|
|
snippet := textSnippet(text, query)
|
|
return map[string]any{
|
|
"seq": msg.SeqId,
|
|
"from": messageFromUserID(msg.From),
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"type": imMessageType(&msg),
|
|
"version": msg.Version,
|
|
"snippet": snippet,
|
|
"matches": textMatches(snippet, query),
|
|
"snapshot": favoriteSnapshot(&msg),
|
|
"availability": messageAvailability(msg),
|
|
}
|
|
}
|
|
|
|
func messageMutationEventPayload(mutation types.MessageMutation) map[string]any {
|
|
return map[string]any{
|
|
"seq": mutation.SeqId,
|
|
"version": mutation.Version,
|
|
"event": mutation.Event,
|
|
"operator": mutation.Operator,
|
|
"mutated_at": mutation.MutatedAt.UTC().Format(time.RFC3339Nano),
|
|
"mutation_version": mutation.MutationVersion,
|
|
}
|
|
}
|
|
|
|
func messageContextPayload(msg types.Message) map[string]any {
|
|
return map[string]any{
|
|
"seq": msg.SeqId,
|
|
"from": messageFromUserID(msg.From),
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"type": imMessageType(&msg),
|
|
"version": msg.Version,
|
|
"head": msg.Head,
|
|
"content": msg.Content,
|
|
"availability": messageAvailability(msg),
|
|
}
|
|
}
|
|
|
|
func mediaMessagePayload(msg types.Message) map[string]any {
|
|
snapshot := favoriteSnapshot(&msg)
|
|
data := map[string]any{
|
|
"seq": msg.SeqId,
|
|
"from": messageFromUserID(msg.From),
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"availability": messageAvailability(msg),
|
|
}
|
|
if content, ok := snapshot["content"].(map[string]any); ok {
|
|
for key, value := range content {
|
|
data[key] = value
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func messageFromUserID(from string) string {
|
|
from = strings.TrimSpace(from)
|
|
if from == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(from, "usr") {
|
|
if uid := types.ParseUserId(from); !uid.IsZero() {
|
|
return uid.UserId()
|
|
}
|
|
return from
|
|
}
|
|
if uid := types.ParseUid(from); !uid.IsZero() {
|
|
return uid.UserId()
|
|
}
|
|
return from
|
|
}
|
|
|
|
func callRecordPayloads(records []types.CallRecord, viewer types.Uid) []map[string]any {
|
|
items := make([]map[string]any, 0, len(records))
|
|
for _, record := range records {
|
|
items = append(items, callRecordPayload(record, viewer))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any {
|
|
peer := record.CalleeID
|
|
direction := "outgoing"
|
|
if record.CalleeID == viewer {
|
|
peer = record.CallerID
|
|
direction = "incoming"
|
|
}
|
|
displayTopic := record.Topic
|
|
if viewTopic, err := types.P2PNameForUser(viewer, record.Topic); err == nil && viewTopic != "" {
|
|
displayTopic = viewTopic
|
|
}
|
|
ts := record.StartedAt
|
|
if ts.IsZero() {
|
|
ts = record.CreatedAt
|
|
}
|
|
data := map[string]any{
|
|
"call_id": record.CallID,
|
|
"topic": record.Topic,
|
|
"display_topic": displayTopic,
|
|
"seq": record.SeqID,
|
|
"caller_id": record.CallerID.UserId(),
|
|
"callee_id": record.CalleeID.UserId(),
|
|
"peer_user_id": peer.UserId(),
|
|
"peer": map[string]any{
|
|
"user_id": peer.UserId(),
|
|
"topic": displayTopic,
|
|
},
|
|
"direction": direction,
|
|
"incoming": direction == "incoming",
|
|
"call_type": record.CallType,
|
|
"status": record.Status,
|
|
"result": record.Status,
|
|
"duration_ms": record.DurationMS,
|
|
"ts": ts.UTC().Format(time.RFC3339Nano),
|
|
"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 bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid) (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
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
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 userProfilePayload10(profile *types.UserProfile) map[string]any {
|
|
data := map[string]any{
|
|
"avatar_file_id": profile.AvatarFileID,
|
|
"nickname": profile.Nickname,
|
|
"birth_date": profile.BirthDate,
|
|
"signature": profile.Signature,
|
|
"gender": profile.Gender,
|
|
"region": profile.Region,
|
|
"email": profile.Email,
|
|
"public_account": profile.PublicAccount,
|
|
"version": profile.Version,
|
|
"updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"field_rules": map[string]any{
|
|
"nickname": map[string]any{"min_length": 1, "max_length": 32},
|
|
"signature": map[string]any{"max_length": 120},
|
|
"region": map[string]any{"max_length": 128},
|
|
"birth_date": map[string]any{"format": "YYYY-MM-DD"},
|
|
"gender": []string{"male", "female", "other", ""},
|
|
"email": map[string]any{"max_length": 128},
|
|
},
|
|
}
|
|
return data
|
|
}
|
|
|
|
func currentUserProfilePayload(tenantID types.TenantID, uid types.Uid, profile *types.UserProfile) (map[string]any, error) {
|
|
account, err := currentUserAccountPayload(tenantID, uid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data := userProfilePayload10(profile)
|
|
applyDefaultUserAvatar(data, uid.UserId(), profile.Gender)
|
|
data["phone_mask"] = account["phone_mask"]
|
|
data["username"] = account["username"]
|
|
return data, nil
|
|
}
|
|
|
|
func currentUserAccountPayload(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
|
user, err := store.Users.Get(tenantID, uid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data := map[string]any{"phone_mask": "", "username": ""}
|
|
if user != nil {
|
|
data["phone_mask"] = maskIMProfilePhone(user.Phone)
|
|
data["username"] = user.Username
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func validIMSecurityChallengeScene(scene string) bool {
|
|
switch strings.TrimSpace(scene) {
|
|
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password",
|
|
"group_owner_transfer", "group_dissolve":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any {
|
|
return map[string]any{
|
|
"add_me_methods": settings.AddMeMethods,
|
|
"profile_visibility": settings.ProfileVisibility,
|
|
"last_seen_visibility": settings.LastSeenVisibility,
|
|
"read_receipts": settings.ReadReceipts,
|
|
"friend_request_verification": settings.FriendRequestVerification,
|
|
"group_invite_verification": settings.GroupInviteVerification,
|
|
"version": settings.Version,
|
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"editable": map[string]any{
|
|
"add_me_methods": true,
|
|
"profile_visibility": true,
|
|
"last_seen_visibility": true,
|
|
"read_receipts": true,
|
|
"friend_request_verification": true,
|
|
"group_invite_verification": true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func imPrivacyAllowsAdd(settings *types.UserPrivacySettings, source string) bool {
|
|
if settings == nil {
|
|
return false
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
|
case "phone", "qr", "group":
|
|
source = strings.ToLower(strings.TrimSpace(source))
|
|
default:
|
|
source = "account"
|
|
}
|
|
allowed, exists := settings.AddMeMethods[source]
|
|
return !exists || allowed
|
|
}
|
|
|
|
func imPrivacyAllowsProfile(settings *types.UserPrivacySettings, relationship string) bool {
|
|
if settings == nil {
|
|
return false
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(settings.ProfileVisibility)) {
|
|
case "everyone":
|
|
return true
|
|
case "friends", "":
|
|
return relationship == types.RelationshipFriend
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func imCanViewLastSeen(tenantID types.TenantID, viewer, target types.Uid) bool {
|
|
if viewer.IsZero() || target.IsZero() {
|
|
return false
|
|
}
|
|
if viewer == target {
|
|
return true
|
|
}
|
|
// Legacy deployments without the product privacy stores retain MeetingIM's
|
|
// original presence behavior.
|
|
if store.Profiles == nil || store.Relations == nil {
|
|
return true
|
|
}
|
|
settings, err := store.Profiles.GetPrivacy(tenantID, target)
|
|
if err != nil || settings == nil {
|
|
return false
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(settings.LastSeenVisibility)) {
|
|
case "everyone":
|
|
return true
|
|
case "friends", "":
|
|
ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, target, viewer.P2PName(target))
|
|
return err == nil && ctx != nil && ctx.Relationship == types.RelationshipFriend
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func appReleasePayload(release *types.AppRelease, currentVersion string, hasUpdate bool, updateType string) map[string]any {
|
|
return map[string]any{
|
|
"current_version": currentVersion,
|
|
"has_update": hasUpdate,
|
|
"force_update": hasUpdate && updateType == "force",
|
|
"platform": release.Platform,
|
|
"channel": release.Channel,
|
|
"latest_version": release.LatestVersion,
|
|
"min_supported_version": release.MinSupportedVersion,
|
|
"update_type": updateType,
|
|
"release_notes": release.ReleaseNotes,
|
|
"store_url": release.StoreURL,
|
|
"package_url": release.PackageURL,
|
|
"checksum": release.Checksum,
|
|
"remind_after_seconds": release.RemindAfterSeconds,
|
|
"published_at": release.PublishedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func parseNumericAppVersion(raw string) ([]uint64, error) {
|
|
raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v")
|
|
parts := strings.Split(raw, ".")
|
|
if len(parts) == 0 || len(parts) > 8 {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
parsed := make([]uint64, len(parts))
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
value, err := strconv.ParseUint(part, 10, 32)
|
|
if err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
parsed[i] = value
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func compareNumericAppVersion(left, right []uint64) int {
|
|
length := len(left)
|
|
if len(right) > length {
|
|
length = len(right)
|
|
}
|
|
for i := 0; i < length; i++ {
|
|
var leftPart, rightPart uint64
|
|
if i < len(left) {
|
|
leftPart = left[i]
|
|
}
|
|
if i < len(right) {
|
|
rightPart = right[i]
|
|
}
|
|
if leftPart < rightPart {
|
|
return -1
|
|
}
|
|
if leftPart > rightPart {
|
|
return 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
|
|
data := map[string]any{
|
|
"enabled": settings.Enabled,
|
|
"policy_version": settings.PolicyVersion,
|
|
"restrictions": settings.Restrictions,
|
|
"restriction_items": youthModeRestrictionItems(settings.Restrictions),
|
|
"password_set": settings.PasswordSet,
|
|
"can_enable": settings.CanEnable,
|
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if settings.EnabledAt != nil {
|
|
data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func youthModeRestrictionItems(restrictions []string) []map[string]any {
|
|
catalog := map[string]map[string]any{
|
|
"night_quiet": {
|
|
"code": "night_quiet",
|
|
"title": "深夜免打扰",
|
|
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
|
"enforced": true,
|
|
},
|
|
"content_filter": {
|
|
"code": "content_filter",
|
|
"title": "内容过滤",
|
|
"description": "对不适合青少年的内容进行识别和限制展示。",
|
|
"enforced": true,
|
|
},
|
|
"payment_guard": {
|
|
"code": "payment_guard",
|
|
"title": "支付守护",
|
|
"description": "限制或确认支付、充值等敏感消费行为。",
|
|
"enforced": false,
|
|
},
|
|
}
|
|
items := make([]map[string]any, 0, len(restrictions))
|
|
for _, code := range restrictions {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
continue
|
|
}
|
|
if item, ok := catalog[code]; ok {
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{
|
|
"code": code,
|
|
"title": code,
|
|
"description": "",
|
|
"enforced": false,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
|
|
return map[string]any{
|
|
"device_id": token.DeviceID,
|
|
"provider": token.Provider,
|
|
"app_version": token.AppVersion,
|
|
"platform": token.Platform,
|
|
"sdk_version": token.SDKVersion,
|
|
"push_env": token.PushEnv,
|
|
"notification_permission": token.NotificationPermission,
|
|
"updated_at": token.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func riskChallengePayload(challenge *types.RiskChallenge) map[string]any {
|
|
return map[string]any{
|
|
"risk_challenge_id": challenge.ChallengeID,
|
|
"action": challenge.Action,
|
|
"level": challenge.Level,
|
|
"notice_code": challenge.NoticeCode,
|
|
"notice_params": challenge.NoticeParams,
|
|
"state": challenge.State,
|
|
"decision": challenge.Decision,
|
|
"expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": challenge.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func abuseReasonPayload(reason types.AbuseReason) map[string]any {
|
|
return map[string]any{
|
|
"target_type": reason.TargetType,
|
|
"reason_code": reason.ReasonCode,
|
|
"label": reason.Label,
|
|
"requires_description": reason.RequiresDescription,
|
|
"allows_evidence": reason.AllowsEvidence,
|
|
}
|
|
}
|
|
|
|
func defaultIMAbuseReasons(targetType string) []types.AbuseReason {
|
|
targetType = strings.ToLower(strings.TrimSpace(targetType))
|
|
if targetType == "" {
|
|
targetType = "message"
|
|
}
|
|
return []types.AbuseReason{
|
|
{TargetType: targetType, ReasonCode: "spam", Label: "垃圾广告", RequiresDescription: false, AllowsEvidence: true, SortOrder: 10},
|
|
{TargetType: targetType, ReasonCode: "fraud", Label: "欺诈风险", RequiresDescription: false, AllowsEvidence: true, SortOrder: 20},
|
|
{TargetType: targetType, ReasonCode: "harassment", Label: "骚扰辱骂", RequiresDescription: false, AllowsEvidence: true, SortOrder: 30},
|
|
{TargetType: targetType, ReasonCode: "other", Label: "其他", RequiresDescription: true, AllowsEvidence: true, SortOrder: 99},
|
|
}
|
|
}
|
|
|
|
func isAllowedIMAbuseTargetType(targetType string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(targetType)) {
|
|
case "user", "group", "message":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func abuseReportPayload(report *types.AbuseReport) map[string]any {
|
|
return map[string]any{
|
|
"report_id": report.ReportID,
|
|
"status": report.Status,
|
|
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func feedbackConfigPayload(config *types.FeedbackConfig) map[string]any {
|
|
return map[string]any{
|
|
"categories": config.Categories,
|
|
"max_length": config.MaxLength,
|
|
"attachment_policy": config.AttachmentPolicy,
|
|
"allow_contact": config.AllowContact,
|
|
"diagnostics_notice": config.DiagnosticsNotice,
|
|
"updated_at": config.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]any {
|
|
return map[string]any{
|
|
"feedback_id": feedback.FeedbackID,
|
|
"status": feedback.Status,
|
|
"created_at": feedback.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func publicUploadPayload(upload *types.PublicUpload) map[string]any {
|
|
return map[string]any{
|
|
"file_id": upload.FileID,
|
|
"purpose": upload.Purpose,
|
|
"mime_type": upload.MimeType,
|
|
"size": upload.Size,
|
|
"expires_at": upload.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func tenantApplicationPayload(application *types.TenantApplication) map[string]any {
|
|
return map[string]any{
|
|
"application_id": application.ApplicationID,
|
|
"status": application.Status,
|
|
"estimated_review_hours": application.EstimatedReviewHour,
|
|
"submitted_at": application.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]any {
|
|
return map[string]any{
|
|
"feedback_id": feedback.FeedbackID,
|
|
"status": feedback.Status,
|
|
"submitted_at": feedback.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any {
|
|
data := map[string]any{
|
|
"topic": ctx.Topic,
|
|
"peer_user_id": ctx.PeerID.UserId(),
|
|
"relationship": ctx.Relationship,
|
|
"can_send": ctx.CanSend,
|
|
"capabilities": map[string]any{
|
|
"can_send_text": ctx.CanSend,
|
|
"can_send_media": ctx.CanSend,
|
|
"can_send_card": ctx.CanSend,
|
|
"can_call": imCanCall(ctx.CanSend),
|
|
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
|
"can_handle_request": ctx.Relationship == types.RelationshipPendingInbound,
|
|
},
|
|
}
|
|
if ctx.RequestID != "" {
|
|
data["request_id"] = ctx.RequestID
|
|
}
|
|
if ctx.Remark != "" {
|
|
data["remark"] = ctx.Remark
|
|
}
|
|
if ctx.Peer != nil {
|
|
peer := userSummaryPayload(ctx.Peer, ctx.Remark)
|
|
peer["public"] = ctx.Peer.Public
|
|
peer["trusted"] = ctx.Peer.Trusted
|
|
data["peer"] = peer
|
|
if displayName, ok := peer["display_name"]; ok {
|
|
data["display_name"] = displayName
|
|
}
|
|
if avatarFileID, ok := peer["avatar_file_id"]; ok {
|
|
data["avatar_file_id"] = avatarFileID
|
|
}
|
|
}
|
|
if _, ok := data["display_name"]; !ok {
|
|
data["display_name"] = ctx.PeerID.UserId()
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendRequestPayload(req *types.FriendRequest) map[string]any {
|
|
data := map[string]any{
|
|
"request_id": req.RequestID,
|
|
"applicant_id": req.ApplicantID.UserId(),
|
|
"target_user_id": req.TargetID.UserId(),
|
|
"message": req.Message,
|
|
"source": req.Source,
|
|
"status": req.Status,
|
|
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if topic := req.ApplicantID.P2PName(req.TargetID); topic != "" {
|
|
data["topic"] = topic
|
|
}
|
|
if req.SourceTopic != "" {
|
|
data["source_topic"] = req.SourceTopic
|
|
}
|
|
if req.HandledAt != nil {
|
|
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]any {
|
|
data := friendRequestPayload(&item.Request)
|
|
data["direction"] = item.Direction
|
|
if item.Peer != nil {
|
|
data["peer"] = userSummaryPayload(item.Peer, "")
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendListItemPayload(tenantID types.TenantID, viewer types.Uid, item types.FriendListItem) map[string]any {
|
|
relationship := item.Friendship.Status
|
|
canSend := relationship == types.RelationshipFriend
|
|
if ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, item.Friendship.PeerID, item.Friendship.Topic); err == nil && ctx != nil {
|
|
relationship = ctx.Relationship
|
|
canSend = ctx.CanSend
|
|
}
|
|
online := false
|
|
onlineVisibility := "hidden"
|
|
if relationship == types.RelationshipFriend && imCanViewLastSeen(tenantID, viewer, item.Friendship.PeerID) {
|
|
onlineVisibility = "visible"
|
|
online = redisUserOnline(tenantID, item.Friendship.PeerID, false)
|
|
}
|
|
data := map[string]any{
|
|
"user_id": item.Friendship.PeerID.UserId(),
|
|
"relationship": relationship,
|
|
"remark": item.Friendship.Remark,
|
|
"topic": item.Friendship.Topic,
|
|
"online": online,
|
|
"online_visibility": onlineVisibility,
|
|
"created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"capabilities": map[string]any{"can_send": canSend, "can_call": imCanCall(canSend)},
|
|
"display_name_source": "profile",
|
|
}
|
|
if item.Friendship.Remark != "" {
|
|
data["display_name"] = item.Friendship.Remark
|
|
data["display_name_source"] = "remark"
|
|
}
|
|
if item.Peer != nil {
|
|
for key, value := range userSummaryPayload(item.Peer, item.Friendship.Remark) {
|
|
_, exists := data[key]
|
|
if !exists || key == "avatar_file_id" || (key == "display_name" && item.Friendship.Remark == "") {
|
|
data[key] = value
|
|
}
|
|
}
|
|
data["peer"] = userSummaryPayload(item.Peer, item.Friendship.Remark)
|
|
if peer, ok := data["peer"].(map[string]any); ok {
|
|
peer["online"] = online
|
|
}
|
|
}
|
|
if item.Friendship.Remark == "" && data["display_name"] == data["user_id"] {
|
|
data["display_name_source"] = "user_id"
|
|
}
|
|
data["initial"] = displayInitial(data["display_name"])
|
|
return data
|
|
}
|
|
|
|
func userSearchItemPayload(item types.UserSearchItem) map[string]any {
|
|
data := userSummaryPayload(item.User, "")
|
|
if public, ok := item.User.Public.(map[string]any); ok {
|
|
copyOptionalProfileFields(data, public)
|
|
} else if public, ok := item.User.Public.(types.KVMap); ok {
|
|
copyOptionalProfileFields(data, map[string]any(public))
|
|
}
|
|
data["relationship"] = item.Relationship
|
|
data["can_add"] = item.CanAdd
|
|
data["action"] = item.Action
|
|
if item.RequestID != "" {
|
|
data["request_id"] = item.RequestID
|
|
}
|
|
return data
|
|
}
|
|
|
|
func imRelationshipAction(relationship string) string {
|
|
switch relationship {
|
|
case types.RelationshipFriend:
|
|
return "chat"
|
|
case types.RelationshipPendingInbound, types.RelationshipPendingOutbound:
|
|
return "pending"
|
|
case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer:
|
|
return "none"
|
|
default:
|
|
return "add"
|
|
}
|
|
}
|
|
|
|
func blockListItemPayload(item types.BlockListItem) map[string]any {
|
|
data := map[string]any{
|
|
"user_id": item.Block.BlockedID.UserId(),
|
|
"blocked_at": item.Block.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": item.Block.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if item.User != nil {
|
|
data["user"] = userSummaryPayload(item.User, "")
|
|
}
|
|
return data
|
|
}
|
|
|
|
func systemNotificationPayloads(items []types.SystemNotification) []map[string]any {
|
|
payloads := make([]map[string]any, 0, len(items))
|
|
for i := range items {
|
|
payloads = append(payloads, systemNotificationPayload(items[i]))
|
|
}
|
|
return payloads
|
|
}
|
|
|
|
func systemNotificationPayload(item types.SystemNotification) map[string]any {
|
|
summary := strings.TrimSpace(item.Summary)
|
|
if summary == "" {
|
|
summary = strings.TrimSpace(item.Title)
|
|
}
|
|
action := normalizeSystemNotificationAction(item.Action, item.EventType)
|
|
data := map[string]any{
|
|
"id": item.EventID,
|
|
"event_type": item.EventType,
|
|
"title": item.Title,
|
|
"summary": summary,
|
|
"action": action,
|
|
"created_at": item.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if item.ReadAt != nil {
|
|
data["read_at"] = item.ReadAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func normalizeSystemNotificationAction(action types.KVMap, eventType string) map[string]any {
|
|
normalized := map[string]any{
|
|
"type": strings.TrimSpace(eventType),
|
|
"target_id": "",
|
|
"payload": map[string]any{},
|
|
}
|
|
payload := map[string]any{}
|
|
for key, value := range action {
|
|
payload[key] = value
|
|
normalized[key] = value
|
|
}
|
|
for _, key := range []string{"meeting_id", "group_id", "topic", "resource_id", "target_id"} {
|
|
if value := strings.TrimSpace(fmt.Sprint(payload[key])); value != "" && value != "<nil>" {
|
|
normalized["target_id"] = value
|
|
break
|
|
}
|
|
}
|
|
normalized["payload"] = payload
|
|
return normalized
|
|
}
|
|
|
|
func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
|
|
data := userSummaryPayload(user, ctx.Remark)
|
|
data["username"] = user.Username
|
|
if original := userOriginalDisplayName(user); original != "" {
|
|
data["profile_display_name"] = original
|
|
data["original_display_name"] = original
|
|
}
|
|
data["relationship"] = ctx.Relationship
|
|
data["topic"] = ctx.Topic
|
|
data["capabilities"] = map[string]any{
|
|
"can_send": ctx.CanSend,
|
|
"can_call": imCanCall(ctx.CanSend),
|
|
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
|
}
|
|
if ctx.Remark != "" {
|
|
data["remark"] = ctx.Remark
|
|
}
|
|
if ctx.RequestID != "" {
|
|
data["request_id"] = ctx.RequestID
|
|
}
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyOptionalProfileFields(data, public)
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
copyOptionalProfileFields(data, map[string]any(public))
|
|
}
|
|
return data
|
|
}
|
|
|
|
func blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
|
|
data := userSummaryPayload(user, ctx.Remark)
|
|
data["username"] = user.Username
|
|
if original := userOriginalDisplayName(user); original != "" {
|
|
data["profile_display_name"] = original
|
|
data["original_display_name"] = original
|
|
}
|
|
data["relationship"] = ctx.Relationship
|
|
data["topic"] = ctx.Topic
|
|
data["capabilities"] = map[string]any{
|
|
"can_send": false,
|
|
"can_call": false,
|
|
"can_add_friend": false,
|
|
}
|
|
if ctx.Remark != "" {
|
|
data["remark"] = ctx.Remark
|
|
}
|
|
return data
|
|
}
|
|
|
|
func copyOptionalProfileFields(data map[string]any, public map[string]any) {
|
|
for _, key := range []string{"nickname", "gender", "region", "public_account", "signature"} {
|
|
if value, ok := public[key]; ok {
|
|
data[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
func userSummaryPayload(user *types.User, remark string) map[string]any {
|
|
uid := types.ParseUid(user.Id)
|
|
userID := uid.UserId()
|
|
if uid.IsZero() {
|
|
userID = user.Id
|
|
}
|
|
data := map[string]any{"user_id": userID}
|
|
gender := ""
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyUserPublicSummary(data, public)
|
|
gender = stringFromAny(public["gender"])
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
publicMap := map[string]any(public)
|
|
copyUserPublicSummary(data, publicMap)
|
|
gender = stringFromAny(publicMap["gender"])
|
|
}
|
|
if remark != "" {
|
|
data["display_name"] = remark
|
|
}
|
|
if _, ok := data["display_name"]; !ok {
|
|
data["display_name"] = userID
|
|
}
|
|
applyDefaultUserAvatar(data, userID, gender)
|
|
return data
|
|
}
|
|
|
|
func userOriginalDisplayName(user *types.User) string {
|
|
data := map[string]any{}
|
|
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 value := strings.TrimSpace(fmt.Sprint(data["display_name"])); value != "" && value != "<nil>" {
|
|
return value
|
|
}
|
|
uid := types.ParseUid(user.Id)
|
|
if uid.IsZero() {
|
|
return user.Id
|
|
}
|
|
return uid.UserId()
|
|
}
|
|
|
|
func copyUserPublicSummary(data map[string]any, public map[string]any) {
|
|
for _, key := range []string{"display_name", "fn", "name", "nickname"} {
|
|
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
|
data["display_name"] = value
|
|
break
|
|
}
|
|
}
|
|
for _, key := range []string{"avatar_file_id", "photo", "avatar"} {
|
|
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
|
data["avatar_file_id"] = value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func displayInitial(value any) string {
|
|
name := strings.TrimSpace(fmt.Sprint(value))
|
|
if name == "" {
|
|
return "#"
|
|
}
|
|
for _, r := range name {
|
|
return strings.ToUpper(string(r))
|
|
}
|
|
return "#"
|
|
}
|
|
|
|
func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonResourceNotFound, "resource not found", nil)
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
writeIMError(wrt, req, http.StatusForbidden, 20005, imReasonPermissionDenied, "permission denied", nil)
|
|
case errors.Is(err, types.ErrInvalidResponse):
|
|
writeIMError(wrt, req, http.StatusConflict, 20009, "VERSION_CONFLICT", "version conflict", nil)
|
|
case errors.Is(err, types.ErrMalformed):
|
|
writeIMError(wrt, req, http.StatusBadRequest, 20000, imReasonMalformedRequest, "malformed request", nil)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
}
|
|
}
|
|
|
|
func relationErrorReason(err error) string {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
return "NOT_FOUND"
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
return imReasonPermissionDenied
|
|
case errors.Is(err, types.ErrMalformed):
|
|
return imReasonMalformedRequest
|
|
default:
|
|
return imReasonInternal
|
|
}
|
|
}
|
|
|
|
func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, needRead bool) (*imPrincipal, *types.Subscription, bool) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return nil, nil, false
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, nil, false
|
|
}
|
|
access := types.ModeNone
|
|
if sub != nil {
|
|
access = sub.ModeGiven & sub.ModeWant
|
|
}
|
|
if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
|
|
return nil, nil, false
|
|
}
|
|
return principal, sub, true
|
|
}
|
|
|
|
func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return nil, "", false
|
|
}
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return nil, "", false
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return nil, "", false
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return nil, "", false
|
|
}
|
|
return principal, topic, true
|
|
}
|
|
|
|
func requireTopicWriter(principal *imPrincipal, topic string) error {
|
|
if principal == nil || topic == "" {
|
|
return types.ErrMalformed
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsWriter() {
|
|
return types.ErrPermissionDenied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireTopicReader(principal *imPrincipal, topic string) error {
|
|
if principal == nil || topic == "" {
|
|
return types.ErrMalformed
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
return types.ErrPermissionDenied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyStringAnyMap(in map[string]any) map[string]any {
|
|
out := make(map[string]any, len(in))
|
|
for key, value := range in {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func stringFromAny(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
|
|
func defaultString(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func boolFromAny(value any) bool {
|
|
if got, ok := value.(bool); ok {
|
|
return got
|
|
}
|
|
if got, ok := value.(string); ok {
|
|
return got == "true" || got == "1"
|
|
}
|
|
return false
|
|
}
|
|
|
|
func intFromAny(value any) int {
|
|
switch got := value.(type) {
|
|
case int:
|
|
return got
|
|
case int64:
|
|
return int(got)
|
|
case float64:
|
|
return int(got)
|
|
case json.Number:
|
|
parsed, _ := got.Int64()
|
|
return int(parsed)
|
|
case string:
|
|
parsed, _ := strconv.Atoi(got)
|
|
return parsed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func imMessageType(msg *types.Message) string {
|
|
if msg == nil {
|
|
return "text"
|
|
}
|
|
for _, key := range []string{"type", "x-im-type"} {
|
|
if value := strings.TrimSpace(fmt.Sprint(msg.Head[key])); value != "" && value != "<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"
|
|
}
|
|
if mtype := draftyIMMessageType(content); mtype != "" {
|
|
return mtype
|
|
}
|
|
}
|
|
return "text"
|
|
}
|
|
|
|
// draftyIMMessageType derives the semantic message type from Drafty content
|
|
// ({"txt","fmt","ent"}), where "ent" holds entity definitions {"tp","data"}.
|
|
// Attachment entities win over inline links; EX is refined by its mime type.
|
|
// Returns "" when no recognized entity is present.
|
|
func draftyIMMessageType(content map[string]any) string {
|
|
ents, ok := content["ent"].([]any)
|
|
if !ok || len(ents) == 0 {
|
|
return ""
|
|
}
|
|
link := ""
|
|
file := ""
|
|
for _, e := range ents {
|
|
ent, ok := e.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
data, _ := ent["data"].(map[string]any)
|
|
switch stringFromAny(ent["tp"]) {
|
|
case "IM":
|
|
return "image"
|
|
case "VD":
|
|
return "video"
|
|
case "AU":
|
|
return "voice"
|
|
case "EX":
|
|
if file == "" {
|
|
file = draftyAttachmentType(data)
|
|
}
|
|
case "LN":
|
|
if link == "" {
|
|
link = "link"
|
|
}
|
|
}
|
|
}
|
|
if file != "" {
|
|
return file
|
|
}
|
|
return link
|
|
}
|
|
|
|
func draftyAttachmentType(data map[string]any) string {
|
|
mime := ""
|
|
if data != nil {
|
|
mime = stringFromAny(data["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"
|
|
}
|
|
}
|
|
|
|
func favoriteSnapshot(msg *types.Message) map[string]any {
|
|
if msg == nil {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{
|
|
"topic": msg.Topic,
|
|
"seq": msg.SeqId,
|
|
"from": messageFromUserID(msg.From),
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"type": imMessageType(msg),
|
|
"version": msg.Version,
|
|
"head": msg.Head,
|
|
"content": msg.Content,
|
|
"text": messageText(*msg),
|
|
}
|
|
}
|
|
|
|
func favoriteAttachmentRefs(msg *types.Message) any {
|
|
if msg == nil {
|
|
return []string{}
|
|
}
|
|
if content, ok := msg.Content.(map[string]any); ok {
|
|
if fileID := stringFromAny(content["file_id"]); fileID != "" {
|
|
return []string{fileID}
|
|
}
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string]any {
|
|
payload := map[string]any{
|
|
"favorite_id": item.FavoriteID,
|
|
"source": map[string]any{
|
|
"topic": item.SourceTopic,
|
|
"seq": item.SourceSeq,
|
|
},
|
|
"snapshot": item.Snapshot,
|
|
"forwarded": true,
|
|
}
|
|
if refs, ok := item.AttachmentRefs.([]string); ok && len(refs) > 0 {
|
|
payload["attachment_refs"] = refs
|
|
} else if item.AttachmentRefs != nil {
|
|
payload["attachment_refs"] = item.AttachmentRefs
|
|
}
|
|
if comment != "" {
|
|
payload["comment"] = comment
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func favoriteForwardMessageHeadContent(item *types.FavoriteItem, clientMid string) (types.KVMap, any) {
|
|
itemType := "text"
|
|
if item != nil && strings.TrimSpace(item.ItemType) != "" {
|
|
itemType = strings.TrimSpace(item.ItemType)
|
|
}
|
|
head := types.KVMap{"type": itemType, "x-im-type": itemType, "x-im-forwarded": true}
|
|
if item != nil {
|
|
if snapshot, ok := item.Snapshot.(map[string]any); ok {
|
|
if rawHead, ok := snapshot["head"].(map[string]any); ok && len(rawHead) > 0 {
|
|
head = types.KVMap{}
|
|
for key, value := range rawHead {
|
|
head[key] = value
|
|
}
|
|
}
|
|
head["x-im-forwarded"] = true
|
|
if _, ok := head["x-im-type"]; !ok {
|
|
head["x-im-type"] = itemType
|
|
}
|
|
if _, ok := head["type"]; !ok {
|
|
head["type"] = itemType
|
|
}
|
|
head["x-im-client-mid"] = clientMid
|
|
if content, ok := snapshot["content"]; ok {
|
|
return head, content
|
|
}
|
|
}
|
|
}
|
|
return forwardBundleMessageHead(clientMid), favoriteForwardContent(item, "")
|
|
}
|
|
|
|
func forwardFailurePayload(topic string, seq int64, target, reason string) map[string]any {
|
|
return map[string]any{
|
|
"source": map[string]any{"topic": strings.TrimSpace(topic), "seq": seq},
|
|
"target_topic": strings.TrimSpace(target),
|
|
"status": "failed",
|
|
"reason": reason,
|
|
}
|
|
}
|
|
|
|
func forwardResultUnknownPayload(target string, seq int64, detailReason string) map[string]any {
|
|
return map[string]any{
|
|
"target_topic": strings.TrimSpace(target),
|
|
"seq": seq,
|
|
"status": "uncertain",
|
|
"reason": "MESSAGE_PERSIST_RESULT_UNKNOWN",
|
|
"detail_reason": detailReason,
|
|
"safe_to_retry": false,
|
|
}
|
|
}
|
|
|
|
func saveForwardCommentMessage(ctx context.Context, principal *imPrincipal, target, comment, clientMid string) map[string]any {
|
|
comment = strings.TrimSpace(comment)
|
|
if principal == nil || comment == "" {
|
|
return nil
|
|
}
|
|
msg := &types.Message{
|
|
TenantID: principal.TenantID,
|
|
Topic: target,
|
|
From: principal.Uid.String(),
|
|
Head: types.KVMap{
|
|
"type": "text",
|
|
"x-im-type": "text",
|
|
"x-im-forwarded": true,
|
|
"x-im-client-mid": clientMid,
|
|
},
|
|
Content: map[string]any{"text": comment},
|
|
}
|
|
result := map[string]any{"comment_client_mid": clientMid}
|
|
if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil {
|
|
result["comment_status"] = "failed"
|
|
result["comment_reason"] = relationErrorReason(err)
|
|
return result
|
|
}
|
|
saved, err := saveMessageMaybeKafka(ctx, principal.TenantID, msg, nil, true)
|
|
if err != nil {
|
|
if saved != nil && saved.Pending {
|
|
result["comment_status"] = "pending"
|
|
result["comment_seq"] = saved.SeqId
|
|
result["comment_reason"] = "MESSAGE_PERSIST_PENDING"
|
|
return result
|
|
}
|
|
result["comment_status"] = "failed"
|
|
result["comment_reason"] = relationErrorReason(err)
|
|
return result
|
|
}
|
|
if saved != nil && saved.Pending {
|
|
result["comment_status"] = "pending"
|
|
result["comment_seq"] = saved.SeqId
|
|
result["comment_reason"] = "MESSAGE_PERSIST_PENDING"
|
|
return result
|
|
}
|
|
if saved != nil {
|
|
msg.SeqId = saved.SeqId
|
|
broadcastHTTPForwardMessage(principal.TenantID, msg)
|
|
result["comment_status"] = "sent"
|
|
result["comment_seq"] = saved.SeqId
|
|
return result
|
|
}
|
|
result["comment_status"] = "failed"
|
|
result["comment_reason"] = imReasonInternal
|
|
return result
|
|
}
|
|
|
|
func mergeForwardCommentResult(item, comment map[string]any) {
|
|
if item == nil || comment == nil {
|
|
return
|
|
}
|
|
for key, value := range comment {
|
|
item[key] = value
|
|
}
|
|
}
|
|
|
|
func forwardClientMid(requestKey string, parts ...any) string {
|
|
requestKey = strings.TrimSpace(requestKey)
|
|
if requestKey == "" {
|
|
return "fwd_" + store.Store.GetUidString()
|
|
}
|
|
args := make([]any, 0, len(parts)+2)
|
|
args = append(args, "forward", requestKey)
|
|
args = append(args, parts...)
|
|
return "fwd_" + stableIMEventID(args...)
|
|
}
|
|
|
|
func normalizeIMForwardTopic(viewer types.Uid, topic string) (string, bool) {
|
|
return normalizeIMUserVisibleTopic(viewer, topic)
|
|
}
|
|
|
|
func normalizeIMFavoriteSourceTopic(viewer types.Uid, topic string) (string, bool) {
|
|
return normalizeIMUserVisibleTopic(viewer, topic)
|
|
}
|
|
|
|
func favoriteItemIdempotencyKey(requestKey, topic string, seq int64) string {
|
|
requestKey = strings.TrimSpace(requestKey)
|
|
if requestKey == "" {
|
|
return ""
|
|
}
|
|
return stableIMEventID("favorite", requestKey, strings.TrimSpace(topic), seq)
|
|
}
|
|
|
|
func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) {
|
|
topic = strings.TrimSpace(topic)
|
|
if topic == "" {
|
|
return "", false
|
|
}
|
|
if topic == "slf" {
|
|
return viewer.SlfName(), !viewer.IsZero()
|
|
}
|
|
if len(topic) < 3 {
|
|
return "", false
|
|
}
|
|
switch topic[:3] {
|
|
case "usr":
|
|
peer := types.ParseUserId(topic)
|
|
if peer.IsZero() {
|
|
return "", false
|
|
}
|
|
p2p := viewer.P2PName(peer)
|
|
return p2p, p2p != ""
|
|
case "p2p", "grp", "slf":
|
|
return topic, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func buildForwardBundle(principal *imPrincipal, sources []struct {
|
|
Topic string `json:"topic"`
|
|
Seq imForwardSeq `json:"seq"`
|
|
}, target string, sourceReadable map[string]error, comment string) (map[string]any, []map[string]any) {
|
|
failures := make([]map[string]any, 0)
|
|
sourceMessages := make([]*types.Message, 0, len(sources))
|
|
sourceTopics := make([]string, 0, len(sources))
|
|
sourceSeqs := make([]int64, 0, len(sources))
|
|
for _, source := range sources {
|
|
sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic)
|
|
if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK {
|
|
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, imReasonMalformedRequest))
|
|
continue
|
|
}
|
|
readErr, ok := sourceReadable[sourceTopic]
|
|
if !ok {
|
|
readErr = requireTopicReader(principal, sourceTopic)
|
|
sourceReadable[sourceTopic] = readErr
|
|
}
|
|
if readErr != nil {
|
|
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(readErr)))
|
|
continue
|
|
}
|
|
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, int64(source.Seq))
|
|
if err != nil {
|
|
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
if msg == nil {
|
|
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "NOT_FOUND"))
|
|
continue
|
|
}
|
|
if messageAvailability(*msg) != "available" {
|
|
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "MESSAGE_UNAVAILABLE"))
|
|
continue
|
|
}
|
|
sourceMessages = append(sourceMessages, msg)
|
|
sourceTopics = append(sourceTopics, sourceTopic)
|
|
sourceSeqs = append(sourceSeqs, int64(source.Seq))
|
|
}
|
|
if len(failures) > 0 {
|
|
return nil, failures
|
|
}
|
|
userSummaries := forwardMessageUserSummaries(principal.TenantID, sourceMessages, forwardBundleTitleUIDs(sourceTopics)...)
|
|
items := make([]map[string]any, 0, len(sourceMessages))
|
|
preview := make([]map[string]any, 0, min(len(sourceMessages), 3))
|
|
sourceRefs := make([]map[string]any, 0, len(sourceMessages))
|
|
for i, msg := range sourceMessages {
|
|
snapshot := favoriteSnapshot(msg)
|
|
if fromUser := forwardMessageUserSummary(msg.From, userSummaries); fromUser != nil {
|
|
snapshot["from_user"] = fromUser
|
|
}
|
|
items = append(items, snapshot)
|
|
ref := map[string]any{"topic": sourceTopics[i], "seq": sourceSeqs[i]}
|
|
sourceRefs = append(sourceRefs, ref)
|
|
if len(preview) < 3 {
|
|
previewItem := map[string]any{
|
|
"from": msg.From,
|
|
"type": imMessageType(msg),
|
|
"summary": textSnippet(messageText(*msg), ""),
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if fromUser := forwardMessageUserSummary(msg.From, userSummaries); fromUser != nil {
|
|
previewItem["from_user"] = fromUser
|
|
}
|
|
preview = append(preview, previewItem)
|
|
}
|
|
}
|
|
bundleID := "fbundle_" + store.Store.GetUidString()
|
|
return map[string]any{
|
|
"bundle_id": bundleID,
|
|
"title": forwardBundleTitle(principal.TenantID, sourceTopics, userSummaries),
|
|
"item_count": len(items),
|
|
"preview": preview,
|
|
"items": items,
|
|
"source_refs": sourceRefs,
|
|
"comment": strings.TrimSpace(comment),
|
|
"forwarded": true,
|
|
}, nil
|
|
}
|
|
|
|
func forwardBundleTitleUIDs(sourceTopics []string) []types.Uid {
|
|
seen := make(map[types.Uid]bool)
|
|
uids := make([]types.Uid, 0, 2)
|
|
for _, topic := range sourceTopics {
|
|
if types.GetTopicCat(topic) != types.TopicCatP2P {
|
|
continue
|
|
}
|
|
uid1, uid2, err := types.ParseP2P(topic)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, uid := range []types.Uid{uid1, uid2} {
|
|
if uid.IsZero() || seen[uid] {
|
|
continue
|
|
}
|
|
seen[uid] = true
|
|
uids = append(uids, uid)
|
|
}
|
|
}
|
|
return uids
|
|
}
|
|
|
|
func forwardBundleTitle(tenantID types.TenantID, sourceTopics []string, userSummaries map[types.Uid]map[string]any) string {
|
|
if len(sourceTopics) == 0 {
|
|
return "聊天记录"
|
|
}
|
|
topic := sourceTopics[0]
|
|
switch types.GetTopicCat(topic) {
|
|
case types.TopicCatP2P:
|
|
uid1, uid2, err := types.ParseP2P(topic)
|
|
if err != nil {
|
|
return "聊天记录"
|
|
}
|
|
return forwardBundleDisplayName(uid1, userSummaries) + "和" + forwardBundleDisplayName(uid2, userSummaries) + "的聊天记录"
|
|
case types.TopicCatGrp:
|
|
groupName := topic
|
|
if store.Topics != nil {
|
|
if group, err := store.Topics.Get(tenantID, topic); err == nil && group != nil {
|
|
groupName = imDisplayNameFromPublic(group.Public, topic)
|
|
} else if err != nil {
|
|
logs.Warn.Printf("im message forward: failed to load group title topic=%s: %v", topic, err)
|
|
}
|
|
}
|
|
if strings.HasSuffix(groupName, "群") {
|
|
return groupName + "的聊天记录"
|
|
}
|
|
return groupName + "群的聊天记录"
|
|
default:
|
|
return "聊天记录"
|
|
}
|
|
}
|
|
|
|
func forwardBundleDisplayName(uid types.Uid, summaries map[types.Uid]map[string]any) string {
|
|
if summary := forwardMessageUserSummary(uid.String(), summaries); summary != nil {
|
|
if name := strings.TrimSpace(fmt.Sprint(summary["display_name"])); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return uid.UserId()
|
|
}
|
|
|
|
func forwardMessageUserSummaries(tenantID types.TenantID, messages []*types.Message, extraUIDs ...types.Uid) map[types.Uid]map[string]any {
|
|
if store.Users == nil {
|
|
return nil
|
|
}
|
|
seen := make(map[types.Uid]bool)
|
|
uids := make([]types.Uid, 0, len(messages)+len(extraUIDs))
|
|
for _, uid := range extraUIDs {
|
|
if uid.IsZero() || seen[uid] {
|
|
continue
|
|
}
|
|
seen[uid] = true
|
|
uids = append(uids, uid)
|
|
}
|
|
for _, msg := range messages {
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
uid := types.ParseUid(msg.From)
|
|
if uid.IsZero() || seen[uid] {
|
|
continue
|
|
}
|
|
seen[uid] = true
|
|
uids = append(uids, uid)
|
|
}
|
|
if len(uids) == 0 {
|
|
return nil
|
|
}
|
|
users, err := store.Users.GetAll(tenantID, uids...)
|
|
if err != nil {
|
|
logs.Warn.Printf("im http: failed to load forward user summaries: %v", err)
|
|
return nil
|
|
}
|
|
summaries := make(map[types.Uid]map[string]any, len(users))
|
|
for i := range users {
|
|
uid := users[i].Uid()
|
|
if uid.IsZero() {
|
|
uid = types.ParseUid(users[i].Id)
|
|
}
|
|
if !uid.IsZero() {
|
|
summaries[uid] = userSummaryPayload(&users[i], "")
|
|
}
|
|
}
|
|
return summaries
|
|
}
|
|
|
|
func forwardMessageUserSummary(from string, summaries map[types.Uid]map[string]any) map[string]any {
|
|
uid := types.ParseUid(from)
|
|
if uid.IsZero() {
|
|
return nil
|
|
}
|
|
if summary, ok := summaries[uid]; ok && summary != nil {
|
|
return summary
|
|
}
|
|
return map[string]any{
|
|
"user_id": uid.UserId(),
|
|
"display_name": uid.UserId(),
|
|
}
|
|
}
|
|
|
|
func prepareHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) error {
|
|
if msg == nil || msg.TenantID != tenantID || strings.TrimSpace(msg.Topic) == "" {
|
|
return types.ErrMalformed
|
|
}
|
|
if msg.SeqId > 0 {
|
|
return nil
|
|
}
|
|
if !kafkaEnabled() && globals.hub == nil {
|
|
return nil
|
|
}
|
|
if store.Topics == nil {
|
|
return types.ErrMalformed
|
|
}
|
|
topicRecord, err := store.Topics.Get(tenantID, msg.Topic)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if topicRecord == nil {
|
|
return types.ErrNotFound
|
|
}
|
|
msg.SeqId = topicRecord.SeqId + 1
|
|
if msg.CreatedAt.IsZero() {
|
|
msg.ObjHeader.CreatedAt = time.Now().UTC().Round(time.Millisecond)
|
|
}
|
|
if msg.UpdatedAt.IsZero() {
|
|
msg.ObjHeader.UpdatedAt = msg.CreatedAt
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func broadcastHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) {
|
|
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
|
|
return
|
|
}
|
|
timestamp := msg.CreatedAt
|
|
if timestamp.IsZero() {
|
|
timestamp = time.Now().UTC().Round(time.Millisecond)
|
|
}
|
|
select {
|
|
case globals.hub.routeSrv <- &ServerComMessage{
|
|
TenantID: tenantID,
|
|
RcptTo: msg.Topic,
|
|
AsUser: types.ParseUid(msg.From).UserId(),
|
|
Timestamp: timestamp,
|
|
Data: &MsgServerData{
|
|
Topic: msg.Topic,
|
|
From: types.ParseUid(msg.From).UserId(),
|
|
Timestamp: timestamp,
|
|
SeqId: msg.SeqId,
|
|
Head: msg.Head,
|
|
Content: msg.Content,
|
|
},
|
|
}:
|
|
default:
|
|
logs.Warn.Printf("im message forward: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId)
|
|
}
|
|
broadcastHTTPForwardPresence(tenantID, msg, timestamp)
|
|
}
|
|
|
|
func broadcastHTTPForwardPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
|
|
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
|
|
return
|
|
}
|
|
uid1, uid2, err := types.ParseP2P(msg.Topic)
|
|
if err != nil {
|
|
return
|
|
}
|
|
from := types.ParseUid(msg.From).UserId()
|
|
for _, rcpt := range []struct {
|
|
uid types.Uid
|
|
src string
|
|
}{
|
|
{uid: uid1, src: uid2.UserId()},
|
|
{uid: uid2, src: uid1.UserId()},
|
|
} {
|
|
actor := from
|
|
if actor == rcpt.uid.UserId() {
|
|
actor = ""
|
|
}
|
|
select {
|
|
case globals.hub.routeSrv <- &ServerComMessage{
|
|
TenantID: tenantID,
|
|
RcptTo: rcpt.uid.UserId(),
|
|
Timestamp: timestamp,
|
|
Pres: &MsgServerPres{
|
|
Topic: "me",
|
|
What: "msg",
|
|
Src: rcpt.src,
|
|
SeqId: msg.SeqId,
|
|
AcsActor: actor,
|
|
SkipTopic: msg.Topic,
|
|
},
|
|
}:
|
|
default:
|
|
logs.Warn.Printf("im message forward: hub presence queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, rcpt.uid.UserId())
|
|
}
|
|
}
|
|
}
|
|
|
|
func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap {
|
|
head := types.KVMap{}
|
|
if msg != nil {
|
|
for key, value := range msg.Head {
|
|
head[key] = value
|
|
}
|
|
msgType := imMessageType(msg)
|
|
if strings.TrimSpace(fmt.Sprint(head["type"])) == "" || fmt.Sprint(head["type"]) == "<nil>" {
|
|
head["type"] = msgType
|
|
}
|
|
if strings.TrimSpace(fmt.Sprint(head["x-im-type"])) == "" || fmt.Sprint(head["x-im-type"]) == "<nil>" {
|
|
head["x-im-type"] = msgType
|
|
}
|
|
head["x-im-forward-source"] = map[string]any{"topic": msg.Topic, "seq": msg.SeqId, "from": msg.From}
|
|
}
|
|
head["x-im-forwarded"] = true
|
|
head["x-im-client-mid"] = clientMid
|
|
return head
|
|
}
|
|
|
|
func forwardBundleMessageHead(clientMid string) types.KVMap {
|
|
return types.KVMap{
|
|
"type": "forward_bundle",
|
|
"x-im-type": "forward_bundle",
|
|
"x-im-forwarded": true,
|
|
"x-im-client-mid": clientMid,
|
|
}
|
|
}
|
|
|
|
func forwardedMessageContent(msg *types.Message, comment string) any {
|
|
if strings.TrimSpace(comment) == "" {
|
|
if msg == nil {
|
|
return nil
|
|
}
|
|
return msg.Content
|
|
}
|
|
return map[string]any{
|
|
"forwarded": true,
|
|
"comment": strings.TrimSpace(comment),
|
|
"snapshot": favoriteSnapshot(msg),
|
|
}
|
|
}
|
|
|
|
func classifyIMURLRisk(rawURL string) (decision, riskLevel, reason string) {
|
|
if rawURL == "" || len(rawURL) > 2048 {
|
|
return "block", "high", "invalid_url"
|
|
}
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return "block", "high", "invalid_url"
|
|
}
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return "block", "high", "unsupported_scheme"
|
|
}
|
|
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
|
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
|
|
return "block", "high", "private_host"
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
|
return "block", "high", "private_host"
|
|
}
|
|
}
|
|
normalized := strings.ToLower(rawURL)
|
|
for _, marker := range []string{"javascript:", "data:", "file:", "password=", "passwd=", "token=", "access_token=", "xn--"} {
|
|
if strings.Contains(normalized, marker) {
|
|
return "warn", "medium", "suspicious_url"
|
|
}
|
|
}
|
|
return "allow", "low", "ok"
|
|
}
|
|
|
|
func shortTokenHash(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])[:16]
|
|
}
|
|
|
|
func messageText(msg types.Message) string {
|
|
switch content := msg.Content.(type) {
|
|
case string:
|
|
return content
|
|
case map[string]any:
|
|
for _, key := range []string{"text", "txt", "content", "title", "summary", "name", "url"} {
|
|
if value := stringFromAny(content[key]); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
encoded, _ := json.Marshal(msg.Content)
|
|
return string(encoded)
|
|
}
|
|
|
|
func messageAvailability(msg types.Message) string {
|
|
if msg.DeletedAt != nil || msg.RecalledAt != nil {
|
|
return "removed"
|
|
}
|
|
return "available"
|
|
}
|
|
|
|
func textSnippet(text, query string) string {
|
|
text = strings.TrimSpace(text)
|
|
runes := []rune(text)
|
|
if len(runes) <= 120 || strings.TrimSpace(query) == "" {
|
|
return text
|
|
}
|
|
idx := runeIndexFold(text, query)
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
start := idx - 40
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := start + 120
|
|
if end > len(runes) {
|
|
end = len(runes)
|
|
}
|
|
return string(runes[start:end])
|
|
}
|
|
|
|
func textMatches(text, query string) []map[string]any {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return []map[string]any{}
|
|
}
|
|
idx := runeIndexFold(text, query)
|
|
if idx < 0 {
|
|
return []map[string]any{}
|
|
}
|
|
return []map[string]any{{"start": idx, "end": idx + len([]rune(query))}}
|
|
}
|
|
|
|
func runeIndexFold(text, query string) int {
|
|
query = strings.TrimSpace(query)
|
|
if text == "" || query == "" {
|
|
return -1
|
|
}
|
|
textRunes := []rune(strings.ToLower(text))
|
|
queryRunes := []rune(strings.ToLower(query))
|
|
if len(queryRunes) == 0 || len(queryRunes) > len(textRunes) {
|
|
return -1
|
|
}
|
|
for i := 0; i <= len(textRunes)-len(queryRunes); i++ {
|
|
matched := true
|
|
for j := range queryRunes {
|
|
if textRunes[i+j] != queryRunes[j] {
|
|
matched = false
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func validIMRealName(name, idType, idNumber string) bool {
|
|
if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" {
|
|
return false
|
|
}
|
|
return regexp.MustCompile(`^[0-9]{17}[0-9X]$`).MatchString(idNumber)
|
|
}
|
|
|
|
func protectIMSensitive(tenantID types.TenantID, value string) string {
|
|
return base64.RawStdEncoding.EncodeToString([]byte(hashIMSecret(tenantID, value, "sensitive")))
|
|
}
|
|
|
|
func maskIMName(name string) string {
|
|
runes := []rune(name)
|
|
if len(runes) <= 1 {
|
|
return name
|
|
}
|
|
return string(runes[0]) + "*"
|
|
}
|
|
|
|
func maskIMIDNumber(id string) string {
|
|
if len(id) <= 8 {
|
|
return id
|
|
}
|
|
return id[:3] + strings.Repeat("*", len(id)-7) + id[len(id)-4:]
|
|
}
|
|
|
|
func validIMIndependentPassword(password string) bool {
|
|
if !regexp.MustCompile(`^[0-9]{4}$`).MatchString(password) {
|
|
return false
|
|
}
|
|
if password[0] == password[1] && password[1] == password[2] && password[2] == password[3] {
|
|
return false
|
|
}
|
|
ascending, descending := true, true
|
|
for i := 1; i < len(password); i++ {
|
|
ascending = ascending && password[i] == password[i-1]+1
|
|
descending = descending && password[i]+1 == password[i-1]
|
|
}
|
|
return !ascending && !descending
|
|
}
|
|
|
|
func decodeIMJSONBody(wrt http.ResponseWriter, req *http.Request, dst any) bool {
|
|
if req.Body == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
}
|
|
dec := json.NewDecoder(req.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(dst); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
} else if err == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCode string) (*types.Tenant, bool) {
|
|
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
|
if tenantCode == "" {
|
|
tenantCode = strings.TrimSpace(fallbackCode)
|
|
}
|
|
if tenantCode == "" || len(tenantCode) > 64 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
|
return nil, false
|
|
}
|
|
if store.Tenants == nil {
|
|
logs.Err.Println("im http: tenant store is not initialized")
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, false
|
|
}
|
|
tenant, err := store.Tenants.GetByCode(tenantCode)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return nil, false
|
|
}
|
|
logs.Warn.Println("im http: failed to resolve tenant", tenantCode, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, false
|
|
}
|
|
if !tenant.IsActive() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return nil, false
|
|
}
|
|
return tenant, true
|
|
}
|
|
|
|
func resolveIMHTTPRegistrationTenant(wrt http.ResponseWriter, req *http.Request, body imVerificationCreateRequest) (*types.Tenant, bool) {
|
|
if body.RegistrationType == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
|
|
return nil, false
|
|
}
|
|
|
|
headerCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
|
bodyCode := strings.TrimSpace(body.TenantCode)
|
|
var tenantCode string
|
|
|
|
switch *body.RegistrationType {
|
|
case registrationTypePersonal:
|
|
if bodyCode != "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
|
return nil, false
|
|
}
|
|
if headerCode != "" && !strings.EqualFold(headerCode, imDefaultTenantCode) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
|
return nil, false
|
|
}
|
|
tenantCode = imDefaultTenantCode
|
|
case registrationTypeEnterprise:
|
|
if bodyCode == "" {
|
|
bodyCode = headerCode
|
|
} else if headerCode != "" && !strings.EqualFold(headerCode, bodyCode) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
|
return nil, false
|
|
}
|
|
if bodyCode == "" || len(bodyCode) > 64 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
|
return nil, false
|
|
}
|
|
if strings.EqualFold(bodyCode, imDefaultTenantCode) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return nil, false
|
|
}
|
|
tenantCode = bodyCode
|
|
default:
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
|
|
return nil, false
|
|
}
|
|
|
|
return resolveIMHTTPTenant(wrt, req, tenantCode)
|
|
}
|
|
|
|
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 maskIMProfilePhone(phone string) string {
|
|
phone = strings.TrimSpace(phone)
|
|
if strings.HasPrefix(phone, "+86") {
|
|
phone = strings.TrimPrefix(phone, "+86")
|
|
}
|
|
if phone == "" {
|
|
return ""
|
|
}
|
|
if len(phone) <= 7 {
|
|
return phone
|
|
}
|
|
return phone[:3] + "****" + phone[len(phone)-4:]
|
|
}
|
|
|
|
func randomIMNumericCode(length int) (string, error) {
|
|
max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
|
|
n, err := rand.Int(rand.Reader, max)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
code := fmt.Sprintf("%0*d", length, n.Int64())
|
|
return code, nil
|
|
}
|
|
|
|
func randomIMToken(size int) (string, error) {
|
|
buf := make([]byte, size)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
func hashIMSecret(tenantID types.TenantID, parts ...string) string {
|
|
h := sha256.New()
|
|
h.Write(globals.apiKeySalt)
|
|
h.Write([]byte(fmt.Sprintf(":%d:", tenantID)))
|
|
for _, part := range parts {
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(part))
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
func generateIMQRCodeToken(tenantID types.TenantID, uid types.Uid, purpose string, expiresAt time.Time) (string, error) {
|
|
return generateIMQRCodeTokenForTopic(tenantID, uid, "", purpose, expiresAt)
|
|
}
|
|
|
|
func generateIMQRCodeTokenForTopic(tenantID types.TenantID, uid types.Uid, topic, purpose string, expiresAt time.Time) (string, error) {
|
|
nonce, err := randomIMToken(12)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
claims := imQRCodeClaims{
|
|
Version: 1,
|
|
TenantID: tenantID,
|
|
UserID: uid.UserId(),
|
|
Topic: topic,
|
|
Purpose: purpose,
|
|
Expires: expiresAt.Unix(),
|
|
Nonce: nonce,
|
|
}
|
|
payload, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
|
mac := signIMQRCodePayload(encodedPayload)
|
|
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac), nil
|
|
}
|
|
|
|
func parseIMQRCodeToken(token, expectedPurpose string, now time.Time) (*imQRCodeClaims, error) {
|
|
payload, signature, ok := strings.Cut(token, ".")
|
|
if !ok || payload == "" || signature == "" {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
got, err := base64.RawURLEncoding.DecodeString(signature)
|
|
if err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
want := signIMQRCodePayload(payload)
|
|
if !hmac.Equal(got, want) {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
var claims imQRCodeClaims
|
|
if err = json.Unmarshal(raw, &claims); err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
if claims.Version != 1 || claims.TenantID == 0 || claims.UserID == "" || claims.Purpose == "" || claims.Expires <= now.Unix() {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
if expectedPurpose != "" && claims.Purpose != expectedPurpose {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
return &claims, nil
|
|
}
|
|
|
|
func signIMQRCodePayload(payload string) []byte {
|
|
mac := hmac.New(sha256.New, globals.apiKeySalt)
|
|
mac.Write([]byte("im:qrcode:v1"))
|
|
mac.Write([]byte{0})
|
|
mac.Write([]byte(payload))
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
func verificationChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/auth/verification-codes/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func passwordResetChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/auth/password-reset/challenges/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func securityChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/security/challenges/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func riskChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/security/risk-challenges/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "confirm" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func cleanStringList(values []string, limit int) []string {
|
|
if limit <= 0 {
|
|
limit = len(values)
|
|
}
|
|
capacity := len(values)
|
|
if capacity > limit {
|
|
capacity = limit
|
|
}
|
|
cleaned := make([]string, 0, capacity)
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
cleaned = append(cleaned, value)
|
|
if len(cleaned) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func isAllowedPublicUploadPurpose(purpose string) bool {
|
|
switch strings.TrimSpace(purpose) {
|
|
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
|
|
types.PublicUploadPurposeUserAvatar,
|
|
types.PublicUploadPurposeGroupAvatar:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isAllowedPublicUploadMime(purpose, mimeType string) bool {
|
|
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
|
|
switch purpose {
|
|
case types.PublicUploadPurposeEnterpriseLicense:
|
|
return strings.HasPrefix(mimeType, "image/jpeg") ||
|
|
strings.HasPrefix(mimeType, "image/png") ||
|
|
strings.HasPrefix(mimeType, "image/webp") ||
|
|
mimeType == "application/pdf"
|
|
case types.PublicUploadPurposeTenantFeedback:
|
|
return strings.HasPrefix(mimeType, "image/jpeg") ||
|
|
strings.HasPrefix(mimeType, "image/png") ||
|
|
strings.HasPrefix(mimeType, "image/webp")
|
|
case types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar:
|
|
return strings.HasPrefix(mimeType, "image/jpeg") ||
|
|
strings.HasPrefix(mimeType, "image/png") ||
|
|
strings.HasPrefix(mimeType, "image/webp")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func detectIMUploadMime(file interface {
|
|
io.Reader
|
|
io.Seeker
|
|
}, contentType string) (string, error) {
|
|
buff := make([]byte, 512)
|
|
n, err := file.Read(buff)
|
|
if err != nil && err != io.EOF {
|
|
return "", err
|
|
}
|
|
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
|
return "", err
|
|
}
|
|
mimeType := http.DetectContentType(buff[:n])
|
|
if mimeType == "application/octet-stream" {
|
|
if parsed, _, err := mime.ParseMediaType(contentType); err == nil && parsed != "" {
|
|
mimeType = parsed
|
|
}
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(mimeType)), nil
|
|
}
|
|
|
|
func isValidCreditCode(code string) bool {
|
|
if len(code) != 18 {
|
|
return false
|
|
}
|
|
for _, r := range code {
|
|
if !(r >= '0' && r <= '9' || r >= 'A' && r <= 'Z') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isValidIMEmail(email string) bool {
|
|
if len(email) > 255 || strings.ContainsAny(email, "\r\n\t ") {
|
|
return false
|
|
}
|
|
return regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`).MatchString(email)
|
|
}
|
|
|
|
func cleanTenantCodeHint(code string) string {
|
|
code = strings.TrimSpace(code)
|
|
if len(code) > 64 {
|
|
return code[:64]
|
|
}
|
|
return code
|
|
}
|
|
|
|
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) {
|
|
targetType := strings.ToLower(strings.TrimSpace(target.Type))
|
|
snapshot := map[string]any{"type": targetType}
|
|
switch targetType {
|
|
case "user":
|
|
uid := types.ParseUserId(strings.TrimSpace(target.UserID))
|
|
if uid.IsZero() || uid == reporter {
|
|
return "", 0, "", 0, nil, types.ErrMalformed
|
|
}
|
|
if store.Users != nil {
|
|
if _, err := store.Users.Get(tenantID, uid); err != nil {
|
|
return "", 0, "", 0, nil, err
|
|
}
|
|
}
|
|
snapshot["user_id"] = uid.UserId()
|
|
return targetType, uid, "", 0, snapshot, nil
|
|
case "group":
|
|
topic := strings.TrimSpace(target.Topic)
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
return "", 0, "", 0, nil, types.ErrMalformed
|
|
}
|
|
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
|
|
return "", 0, "", 0, nil, err
|
|
}
|
|
snapshot["topic"] = topic
|
|
return targetType, 0, topic, 0, snapshot, nil
|
|
case "message":
|
|
topic := strings.TrimSpace(target.Topic)
|
|
seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64)
|
|
if topic == "" || seq <= 0 || err != nil {
|
|
return "", 0, "", 0, nil, types.ErrMalformed
|
|
}
|
|
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
|
|
return "", 0, "", 0, nil, err
|
|
}
|
|
msg, err := store.Messages.GetOne(tenantID, topic, seq)
|
|
if err != nil {
|
|
return "", 0, "", 0, nil, err
|
|
}
|
|
snapshot["topic"] = topic
|
|
snapshot["seq"] = seq
|
|
snapshot["from"] = messageFromUserID(msg.From)
|
|
snapshot["ts"] = msg.CreatedAt.UTC().Format(time.RFC3339Nano)
|
|
return targetType, 0, topic, seq, snapshot, nil
|
|
default:
|
|
return "", 0, "", 0, nil, types.ErrMalformed
|
|
}
|
|
}
|
|
|
|
func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string) error {
|
|
if store.Subs == nil {
|
|
return types.ErrPermissionDenied
|
|
}
|
|
sub, err := store.Subs.Get(tenantID, topic, uid, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sub == nil || !sub.ModeGiven.IsReader() {
|
|
return types.ErrPermissionDenied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func publishIMConversationSystemNotice(tenantID types.TenantID, topic, msgType, template, text string, actors []imSystemMessageActor, actions []imSystemMessageAction, extra map[string]any) {
|
|
if tenantID == types.ZeroTenantID || strings.TrimSpace(topic) == "" || store.Store == nil || !store.Store.IsOpen() || store.Topics == nil || store.Messages == nil {
|
|
return
|
|
}
|
|
msgType = strings.TrimSpace(msgType)
|
|
if msgType == "" {
|
|
msgType = "system_notice"
|
|
}
|
|
if msgType != "system_notice" && msgType != "system_action" {
|
|
logs.Warn.Printf("im system message: unsupported type %q for topic %s", msgType, topic)
|
|
return
|
|
}
|
|
template = strings.TrimSpace(template)
|
|
text = strings.TrimSpace(text)
|
|
if template == "" || text == "" {
|
|
return
|
|
}
|
|
topicRecord, err := store.Topics.Get(tenantID, topic)
|
|
if err != nil || topicRecord == nil {
|
|
if err != nil {
|
|
logs.Warn.Printf("im system message: failed to load topic %s: %v", topic, err)
|
|
}
|
|
return
|
|
}
|
|
content := map[string]any{
|
|
"text": text,
|
|
"template": template,
|
|
"level": "normal",
|
|
}
|
|
if len(actors) > 0 {
|
|
content["actors"] = actors
|
|
}
|
|
if len(actions) > 0 {
|
|
content["actions"] = actions
|
|
}
|
|
if len(extra) > 0 {
|
|
content["extra"] = extra
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
msg := &types.Message{
|
|
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
|
|
TenantID: tenantID,
|
|
SeqId: topicRecord.SeqId + 1,
|
|
Topic: topic,
|
|
From: types.ZeroUid.String(),
|
|
Head: types.KVMap{
|
|
"x-im-schema": 1,
|
|
"x-im-type": msgType,
|
|
"x-im-template": template,
|
|
"x-im-from": "system",
|
|
},
|
|
Content: content,
|
|
}
|
|
_, err = store.Messages.Save(tenantID, msg, nil, false)
|
|
if err != nil && !errors.Is(err, types.ErrDuplicate) {
|
|
logs.Warn.Printf("im system message: failed to save %s for topic %s: %v", template, topic, err)
|
|
return
|
|
}
|
|
broadcastIMConversationSystemMessage(tenantID, msg, now)
|
|
sendPushForStoredGroupMessage(tenantID, msg)
|
|
}
|
|
|
|
func broadcastIMConversationSystemMessage(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
|
|
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || strings.TrimSpace(msg.Topic) == "" {
|
|
return
|
|
}
|
|
if timestamp.IsZero() {
|
|
timestamp = time.Now().UTC().Round(time.Millisecond)
|
|
}
|
|
out := &ServerComMessage{
|
|
TenantID: tenantID,
|
|
RcptTo: msg.Topic,
|
|
AsUser: types.ParseUid(msg.From).UserId(),
|
|
Timestamp: timestamp,
|
|
PreserveTopicName: true,
|
|
Data: &MsgServerData{
|
|
Topic: msg.Topic,
|
|
From: types.ParseUid(msg.From).UserId(),
|
|
Timestamp: timestamp,
|
|
SeqId: msg.SeqId,
|
|
Head: msg.Head,
|
|
Content: msg.Content,
|
|
},
|
|
}
|
|
select {
|
|
case globals.hub.routeSrv <- out:
|
|
default:
|
|
logs.Warn.Printf("im system message: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId)
|
|
}
|
|
deliverIMConversationSystemMessageToP2PSessions(tenantID, msg, out)
|
|
if !publishRealtimeP2PConversationSystemMessage(tenantID, msg.Topic, out) {
|
|
publishRealtimeTopicMessage(msg.Topic, out)
|
|
}
|
|
broadcastIMConversationSystemPresence(tenantID, msg, timestamp)
|
|
}
|
|
|
|
func publishIMGroupInvitationNotification(tenantID types.TenantID, inv *types.GroupInvitation) {
|
|
if tenantID == types.ZeroTenantID || inv == nil || inv.TargetUserID.IsZero() || store.Security == nil {
|
|
return
|
|
}
|
|
eventID := stableIMEventID("group-invitation", inv.InvitationID, inv.TargetUserID.UserId(), "created")
|
|
action := types.KVMap{
|
|
"type": "group_invitation_accept",
|
|
"invitation_id": inv.InvitationID,
|
|
"topic": inv.Topic,
|
|
"inviter_id": inv.InviterUserID.UserId(),
|
|
}
|
|
if inv.Message != "" {
|
|
action["message"] = inv.Message
|
|
}
|
|
if err := createIMSystemNotification(tenantID, inv.TargetUserID, eventID, "group.invitation.created",
|
|
"群聊邀请", "邀请你加入群聊。", action); err != nil {
|
|
logs.Warn.Printf("group invitation: create notification invitation=%s target=%s: %v", inv.InvitationID, inv.TargetUserID.UserId(), err)
|
|
}
|
|
}
|
|
|
|
func publishIMGroupMemberJoinedSystemNotice(tenantID types.TenantID, topic string, inviter, target types.Uid, text string, extra map[string]any) {
|
|
if target.IsZero() {
|
|
return
|
|
}
|
|
actors := make([]imSystemMessageActor, 0, 2)
|
|
if !inviter.IsZero() && inviter != target {
|
|
actors = append(actors, imSystemMessageActor{
|
|
UserID: inviter.UserId(),
|
|
Click: map[string]any{"type": "user_profile", "target_id": inviter.UserId()},
|
|
})
|
|
}
|
|
actors = append(actors, imSystemMessageActor{
|
|
UserID: target.UserId(),
|
|
Click: map[string]any{"type": "user_profile", "target_id": target.UserId()},
|
|
})
|
|
publishIMConversationSystemNotice(tenantID, topic,
|
|
"system_action", "group_member_joined", text,
|
|
actors,
|
|
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}},
|
|
extra)
|
|
}
|
|
|
|
func deliverIMConversationSystemMessageToP2PSessions(tenantID types.TenantID, msg *types.Message, out *ServerComMessage) {
|
|
if globals.sessionStore == nil || msg == nil || out == nil || out.Data == nil {
|
|
return
|
|
}
|
|
uid1, uid2, err := types.ParseP2P(msg.Topic)
|
|
if err != nil {
|
|
return
|
|
}
|
|
participants := map[types.Uid]struct{}{uid1: {}, uid2: {}}
|
|
globals.sessionStore.Range(func(_ string, session *Session) bool {
|
|
if session == nil || session.tenantID != tenantID {
|
|
return true
|
|
}
|
|
if _, ok := participants[session.uid]; !ok {
|
|
return true
|
|
}
|
|
if _, err := types.P2PNameForUser(session.uid, msg.Topic); err != nil {
|
|
return true
|
|
}
|
|
if session.getSub(msg.Topic) != nil {
|
|
return true
|
|
}
|
|
msgCopy, ok := p2pConversationSystemMessageForUser(out, msg.Topic, session.uid)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if !session.queueOut(msgCopy) {
|
|
logs.Warn.Printf("im system message: session queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, session.uid.UserId())
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func publishRealtimeP2PConversationSystemMessage(tenantID types.TenantID, topic string, out *ServerComMessage) bool {
|
|
uid1, uid2, err := types.ParseP2P(topic)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, uid := range []types.Uid{uid1, uid2} {
|
|
msgCopy, ok := p2pConversationSystemMessageForUser(out, topic, uid)
|
|
if !ok {
|
|
continue
|
|
}
|
|
publishRealtimeUserMessage(uid, msgCopy)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func p2pConversationSystemMessageForUser(out *ServerComMessage, topic string, uid types.Uid) (*ServerComMessage, bool) {
|
|
if out == nil || uid.IsZero() {
|
|
return nil, false
|
|
}
|
|
viewTopic, err := types.P2PNameForUser(uid, topic)
|
|
if err != nil || viewTopic == "" {
|
|
return nil, false
|
|
}
|
|
msgCopy := out.copy()
|
|
msgCopy.RcptTo = uid.UserId()
|
|
if msgCopy.Data != nil {
|
|
msgCopy.Data.Topic = viewTopic
|
|
}
|
|
if msgCopy.Info != nil {
|
|
msgCopy.Info.Topic = viewTopic
|
|
}
|
|
if msgCopy.Pres != nil {
|
|
msgCopy.Pres.Topic = viewTopic
|
|
}
|
|
return msgCopy, true
|
|
}
|
|
|
|
func broadcastIMConversationSystemPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
|
|
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
|
|
return
|
|
}
|
|
uid1, uid2, err := types.ParseP2P(msg.Topic)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, uid := range []types.Uid{uid1, uid2} {
|
|
select {
|
|
case globals.hub.routeSrv <- &ServerComMessage{
|
|
TenantID: tenantID,
|
|
RcptTo: uid.UserId(),
|
|
Timestamp: timestamp,
|
|
PreserveTopicName: true,
|
|
Pres: &MsgServerPres{
|
|
Topic: "me",
|
|
What: "msg",
|
|
Src: msg.Topic,
|
|
SeqId: msg.SeqId,
|
|
SkipTopic: msg.Topic,
|
|
},
|
|
}:
|
|
default:
|
|
logs.Warn.Printf("im system message: hub presence queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, uid.UserId())
|
|
}
|
|
}
|
|
}
|
|
|
|
func systemNotificationIDFromPath(path string) string {
|
|
const marker = "/v1/im/system-notifications/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
eventID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "read" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(eventID)
|
|
}
|
|
|
|
func groupInvitationResourcePath(path string) (string, string, bool) {
|
|
const marker = "/v1/im/group-invitations/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
invitationID, tail, ok := strings.Cut(rest, "/")
|
|
if !ok || strings.TrimSpace(invitationID) == "" {
|
|
return "", "", false
|
|
}
|
|
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func messageReceiptsPath(path string) (string, int64, bool) {
|
|
const marker = "/v1/im/topics/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return "", 0, false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
topic, rest, ok := strings.Cut(rest, "/messages/")
|
|
if !ok || topic == "" {
|
|
return "", 0, false
|
|
}
|
|
seqValue, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "receipts" {
|
|
return "", 0, false
|
|
}
|
|
seq, err := strconv.ParseInt(seqValue, 10, 64)
|
|
if err != nil {
|
|
return topic, 0, true
|
|
}
|
|
return topic, seq, true
|
|
}
|
|
|
|
func topicFromMessagesSearchPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/messages/search"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromMessageMutationsPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/message-mutations"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromMediaPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/media"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromCallsPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/calls"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromDraftPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/draft"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromPathWithSuffix(path, prefix, suffix string) string {
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 || !strings.HasSuffix(path, suffix) {
|
|
return ""
|
|
}
|
|
rest := strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
|
|
return strings.Trim(rest, "/")
|
|
}
|
|
|
|
func parseIMMutationSince(primary, fallback string) (int64, error) {
|
|
value := strings.TrimSpace(primary)
|
|
if value == "" {
|
|
value = strings.TrimSpace(fallback)
|
|
}
|
|
if value == "" {
|
|
return 0, nil
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil || parsed < 0 {
|
|
return 0, strconv.ErrSyntax
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func messageContextPath(path string) (string, int64, bool) {
|
|
const prefix = "/v1/im/topics/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", 0, false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
|
if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" {
|
|
return "", 0, false
|
|
}
|
|
seq, err := strconv.ParseInt(parts[2], 10, 64)
|
|
if err != nil || seq <= 0 {
|
|
return "", 0, false
|
|
}
|
|
return parts[0], seq, true
|
|
}
|
|
|
|
func messageActionPath(path, action string) (string, int64, bool) {
|
|
const marker = "/v1/im/topics/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return "", 0, false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
topic, rest, ok := strings.Cut(rest, "/messages/")
|
|
if !ok || topic == "" {
|
|
return "", 0, false
|
|
}
|
|
seqValue, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != action {
|
|
return "", 0, false
|
|
}
|
|
seq, err := strconv.ParseInt(seqValue, 10, 64)
|
|
if err != nil {
|
|
return topic, 0, true
|
|
}
|
|
return topic, seq, true
|
|
}
|
|
|
|
func topicMessagesActionPath(path, action string) (string, bool) {
|
|
const marker = "/v1/im/topics/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
topic, suffix, ok := strings.Cut(rest, "/messages/")
|
|
if !ok || topic == "" || suffix != action {
|
|
return "", false
|
|
}
|
|
return topic, true
|
|
}
|
|
|
|
func conversationContextTopicFromPath(path string) string {
|
|
const prefix = "/v1/im/conversations/"
|
|
const suffix = "/context"
|
|
if !strings.Contains(path, prefix) || !strings.HasSuffix(path, suffix) {
|
|
return ""
|
|
}
|
|
idx := strings.Index(path, prefix)
|
|
return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
|
|
}
|
|
|
|
func conversationResourceFromPath(path string) (string, string) {
|
|
const prefix = "/v1/im/conversations/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", ""
|
|
}
|
|
rest := strings.Trim(strings.TrimPrefix(path[idx:], prefix), "/")
|
|
topic, tail, ok := strings.Cut(rest, "/")
|
|
if !ok {
|
|
return topic, ""
|
|
}
|
|
return strings.TrimSpace(topic), strings.Trim(strings.TrimSpace(tail), "/")
|
|
}
|
|
|
|
func groupResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/groups/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
topic, tail, ok := strings.Cut(rest, "/")
|
|
if topic == "" {
|
|
return "", "", false
|
|
}
|
|
if !ok {
|
|
return topic, "", true
|
|
}
|
|
return topic, strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func userResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/users/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
identifier, tail, ok := strings.Cut(rest, "/")
|
|
identifier = strings.TrimSpace(identifier)
|
|
if !ok || identifier == "" || tail == "" {
|
|
return "", "", false
|
|
}
|
|
return identifier, strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func groupMemberProfilePath(tail string) (types.Uid, bool) {
|
|
parts := strings.Split(strings.Trim(tail, "/"), "/")
|
|
if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" {
|
|
return types.ZeroUid, false
|
|
}
|
|
uid := types.ParseUserId(strings.TrimSpace(parts[1]))
|
|
return uid, !uid.IsZero()
|
|
}
|
|
|
|
func groupJoinRequestActionPath(tail string) (string, string, bool) {
|
|
parts := strings.Split(strings.Trim(tail, "/"), "/")
|
|
if len(parts) != 3 || parts[0] != "join-requests" || parts[1] == "" {
|
|
return "", "", false
|
|
}
|
|
switch parts[2] {
|
|
case "accept", "reject":
|
|
return strings.TrimSpace(parts[1]), parts[2], true
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func friendRequestActionFromPath(path string) (string, string) {
|
|
const prefix = "/v1/im/friend-requests/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
requestID, action, ok := strings.Cut(rest, "/")
|
|
if !ok {
|
|
return "", ""
|
|
}
|
|
return strings.TrimSpace(requestID), strings.TrimSpace(action)
|
|
}
|
|
|
|
func userIDFromTail(path, prefix string) types.Uid {
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return types.ZeroUid
|
|
}
|
|
return types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(path[idx:], prefix)))
|
|
}
|
|
|
|
func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
|
|
if types.GetTopicCat(topic) != types.TopicCatP2P || uid.IsZero() {
|
|
return types.ZeroUid
|
|
}
|
|
left, right, err := types.ParseP2P(topic)
|
|
if err != nil {
|
|
return types.ZeroUid
|
|
}
|
|
if left == uid {
|
|
return right
|
|
}
|
|
if right == uid {
|
|
return left
|
|
}
|
|
return types.ZeroUid
|
|
}
|
|
|
|
func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) {
|
|
topic = strings.TrimSpace(topic)
|
|
if len(topic) < 3 || uid.IsZero() {
|
|
return "", types.ZeroUid
|
|
}
|
|
if types.GetTopicCat(topic) == types.TopicCatMe {
|
|
peer := types.ParseUserId(topic)
|
|
if peer.IsZero() || peer == uid {
|
|
return "", types.ZeroUid
|
|
}
|
|
return uid.P2PName(peer), peer
|
|
}
|
|
peer := peerFromP2PTopic(topic, uid)
|
|
if peer.IsZero() {
|
|
return "", types.ZeroUid
|
|
}
|
|
return topic, peer
|
|
}
|
|
|
|
func resolveConversationContextTopic(principal *imPrincipal, topic string) (string, types.Uid, error) {
|
|
normalized, peer := normalizeP2PConversationTopic(topic, principal.Uid)
|
|
if normalized != "" && !peer.IsZero() {
|
|
return normalized, peer, nil
|
|
}
|
|
topic = strings.TrimSpace(topic)
|
|
if types.GetTopicCat(topic) != types.TopicCatP2P {
|
|
return "", types.ZeroUid, nil
|
|
}
|
|
if _, _, err := types.ParseP2P(topic); err != nil {
|
|
return "", types.ZeroUid, nil
|
|
}
|
|
cursor := ""
|
|
for {
|
|
items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid, "", "", cursor, 100)
|
|
if err != nil {
|
|
return "", types.ZeroUid, err
|
|
}
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item.Friendship.Topic) == topic && !item.Friendship.PeerID.IsZero() {
|
|
return topic, item.Friendship.PeerID, nil
|
|
}
|
|
}
|
|
if nextCursor == "" {
|
|
break
|
|
}
|
|
cursor = nextCursor
|
|
}
|
|
return "", types.ZeroUid, nil
|
|
}
|
|
|
|
func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
|
|
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
|
|
return normalized
|
|
}
|
|
return strings.TrimSpace(topic)
|
|
}
|