11055 lines
375 KiB
Go
11055 lines
375 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nyaruka/phonenumbers"
|
|
"github.com/tinode/chat/server/auth"
|
|
"github.com/tinode/chat/server/logs"
|
|
"github.com/tinode/chat/server/media"
|
|
"github.com/tinode/chat/server/store"
|
|
"github.com/tinode/chat/server/store/types"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
imReasonAPIKeyRequired = "API_KEY_REQUIRED"
|
|
imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
|
|
imReasonMalformedRequest = "MALFORMED_REQUEST"
|
|
imReasonTenantUnavailable = "TENANT_UNAVAILABLE"
|
|
imReasonVerificationRate = "VERIFICATION_RATE_LIMITED"
|
|
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
|
|
imReasonCodeInvalid = "CODE_INVALID"
|
|
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
|
|
imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED"
|
|
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
|
|
imReasonAuthRequired = "AUTH_REQUIRED"
|
|
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
|
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
|
imReasonPermissionDenied = "PERMISSION_DENIED"
|
|
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
|
|
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
|
|
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
|
|
imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS"
|
|
imReasonInternal = "INTERNAL_ERROR"
|
|
imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
|
|
imReasonFileUnavailable = "FILE_UNAVAILABLE"
|
|
imReasonRateLimited = "RATE_LIMITED"
|
|
imReasonResourceNotFound = "RESOURCE_NOT_FOUND"
|
|
)
|
|
|
|
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))
|
|
if req.Method == http.MethodGet || req.Method == http.MethodHead {
|
|
if principal, err := resolveIMHTTPBearer(req); err == nil {
|
|
dispatchIMBusinessEvents(principal.TenantID)
|
|
}
|
|
}
|
|
serveIMHTTPIdempotent(wrt, req, next)
|
|
})
|
|
}
|
|
|
|
type imBufferedResponseWriter struct {
|
|
header http.Header
|
|
status int
|
|
wroteHeader bool
|
|
body bytes.Buffer
|
|
}
|
|
|
|
func newIMBufferedResponseWriter() *imBufferedResponseWriter {
|
|
return &imBufferedResponseWriter{header: make(http.Header), status: http.StatusOK}
|
|
}
|
|
|
|
func (w *imBufferedResponseWriter) Header() http.Header { return w.header }
|
|
|
|
func (w *imBufferedResponseWriter) WriteHeader(status int) {
|
|
if w.wroteHeader {
|
|
return
|
|
}
|
|
w.status = status
|
|
w.wroteHeader = true
|
|
}
|
|
|
|
func (w *imBufferedResponseWriter) Write(data []byte) (int, error) {
|
|
if !w.wroteHeader {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
return w.body.Write(data)
|
|
}
|
|
|
|
func (w *imBufferedResponseWriter) flushTo(wrt http.ResponseWriter) {
|
|
for key, values := range w.header {
|
|
for _, value := range values {
|
|
wrt.Header().Add(key, value)
|
|
}
|
|
}
|
|
wrt.WriteHeader(w.status)
|
|
_, _ = wrt.Write(w.body.Bytes())
|
|
}
|
|
|
|
func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http.Handler) {
|
|
if req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodPatch && req.Method != http.MethodDelete {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
if req.Method == http.MethodPost && strings.TrimSpace(req.URL.Path) == "/v1/im/tenants/resolve" {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(strings.TrimSpace(req.Header.Get("Authorization")), "Bearer ") {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
principal, err := resolveIMHTTPBearer(req)
|
|
if err != nil {
|
|
next.ServeHTTP(wrt, req)
|
|
return
|
|
}
|
|
dispatchIMBusinessEvents(principal.TenantID)
|
|
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if key == "" || len(key) > 128 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(req.Body, 4<<20+1))
|
|
if err != nil || len(body) > 4<<20 {
|
|
writeIMError(wrt, req, http.StatusRequestEntityTooLarge, 30000, imReasonMalformedRequest, "request body too large", nil)
|
|
return
|
|
}
|
|
req.Body = io.NopCloser(bytes.NewReader(body))
|
|
hashInput := append([]byte(req.URL.RawQuery+"\x00"), body...)
|
|
bodyHash := sha256.Sum256(hashInput)
|
|
record := &types.HTTPIdempotencyRecord{
|
|
UserID: principal.Uid, Method: req.Method, Route: req.URL.Path, Key: key,
|
|
BodyHash: fmt.Sprintf("%x", bodyHash[:]), ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
|
}
|
|
existing, acquired, err := store.HTTPInfrastructure.BeginIdempotency(principal.TenantID, record)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if !acquired {
|
|
if existing == nil || existing.BodyHash != record.BodyHash {
|
|
writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil)
|
|
return
|
|
}
|
|
if existing.State == types.HTTPIdempotencyCompleted {
|
|
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
wrt.Header().Set("X-Idempotent-Replay", "true")
|
|
wrt.WriteHeader(existing.ResponseStatus)
|
|
_, _ = wrt.Write(existing.ResponseBody)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
|
|
return
|
|
}
|
|
|
|
buffered := newIMBufferedResponseWriter()
|
|
next.ServeHTTP(buffered, req)
|
|
record.ResponseStatus = buffered.status
|
|
record.ResponseBody = append([]byte(nil), buffered.body.Bytes()...)
|
|
if buffered.status >= http.StatusInternalServerError {
|
|
_ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record)
|
|
buffered.flushTo(wrt)
|
|
return
|
|
}
|
|
if err = store.HTTPInfrastructure.CompleteIdempotency(principal.TenantID, record); err != nil {
|
|
_ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
buffered.flushTo(wrt)
|
|
}
|
|
|
|
type imTenantResolveRequest struct {
|
|
TenantCode string `json:"tenant_code"`
|
|
Platform string `json:"platform,omitempty"`
|
|
AppVersion string `json:"app_version,omitempty"`
|
|
}
|
|
|
|
type imTenantApplicationRequest struct {
|
|
CreditCode string `json:"credit_code"`
|
|
EnterpriseEmail string `json:"enterprise_email,omitempty"`
|
|
LicenseFileID string `json:"license_file_id,omitempty"`
|
|
TenantCodeHint string `json:"tenant_code_hint,omitempty"`
|
|
ClientContext map[string]any `json:"client_context,omitempty"`
|
|
}
|
|
|
|
type imTenantEntryFeedbackRequest struct {
|
|
Description string `json:"description"`
|
|
Contact string `json:"contact,omitempty"`
|
|
TenantCodeHint string `json:"tenant_code_hint,omitempty"`
|
|
ScreenshotFileIDs []string `json:"screenshot_file_ids,omitempty"`
|
|
ClientContext map[string]any `json:"client_context,omitempty"`
|
|
}
|
|
|
|
type imVerificationCreateRequest struct {
|
|
TenantCode string `json:"tenant_code,omitempty"`
|
|
Scene string `json:"scene"`
|
|
Phone string `json:"phone"`
|
|
Country string `json:"country,omitempty"`
|
|
}
|
|
|
|
type imVerificationVerifyRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type imPasswordResetConfirmRequest struct {
|
|
ResetToken string `json:"reset_token"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
type imPrincipal struct {
|
|
TenantID types.TenantID
|
|
Uid types.Uid
|
|
}
|
|
|
|
type imSecurityChallengeRequest struct {
|
|
Scene string `json:"scene"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
|
|
type imURLCheckRequest struct {
|
|
URL string `json:"url"`
|
|
Source string `json:"source,omitempty"`
|
|
}
|
|
|
|
type imRealNameSubmissionRequest struct {
|
|
Name string `json:"name"`
|
|
IDType string `json:"id_type"`
|
|
IDNumber string `json:"id_number"`
|
|
}
|
|
|
|
type imRealNameAppealRequest struct {
|
|
Reason string `json:"reason"`
|
|
Attachments []string `json:"attachments,omitempty"`
|
|
}
|
|
|
|
type imIndependentPasswordRequest struct {
|
|
Password string `json:"password"`
|
|
Purpose string `json:"purpose"`
|
|
ActionToken string `json:"action_token,omitempty"`
|
|
}
|
|
|
|
type imIndependentPasswordVerifyRequest struct {
|
|
Password string `json:"password"`
|
|
Purpose string `json:"purpose"`
|
|
Action string `json:"action,omitempty"`
|
|
}
|
|
|
|
type imYouthModeEnableRequest struct {
|
|
PolicyVersion int `json:"policy_version"`
|
|
ActionToken string `json:"action_token"`
|
|
}
|
|
|
|
type imDevicePushTokenRequest struct {
|
|
Provider string `json:"provider"`
|
|
Token string `json:"token"`
|
|
AppVersion string `json:"app_version,omitempty"`
|
|
Platform string `json:"platform,omitempty"`
|
|
}
|
|
|
|
type imP2PConversationRequest struct {
|
|
PeerUserID string `json:"peer_user_id"`
|
|
}
|
|
|
|
type imConversationSettingsRequest struct {
|
|
Pinned *bool `json:"pinned,omitempty"`
|
|
Muted *bool `json:"muted,omitempty"`
|
|
MuteNotifications *bool `json:"mute_notifications,omitempty"`
|
|
Remark *string `json:"remark,omitempty"`
|
|
MessageRetention *string `json:"message_retention,omitempty"`
|
|
Version int `json:"version"`
|
|
}
|
|
|
|
type imTopicDraftRequest struct {
|
|
Content string `json:"content"`
|
|
Entities any `json:"entities,omitempty"`
|
|
Version int `json:"version"`
|
|
}
|
|
|
|
type imRiskChallengeConfirmRequest struct {
|
|
Decision string `json:"decision"`
|
|
}
|
|
|
|
type imAbuseReportRequest struct {
|
|
Target types.AbuseTarget `json:"target"`
|
|
ReasonCode string `json:"reason_code"`
|
|
Description string `json:"description,omitempty"`
|
|
EvidenceFileIDs []string `json:"evidence_file_ids,omitempty"`
|
|
}
|
|
|
|
type imFeedbackRequest struct {
|
|
Category string `json:"category"`
|
|
Content string `json:"content"`
|
|
Contact string `json:"contact,omitempty"`
|
|
AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"`
|
|
ClientContext map[string]any `json:"client_context,omitempty"`
|
|
IncludeDiagnostics bool `json:"include_diagnostics,omitempty"`
|
|
}
|
|
|
|
type imAttendanceCheckinRequest struct {
|
|
Date string `json:"date"`
|
|
Action string `json:"action"`
|
|
RuleID string `json:"rule_id,omitempty"`
|
|
ShiftID string `json:"shift_id,omitempty"`
|
|
ClientTime string `json:"client_time"`
|
|
Location types.KVMap `json:"location,omitempty"`
|
|
Wifi types.KVMap `json:"wifi,omitempty"`
|
|
Device types.KVMap `json:"device,omitempty"`
|
|
}
|
|
|
|
type imAttendanceAppealRequest struct {
|
|
RecordID string `json:"record_id"`
|
|
AppealType string `json:"appeal_type"`
|
|
Reason string `json:"reason"`
|
|
TargetAction string `json:"target_action,omitempty"`
|
|
TargetTime string `json:"target_time,omitempty"`
|
|
AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"`
|
|
}
|
|
|
|
type imSystemNotificationReadRequest struct {
|
|
ReadAt string `json:"read_at,omitempty"`
|
|
}
|
|
|
|
type imMessageRecallRequest struct {
|
|
Version int `json:"version"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type 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"`
|
|
}
|
|
|
|
type imGroupMemberProfileUpdateRequest struct {
|
|
Nickname string `json:"nickname"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupInvitationCreateRequest struct {
|
|
UserIDs []string `json:"user_ids"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
type imGroupJoinRequestCreateRequest struct {
|
|
Message string `json:"message,omitempty"`
|
|
QRToken string `json:"qr_token,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
}
|
|
|
|
type imGroupJoinRequestActionRequest struct {
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupFileCreateRequest struct {
|
|
FileID string `json:"file_id"`
|
|
FileName string `json:"file_name,omitempty"`
|
|
MIMEType string `json:"mime_type,omitempty"`
|
|
FileType string `json:"file_type,omitempty"`
|
|
FileSize int64 `json:"file_size,omitempty"`
|
|
SourceTopic string `json:"source_topic,omitempty"`
|
|
SourceSeq int64 `json:"source_seq,omitempty"`
|
|
}
|
|
|
|
type imGroupAnnouncementPutRequest struct {
|
|
Content string `json:"content"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupGovernancePatchRequest struct {
|
|
JoinApproval *bool `json:"join_approval,omitempty"`
|
|
MuteAll *bool `json:"mute_all,omitempty"`
|
|
MemberUploadFiles *bool `json:"member_upload_files,omitempty"`
|
|
MemberPrivateChat *bool `json:"member_private_chat,omitempty"`
|
|
MemberInvite *bool `json:"member_invite,omitempty"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupAdminsBatchRequest struct {
|
|
UserIDs []string `json:"user_ids"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupOwnerTransferRequest struct {
|
|
TargetUserID string `json:"target_user_id"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupMuteBatchRequest struct {
|
|
Members []struct {
|
|
UserID string `json:"user_id"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
} `json:"members"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
|
|
var body imTenantResolveRequest
|
|
if req.Body != nil {
|
|
dec := json.NewDecoder(req.Body)
|
|
dec.DisallowUnknownFields()
|
|
err := dec.Decode(&body)
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
err = nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
} else if err == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
|
if tenantCode == "" {
|
|
tenantCode = strings.TrimSpace(body.TenantCode)
|
|
}
|
|
if tenantCode == "" || len(tenantCode) > 64 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
|
|
"field": "tenant_code",
|
|
})
|
|
return
|
|
}
|
|
if store.Tenants == nil {
|
|
logs.Err.Println("im tenant resolve: tenant store is not initialized")
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
|
|
tenant, err := store.Tenants.GetByCode(tenantCode)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return
|
|
}
|
|
logs.Warn.Println("im tenant resolve: failed to resolve tenant", tenantCode, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if !tenant.IsActive() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return
|
|
}
|
|
|
|
endpoints := imPublicEndpoints(req)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"tenant": map[string]any{
|
|
"code": tenant.Code,
|
|
"name": tenant.Name,
|
|
"tenant_desc": tenant.TenantDesc,
|
|
"branding": map[string]any{},
|
|
},
|
|
"endpoints": endpoints,
|
|
"auth_methods": []string{"password", "sms"},
|
|
"password_policy": map[string]any{
|
|
"min_length": 8,
|
|
"max_length": 64,
|
|
},
|
|
"app_update": map[string]any{"type": "none"},
|
|
})
|
|
}
|
|
|
|
func serveIMPublicUploads(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if store.Store == nil || store.Store.GetMediaHandler() == nil {
|
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 90000, imReasonInternal, "media handler is not configured", nil)
|
|
return
|
|
}
|
|
uploader, ok := store.Store.GetMediaHandler().(media.PublicUploader)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30000, imReasonUnsupportedMedia, "public upload is not supported", nil)
|
|
return
|
|
}
|
|
req.Body = http.MaxBytesReader(wrt, req.Body, imPublicUploadMaxBytes)
|
|
if err := req.ParseMultipartForm(imPublicUploadMaxBytes); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed multipart form", nil)
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(req.FormValue("purpose"))
|
|
if !isAllowedPublicUploadPurpose(purpose) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
|
|
return
|
|
}
|
|
file, header, err := req.FormFile("file")
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "file is required", map[string]any{"field": "file"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
mimeType, err := detectIMUploadMime(file, header.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed file", nil)
|
|
return
|
|
}
|
|
if !isAllowedPublicUploadMime(purpose, mimeType) {
|
|
writeIMError(wrt, req, http.StatusUnsupportedMediaType, 30000, imReasonUnsupportedMedia, "unsupported media type", map[string]any{"mime_type": mimeType})
|
|
return
|
|
}
|
|
|
|
token, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
publicFile := &media.PublicFileDef{
|
|
ID: "file_" + token,
|
|
Purpose: purpose,
|
|
MimeType: mimeType,
|
|
CreatedAt: now,
|
|
}
|
|
location, size, err := uploader.UploadPublic(publicFile, file)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if location != "" {
|
|
publicFile.Location = location
|
|
}
|
|
upload, err := store.TenantEntry.CreatePublicUpload(&types.PublicUpload{
|
|
FileID: publicFile.ID,
|
|
Purpose: purpose,
|
|
MimeType: mimeType,
|
|
Size: size,
|
|
ETag: publicFile.ETag,
|
|
Location: publicFile.Location,
|
|
ExpiresAt: now.Add(24 * time.Hour),
|
|
CreatedAt: now,
|
|
UploadedAt: now,
|
|
})
|
|
if err != nil {
|
|
_ = store.Store.GetMediaHandler().Delete([]string{publicFile.Location})
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, publicUploadPayload(upload))
|
|
}
|
|
|
|
func serveIMTenantApplications(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
var body imTenantApplicationRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
creditCode := strings.ToUpper(strings.TrimSpace(body.CreditCode))
|
|
email := strings.TrimSpace(body.EnterpriseEmail)
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
if !isValidCreditCode(creditCode) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_CREDIT_CODE", "invalid credit code", map[string]any{"field": "credit_code"})
|
|
return
|
|
}
|
|
if email != "" && !isValidIMEmail(email) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_EMAIL", "invalid email", map[string]any{"field": "enterprise_email"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
app, err := store.TenantEntry.CreateTenantApplication(&types.TenantApplication{
|
|
ApplicationID: "entapp_" + token,
|
|
CreditCode: creditCode,
|
|
EnterpriseEmail: email,
|
|
LicenseFileID: strings.TrimSpace(body.LicenseFileID),
|
|
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
|
|
ClientContext: body.ClientContext,
|
|
IdempotencyKey: idempotencyKey,
|
|
Status: types.TenantApplicationStatusReceived,
|
|
EstimatedReviewHour: 24,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, tenantApplicationPayload(app))
|
|
}
|
|
|
|
func serveIMTenantEntryFeedback(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
var body imTenantEntryFeedbackRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
description := strings.TrimSpace(body.Description)
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
if len([]rune(description)) < 10 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_REQUIRED", "description is required", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
if len([]rune(description)) > 1000 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_TOO_LONG", "description is too long", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
if len(body.ScreenshotFileIDs) > 9 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, "TOO_MANY_FILES", "too many files", map[string]any{"field": "screenshot_file_ids"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
feedback, err := store.TenantEntry.CreateTenantEntryFeedback(&types.TenantEntryFeedback{
|
|
FeedbackID: "entryfb_" + token,
|
|
Description: description,
|
|
Contact: strings.TrimSpace(body.Contact),
|
|
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
|
|
ScreenshotFileIDs: cleanStringList(body.ScreenshotFileIDs, 9),
|
|
ClientContext: body.ClientContext,
|
|
IdempotencyKey: idempotencyKey,
|
|
Status: types.TenantEntryFeedbackReceived,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, tenantEntryFeedbackPayload(feedback))
|
|
}
|
|
|
|
func imPublicEndpoints(req *http.Request) map[string]any {
|
|
base := imPublicHTTPBase(req)
|
|
wsBase := base
|
|
if strings.HasPrefix(wsBase, "https://") {
|
|
wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://")
|
|
} else if strings.HasPrefix(wsBase, "http://") {
|
|
wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://")
|
|
}
|
|
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": strings.TrimRight(base, "/") + "/v1/im",
|
|
"file": strings.TrimRight(base, "/") + "/v0/file",
|
|
}
|
|
}
|
|
|
|
func imPublicHTTPBase(req *http.Request) string {
|
|
configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/")
|
|
parsed, _ := url.Parse(configured)
|
|
basePath := ""
|
|
if parsed != nil {
|
|
basePath = strings.TrimRight(parsed.Path, "/")
|
|
if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) {
|
|
return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/")
|
|
}
|
|
}
|
|
if basePath == "" && req != nil && req.URL != nil {
|
|
if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 {
|
|
basePath = strings.TrimRight(req.URL.Path[:idx], "/")
|
|
}
|
|
}
|
|
|
|
scheme := "http"
|
|
if req != nil && req.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
if req != nil {
|
|
if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" {
|
|
scheme = forwarded
|
|
}
|
|
}
|
|
|
|
host := ""
|
|
if req != nil {
|
|
host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host"))
|
|
if host == "" {
|
|
host = strings.TrimSpace(req.Host)
|
|
}
|
|
}
|
|
if host == "" && parsed != nil {
|
|
host = parsed.Host
|
|
}
|
|
if host == "" {
|
|
return configured
|
|
}
|
|
return strings.TrimRight(scheme+"://"+host+basePath, "/")
|
|
}
|
|
|
|
func isPublicEndpointHost(host string) bool {
|
|
host = strings.TrimSpace(host)
|
|
return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:")
|
|
}
|
|
|
|
func firstForwardedHeader(value string) string {
|
|
if idx := strings.Index(value, ","); idx >= 0 {
|
|
value = value[:idx]
|
|
}
|
|
return strings.TrimSpace(strings.ToLower(value))
|
|
}
|
|
|
|
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
|
|
var body imVerificationCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
scene := strings.TrimSpace(body.Scene)
|
|
if !isAllowedVerificationScene(scene) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "scene"})
|
|
return
|
|
}
|
|
phone, err := normalizeIMPhone(body.Phone, body.Country)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
|
|
return
|
|
}
|
|
tenant, ok := resolveIMHTTPTenant(wrt, req, body.TenantCode)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
|
|
if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
|
|
logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err)
|
|
} else if !allowed {
|
|
retryAfter := int(ttl.Seconds())
|
|
if retryAfter < 1 {
|
|
retryAfter = int(imVerificationCreateRateWindow.Seconds())
|
|
}
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
"retry_after_seconds": retryAfter,
|
|
})
|
|
return
|
|
}
|
|
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if active != nil && now.Before(active.ResendAfter) {
|
|
retryAfter := int(time.Until(active.ResendAfter).Seconds())
|
|
if retryAfter < 1 {
|
|
retryAfter = 1
|
|
}
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
"challenge_id": active.ChallengeID,
|
|
"retry_after_seconds": retryAfter,
|
|
})
|
|
return
|
|
}
|
|
|
|
challengeID, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
code := "123456"
|
|
deliveryEnabled := scene != "register" && registrationSMSEnabled(now)
|
|
vc := &types.VerificationCode{
|
|
TenantID: tenant.ID,
|
|
ChallengeID: challengeID,
|
|
Scene: scene,
|
|
PhoneHash: phoneHash,
|
|
PhoneMask: maskIMPhone(phone),
|
|
CodeHash: hashIMSecret(tenant.ID, scene, challengeID, phoneHash, code),
|
|
State: types.VerificationStatePending,
|
|
MaxAttempts: imVerificationMaxAttempts,
|
|
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
|
|
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
|
|
}
|
|
if scene == "reset_password" {
|
|
if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil {
|
|
logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err)
|
|
} else {
|
|
vc.TargetUser = uid
|
|
}
|
|
}
|
|
if err := store.Verifications.Create(tenant.ID, vc); err != nil {
|
|
logs.Warn.Println("im verification create: store failed", tenant.Code, scene, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if deliveryEnabled {
|
|
if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil {
|
|
logs.Warn.Println("im verification delivery failed; keeping fixed test code active:", tenant.Code, scene, vc.PhoneMask, err)
|
|
}
|
|
} else {
|
|
logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask)
|
|
}
|
|
|
|
logs.Info.Println("im verification challenge created:", tenant.Code, scene, vc.PhoneMask)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"challenge_id": vc.ChallengeID,
|
|
"phone_mask": vc.PhoneMask,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
"resend_after_seconds": imVerificationResendAfterSeconds,
|
|
})
|
|
}
|
|
|
|
func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
challengeID := verificationChallengeIDFromPath(req.URL.Path)
|
|
if challengeID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
|
|
return
|
|
}
|
|
var body imVerificationVerifyRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
code := strings.TrimSpace(body.Code)
|
|
if len(code) != imVerificationCodeLength {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
|
|
return
|
|
}
|
|
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
vc, err := store.Verifications.Get(tenant.ID, challengeID)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
logs.Warn.Println("im verification verify: lookup failed", tenant.Code, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
|
|
return
|
|
}
|
|
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
|
|
_ = store.Verifications.Fail(tenant.ID, challengeID, types.VerificationStateExpired)
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
|
|
nextState := types.VerificationStatePending
|
|
if vc.Attempts+1 >= vc.MaxAttempts {
|
|
nextState = types.VerificationStateLocked
|
|
}
|
|
_ = store.Verifications.Fail(tenant.ID, challengeID, nextState)
|
|
if nextState == types.VerificationStateLocked {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
|
|
return
|
|
}
|
|
|
|
token, err := randomIMToken(32)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err := store.Verifications.MarkVerified(tenant.ID, challengeID, hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
|
|
logs.Warn.Println("im verification verify: mark verified failed", tenant.Code, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"verification_token": token,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
})
|
|
}
|
|
|
|
func serveIMPasswordResetVerify(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
challengeID := passwordResetChallengeIDFromPath(req.URL.Path)
|
|
if challengeID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
|
|
return
|
|
}
|
|
var body imVerificationVerifyRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
|
|
if !ok {
|
|
return
|
|
}
|
|
vc, token, ok := verifyIMCodeChallenge(wrt, req, tenant.ID, challengeID, strings.TrimSpace(body.Code), "reset_password")
|
|
if !ok {
|
|
return
|
|
}
|
|
if vc.TargetUser.IsZero() {
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"reset_token": vc.ChallengeID + "." + token,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
})
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"reset_token": vc.ChallengeID + "." + token,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
})
|
|
}
|
|
|
|
func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
var body imPasswordResetConfirmRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
|
|
if !ok {
|
|
return
|
|
}
|
|
challengeID, token, ok := strings.Cut(strings.TrimSpace(body.ResetToken), ".")
|
|
if !ok || challengeID == "" || token == "" || strings.TrimSpace(body.NewPassword) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
vc, err := store.Verifications.Get(tenant.ID, challengeID)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
if vc.Scene != "reset_password" || vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
tokenHash := hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token)
|
|
if *vc.VerificationTokenHash != tokenHash || !time.Now().UTC().Before(vc.ExpiresAt) {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
if vc.TargetUser.IsZero() {
|
|
_ = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, time.Now().UTC().Round(time.Millisecond))
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
|
|
return
|
|
}
|
|
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser)
|
|
if err != nil || login == "" {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
basicAuth := store.Store.GetLogicalAuthHandler("basic")
|
|
if basicAuth == nil || !basicAuth.IsInitialized() {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if _, err = basicAuth.UpdateRecord(auth.AuthContext{TenantID: tenant.ID}, &auth.Rec{
|
|
TenantID: tenant.ID,
|
|
Uid: vc.TargetUser,
|
|
AuthLevel: authLevel,
|
|
}, []byte(":"+body.NewPassword)); err != nil {
|
|
if err == types.ErrPolicy {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if store.Security == nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if _, err = store.Security.BumpAuthVersion(tenant.ID, vc.TargetUser, now); err != nil {
|
|
logs.Warn.Println("im password reset confirm: bump auth version failed", err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, now); err != nil {
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return
|
|
}
|
|
if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" {
|
|
if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{
|
|
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
|
|
User: vc.TargetUser.String(),
|
|
Method: "tel",
|
|
Value: account.Phone,
|
|
Done: true,
|
|
}); credErr != nil {
|
|
logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr)
|
|
}
|
|
} else if getErr != nil {
|
|
logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr)
|
|
}
|
|
if globals.sessionStore != nil {
|
|
globals.sessionStore.EvictUser(vc.TargetUser, "")
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
|
|
}
|
|
|
|
func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imURLCheckRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
decision, riskLevel, reason := classifyIMURLRisk(strings.TrimSpace(body.URL))
|
|
payload := map[string]any{
|
|
"url": strings.TrimSpace(body.URL),
|
|
"source": strings.TrimSpace(body.Source),
|
|
"decision": decision,
|
|
"risk_level": riskLevel,
|
|
"reason": reason,
|
|
}
|
|
if decision != "allow" {
|
|
challengeID := "risk_" + shortTokenHash(fmt.Sprintf("%d:%s:%d", principal.Uid, strings.ToLower(strings.TrimSpace(body.URL)), time.Now().UnixNano()))
|
|
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(10 * time.Minute)
|
|
if _, err := store.Safety.CreateRiskChallenge(principal.TenantID, &types.RiskChallenge{
|
|
ChallengeID: challengeID,
|
|
UserID: principal.Uid,
|
|
Action: "open_url",
|
|
Level: riskLevel,
|
|
NoticeCode: "url_" + riskLevel,
|
|
NoticeParams: map[string]any{"source": strings.TrimSpace(body.Source)},
|
|
State: types.RiskChallengeStatePending,
|
|
ExpiresAt: expiresAt,
|
|
}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload["risk_challenge_id"] = challengeID
|
|
payload["expires_at"] = expiresAt.Format(time.RFC3339Nano)
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMRiskChallengeConfirm(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
challengeID := riskChallengeIDFromPath(req.URL.Path)
|
|
if challengeID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "risk_challenge_id"})
|
|
return
|
|
}
|
|
var body imRiskChallengeConfirmRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
decision := strings.ToLower(strings.TrimSpace(body.Decision))
|
|
var tokenID string
|
|
var token string
|
|
var expiresAt time.Time
|
|
var err error
|
|
if decision == "continue" {
|
|
token, expiresAt, err = issueIMActionToken(principal.TenantID, principal.Uid, "risk_continue", challengeID)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
tokenID, _, _ = strings.Cut(token, ".")
|
|
}
|
|
challenge, err := store.Safety.ConfirmRiskChallenge(principal.TenantID, principal.Uid, challengeID, decision, tokenID, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "risk challenge not found", nil)
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := riskChallengePayload(challenge)
|
|
if decision == "continue" {
|
|
payload["risk_action_token"] = token
|
|
payload["token_action"] = "risk_continue"
|
|
payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano)
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
eventType := "security.risk." + decision
|
|
eventID := stableIMEventID("risk", challengeID, decision, challenge.State)
|
|
if err = createIMSystemNotification(principal.TenantID, principal.Uid,
|
|
stableIMEventID(eventID, "notification"), eventType, "安全操作确认", decision,
|
|
types.KVMap{"risk_challenge_id": challengeID}); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "user", principal.Uid.UserId(),
|
|
principal.Uid, eventType, map[string]any{"risk_challenge_id": challengeID, "decision": decision}); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type")))
|
|
if !isAllowedIMAbuseTargetType(targetType) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_type"})
|
|
return
|
|
}
|
|
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
|
|
if err != nil {
|
|
logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err)
|
|
reasons = defaultIMAbuseReasons(targetType)
|
|
}
|
|
if len(reasons) == 0 {
|
|
reasons = defaultIMAbuseReasons(targetType)
|
|
}
|
|
items := make([]map[string]any, 0, len(reasons))
|
|
for _, reason := range reasons {
|
|
items = append(items, abuseReasonPayload(reason))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items})
|
|
}
|
|
|
|
func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imAbuseReportRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
targetType, targetUser, targetTopic, targetSeq, snapshot, err := normalizeIMAbuseTarget(principal.TenantID, principal.Uid, body.Target)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
description := strings.TrimSpace(body.Description)
|
|
if len([]rune(description)) > 500 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
if len(body.EvidenceFileIDs) > 9 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "evidence_file_ids"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
report, err := store.Safety.CreateAbuseReport(principal.TenantID, &types.AbuseReport{
|
|
ReportID: "ar_" + token,
|
|
ReporterID: principal.Uid,
|
|
TargetType: targetType,
|
|
TargetUserID: targetUser,
|
|
TargetTopic: targetTopic,
|
|
TargetSeq: targetSeq,
|
|
ReasonCode: strings.TrimSpace(body.ReasonCode),
|
|
Description: description,
|
|
EvidenceFileIDs: cleanStringList(body.EvidenceFileIDs, 9),
|
|
Snapshot: snapshot,
|
|
}, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, abuseReportPayload(report))
|
|
}
|
|
|
|
func serveIMFeedbackConfig(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
config, err := store.Safety.FeedbackConfig(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, feedbackConfigPayload(config))
|
|
}
|
|
|
|
func serveIMFeedback(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFeedbackRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
content := strings.TrimSpace(body.Content)
|
|
if idempotencyKey == "" || strings.TrimSpace(body.Category) == "" || content == "" || len([]rune(content)) > 1000 || len(body.AttachmentFileIDs) > 5 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
feedback, err := store.Safety.CreateFeedback(principal.TenantID, &types.FeedbackSubmission{
|
|
FeedbackID: "fb_" + token,
|
|
UserID: principal.Uid,
|
|
Category: strings.TrimSpace(body.Category),
|
|
Content: content,
|
|
Contact: strings.TrimSpace(body.Contact),
|
|
AttachmentFileIDs: cleanStringList(body.AttachmentFileIDs, 5),
|
|
ClientContext: body.ClientContext,
|
|
IncludeDiagnostics: body.IncludeDiagnostics,
|
|
}, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback))
|
|
}
|
|
|
|
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imSecurityChallengeRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
action := strings.TrimSpace(body.Scene)
|
|
if !validIMSecurityChallengeScene(action) || strings.TrimSpace(body.Channel) != "sms" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
phone, err := currentUserPhone(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30041, imReasonRealNameRequired, "verified phone required", nil)
|
|
return
|
|
}
|
|
createSensitiveChallenge(wrt, req, principal, action, phone)
|
|
}
|
|
|
|
func serveIMSecurityChallengeVerify(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
challengeID := securityChallengeIDFromPath(req.URL.Path)
|
|
if challengeID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
var body imVerificationVerifyRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
vc, _, ok := verifyIMCodeChallenge(wrt, req, principal.TenantID, challengeID, strings.TrimSpace(body.Code), "sensitive_action")
|
|
if !ok {
|
|
return
|
|
}
|
|
if vc.TargetUser != principal.Uid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, vc.Action, defaultIMActionTarget(vc.Action, vc.ChallengeID))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"action_token": token,
|
|
"action": vc.Action,
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func serveIMRealName(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "unverified", "can_submit": true})
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, realNamePayload(rv))
|
|
}
|
|
|
|
func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
var body imRealNameSubmissionRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
idType := strings.TrimSpace(body.IDType)
|
|
idNumber := strings.ToUpper(strings.TrimSpace(body.IDNumber))
|
|
if !validIMRealName(name, idType, idNumber) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
current, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
|
|
if err == nil && (current.Status == "pending" || current.Status == "verified") {
|
|
writeIMSuccess(wrt, req, realNamePayload(current))
|
|
return
|
|
}
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32]
|
|
rv := &types.RealNameVerification{
|
|
TenantID: principal.TenantID,
|
|
UserID: principal.Uid,
|
|
SubmissionID: submissionID,
|
|
Status: "pending",
|
|
NameCipher: protectIMSensitive(principal.TenantID, name),
|
|
IDType: idType,
|
|
IDNumberCipher: protectIMSensitive(principal.TenantID, idNumber),
|
|
IDNumberHash: hashIMSecret(principal.TenantID, idType, idNumber, "realname-id"),
|
|
MaskedName: maskIMName(name),
|
|
MaskedIDNumber: maskIMIDNumber(idNumber),
|
|
}
|
|
if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil {
|
|
if errors.Is(err, types.ErrDuplicate) {
|
|
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending", "can_submit": false})
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"})
|
|
}
|
|
|
|
func serveIMRealNameAppeals(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imRealNameAppealRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
reason := strings.TrimSpace(body.Reason)
|
|
if reason == "" || len([]rune(reason)) > 500 || len(body.Attachments) > 9 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"})
|
|
return
|
|
}
|
|
attachments := make([]string, 0, len(body.Attachments))
|
|
for _, raw := range body.Attachments {
|
|
fileID := strings.TrimSpace(raw)
|
|
if fileID == "" {
|
|
continue
|
|
}
|
|
attachments = append(attachments, fileID)
|
|
}
|
|
encoded, err := json.Marshal(attachments)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
appeal, err := store.Security.CreateRealNameAppeal(principal.TenantID, &types.RealNameAppeal{
|
|
TenantID: principal.TenantID,
|
|
UserID: principal.Uid,
|
|
AppealID: "rna_" + token,
|
|
Reason: reason,
|
|
Attachments: attachments,
|
|
AttachmentsJSON: encoded,
|
|
Status: "pending",
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, realNameAppealPayload(appeal))
|
|
}
|
|
|
|
func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
status := "unverified"
|
|
if rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid); err == nil {
|
|
status = rv.Status
|
|
} else if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
var modules []types.WorkbenchModule
|
|
workbenchVersion := 1
|
|
if store.Workbench != nil {
|
|
var err error
|
|
modules, workbenchVersion, err = store.Workbench.Get(principal.TenantID, principal.Uid)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
}
|
|
modulePayloads := workbenchModulePayloads(modules)
|
|
if err := populateWorkbenchModuleUnreadCounts(principal.TenantID, principal.Uid, modulePayloads); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid, modulePayloads)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
audioCallEnabled, videoCallEnabled := imCallFeatureFlags()
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"features": map[string]any{
|
|
"create_group": true,
|
|
"user_search": true,
|
|
"scan_qr": true,
|
|
"system_notifications": true,
|
|
"attendance": true,
|
|
"message_recall": true,
|
|
"message_edit": true,
|
|
"audio_call": audioCallEnabled,
|
|
"video_call": videoCallEnabled,
|
|
},
|
|
"permissions": map[string]any{
|
|
"create_group": true,
|
|
"user_search": true,
|
|
"scan_qr": true,
|
|
"audio_call": audioCallEnabled,
|
|
"video_call": videoCallEnabled,
|
|
},
|
|
"entry_badges": entryBadges,
|
|
"entries": entries,
|
|
"modules": modulePayloads,
|
|
"workbench_version": workbenchVersion,
|
|
"limits": map[string]any{
|
|
"max_file_size": globals.maxFileUploadSize,
|
|
"max_text_length": globals.maxMessageSize,
|
|
"max_forward_targets": 9,
|
|
},
|
|
"system_topic": map[string]any{
|
|
"topic": "sys",
|
|
"type": "system",
|
|
},
|
|
"message_policy": map[string]any{
|
|
"recall_window_seconds": 120,
|
|
"edit_window_seconds": 300,
|
|
"supported_actions": []string{"copy", "quote", "delete", "recall", "edit"},
|
|
},
|
|
"security": map[string]any{
|
|
"realname": map[string]any{
|
|
"status": status,
|
|
"required": false,
|
|
"allow_skip": true,
|
|
"remind_after": "24h",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func serveIMWorkbench(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
modules, layoutVersion, err := store.Workbench.Get(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
tenant, err := store.Tenants.GetByID(principal.TenantID)
|
|
if err != nil || tenant == nil || strings.TrimSpace(tenant.Code) == "" {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"layout_version": layoutVersion,
|
|
"modules": workbenchModulePayloads(modules),
|
|
"enterprise": map[string]any{
|
|
"tenant_code": tenant.Code,
|
|
"feature_flags": map[string]any{
|
|
"organization": true,
|
|
"attendance": true,
|
|
"meetings": true,
|
|
"daily_reports": true,
|
|
"monthly_reports": true,
|
|
"executive_mailbox": true,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func serveIMOrganizationDepartments(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
parentID := strings.TrimSpace(req.URL.Query().Get("parent_id"))
|
|
if parentID == "" {
|
|
parentID = "root"
|
|
}
|
|
items, nextCursor, err := store.Organization.ListDepartments(principal.TenantID, principal.Uid, parentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"items": organizationDepartmentPayloads(items),
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
})
|
|
}
|
|
|
|
func serveIMOrganizationDepartmentResource(wrt http.ResponseWriter, req *http.Request) {
|
|
departmentID, tail, ok := organizationDepartmentResourcePath(req.URL.Path)
|
|
if !ok || tail != "members" {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "organization route not found", nil)
|
|
return
|
|
}
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Organization.ListMembers(principal.TenantID, principal.Uid, departmentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"department_id": departmentID,
|
|
"items": organizationMemberPayloads(items),
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
})
|
|
}
|
|
|
|
func serveIMOrganizationMembers(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
query := strings.TrimSpace(req.URL.Query().Get("q"))
|
|
if query == "" {
|
|
query = strings.TrimSpace(req.URL.Query().Get("keyword"))
|
|
}
|
|
departmentID := strings.TrimSpace(req.URL.Query().Get("department_id"))
|
|
items, nextCursor, err := store.Organization.ListAllMembers(principal.TenantID, principal.Uid, departmentID, query, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"department_id": departmentID,
|
|
"items": organizationMemberPayloads(items),
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
})
|
|
}
|
|
|
|
func serveIMOrganizationSearch(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
query := strings.TrimSpace(req.URL.Query().Get("q"))
|
|
if query == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"})
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
items, nextCursor, err := store.Organization.Search(principal.TenantID, principal.Uid, query, strings.TrimSpace(req.URL.Query().Get("types")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"items": organizationSearchPayloads(items),
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
})
|
|
}
|
|
|
|
func serveIMAttendanceConfig(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
date, err := parseIMOptionalDate(req.URL.Query().Get("date"), time.Now().UTC())
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
config, err := store.Attendance.Config(principal.TenantID, principal.Uid, date)
|
|
if err != nil {
|
|
logs.Warn.Println("im attendance config failed:", principal.TenantID, principal.Uid.UserId(), err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, attendanceConfigPayload(config))
|
|
}
|
|
|
|
func serveIMAttendanceRecords(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validIMOptionalDateQuery(wrt, req, "date") || !validIMOptionalDateQuery(wrt, req, "from") || !validIMOptionalDateQuery(wrt, req, "to") {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
records, nextCursor, stats, err := store.Attendance.ListRecords(principal.TenantID, principal.Uid, types.AttendanceRecordListOptions{
|
|
Scope: req.URL.Query().Get("scope"),
|
|
From: strings.TrimSpace(req.URL.Query().Get("from")),
|
|
To: strings.TrimSpace(req.URL.Query().Get("to")),
|
|
Date: strings.TrimSpace(req.URL.Query().Get("date")),
|
|
Status: strings.TrimSpace(req.URL.Query().Get("status")),
|
|
DepartmentID: strings.TrimSpace(req.URL.Query().Get("department_id")),
|
|
UserID: strings.TrimSpace(req.URL.Query().Get("user_id")),
|
|
Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
logs.Warn.Println("im attendance records failed:", principal.TenantID, principal.Uid.UserId(), err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"items": attendanceRecordPayloads(records),
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
"statistics": attendanceStatsPayload(stats),
|
|
"permissions": map[string]any{
|
|
"can_checkin": true,
|
|
"can_view_managed": true,
|
|
"can_appeal": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func serveIMAttendanceCheckins(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imAttendanceCheckinRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
date, err := parseIMDate(body.Date)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
clientTime, err := parseIMTime(body.ClientTime)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "client_time"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
record, checkin, err := store.Attendance.Checkin(principal.TenantID, principal.Uid, "attc_"+token, types.AttendanceCheckinRequest{
|
|
Date: date,
|
|
Action: strings.TrimSpace(body.Action),
|
|
RuleID: strings.TrimSpace(body.RuleID),
|
|
ShiftID: strings.TrimSpace(body.ShiftID),
|
|
ClientTime: clientTime,
|
|
Location: body.Location,
|
|
Wifi: body.Wifi,
|
|
Device: body.Device,
|
|
IdempotencyKey: idempotencyKey,
|
|
})
|
|
if err != nil {
|
|
logs.Warn.Println("im attendance checkin failed:", principal.TenantID, principal.Uid.UserId(), err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.record.updated", record.RecordID,
|
|
[]types.Uid{principal.Uid}, "打卡", record.Date.Format("2006-01-02"), map[string]any{"record_id": record.RecordID, "checkin_id": checkin.CheckinID, "status": record.Status})
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"record": attendanceRecordPayload(record),
|
|
"checkin": attendanceCheckinPayload(*checkin),
|
|
})
|
|
}
|
|
|
|
func serveIMAttendanceAppeals(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Attendance.ListAppeals(principal.TenantID, principal.Uid, types.AttendanceAppealListOptions{
|
|
Scope: req.URL.Query().Get("scope"),
|
|
Status: strings.TrimSpace(req.URL.Query().Get("status")),
|
|
Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
logs.Warn.Println("im attendance appeals failed:", principal.TenantID, principal.Uid.UserId(), err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": attendanceAppealPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != ""})
|
|
case http.MethodPost:
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imAttendanceAppealRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.Reason) == "" || len([]rune(body.Reason)) > 500 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"})
|
|
return
|
|
}
|
|
var targetTime *time.Time
|
|
if strings.TrimSpace(body.TargetTime) != "" {
|
|
parsed, err := parseIMTime(body.TargetTime)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_time"})
|
|
return
|
|
}
|
|
targetTime = &parsed
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
appeal, err := store.Attendance.CreateAppeal(principal.TenantID, principal.Uid, &types.AttendanceAppeal{
|
|
AppealID: "atta_" + token,
|
|
RecordID: strings.TrimSpace(body.RecordID),
|
|
UserID: principal.Uid,
|
|
AppealType: strings.TrimSpace(body.AppealType),
|
|
Reason: strings.TrimSpace(body.Reason),
|
|
TargetAction: strings.TrimSpace(body.TargetAction),
|
|
TargetTime: targetTime,
|
|
AttachmentFileIDs: body.AttachmentFileIDs,
|
|
}, idempotencyKey)
|
|
if err != nil {
|
|
logs.Warn.Println("im attendance appeal create failed:", principal.TenantID, principal.Uid.UserId(), err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.appeal.created", appeal.AppealID,
|
|
[]types.Uid{principal.Uid}, "考勤申诉", appeal.RecordID, map[string]any{"appeal_id": appeal.AppealID, "record_id": appeal.RecordID, "status": appeal.Status})
|
|
writeIMSuccess(wrt, req, attendanceAppealPayload(appeal))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method == http.MethodGet {
|
|
serveIMMeetingList(wrt, req)
|
|
return
|
|
}
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Title string `json:"title"`
|
|
RecorderUserID string `json:"recorder_user_id"`
|
|
StartAt string `json:"start_at"`
|
|
EndAt string `json:"end_at"`
|
|
Location string `json:"location"`
|
|
AttendeeUserIDs []string `json:"attendee_user_ids"`
|
|
ReminderMinutes []int `json:"reminder_minutes"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
startAt, err := parseIMTime(body.StartAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"})
|
|
return
|
|
}
|
|
endAt, err := parseIMTime(body.EndAt)
|
|
if err != nil || !endAt.After(startAt) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"})
|
|
return
|
|
}
|
|
meetingID, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
attendeeIDs, ok := parseIMUserIDList(wrt, req, body.AttendeeUserIDs, "attendee_user_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
recorderID := principal.Uid
|
|
if value := strings.TrimSpace(body.RecorderUserID); value != "" {
|
|
recorderID = types.ParseUserId(value)
|
|
if recorderID.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"})
|
|
return
|
|
}
|
|
}
|
|
if !ensureIMMeetingUserExists(principal.TenantID, recorderID) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"})
|
|
return
|
|
}
|
|
if !uidListContains(attendeeIDs, recorderID) && recorderID != principal.Uid {
|
|
attendeeIDs = append(attendeeIDs, recorderID)
|
|
}
|
|
meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{
|
|
MeetingID: "mtg_" + meetingID,
|
|
OrganizerID: principal.Uid,
|
|
RecorderID: recorderID,
|
|
Title: strings.TrimSpace(body.Title),
|
|
StartAt: startAt,
|
|
EndAt: endAt,
|
|
Location: strings.TrimSpace(body.Location),
|
|
ReminderMinutes: body.ReminderMinutes,
|
|
IdempotencyKey: idempotencyKey,
|
|
}, attendeeIDs)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.created"); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID,
|
|
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
|
|
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingList(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
meetings, nextCursor, err := store.Meetings.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(meetings))
|
|
userSummaries := meetingUserSummaries(principal.TenantID, meetings)
|
|
for i := range meetings {
|
|
items = append(items, meetingPayloadWithUserSummaries(&meetings[i], principal.Uid, userSummaries))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"items": items,
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
})
|
|
}
|
|
|
|
func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) {
|
|
meetingID, tail, ok := meetingResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting route not found", nil)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "":
|
|
if req.Method == http.MethodGet {
|
|
serveIMMeetingGet(wrt, req, meetingID)
|
|
return
|
|
}
|
|
if req.Method == http.MethodPatch {
|
|
serveIMMeetingUpdate(wrt, req, meetingID)
|
|
return
|
|
}
|
|
case "cancel":
|
|
serveIMMeetingCancel(wrt, req, meetingID)
|
|
return
|
|
case "reminders":
|
|
serveIMMeetingReminders(wrt, req, meetingID)
|
|
return
|
|
case "my-response":
|
|
serveIMMeetingMyResponse(wrt, req, meetingID)
|
|
return
|
|
case "notes":
|
|
serveIMMeetingNotesDetail(wrt, req, meetingID)
|
|
return
|
|
}
|
|
if strings.HasPrefix(tail, "notes/files") {
|
|
serveIMMeetingNoteFiles(wrt, req, meetingID, tail)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
|
|
func serveIMMeetingNotes(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
meetings, nextCursor, err := store.MeetingNotes.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("query")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
userSummaries := meetingUserSummaries(principal.TenantID, meetings)
|
|
items := make([]map[string]any, 0, len(meetings))
|
|
for i := range meetings {
|
|
files, err := store.MeetingNotes.ListFiles(principal.TenantID, principal.Uid, meetings[i].MeetingID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items = append(items, meetingNoteListItemPayload(&meetings[i], files, principal.Uid, userSummaries))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"items": items,
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
})
|
|
}
|
|
|
|
func serveIMMeetingNotesDetail(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !meetingNotesReadable(meeting, principal.Uid) {
|
|
writeIMRelationError(wrt, req, types.ErrPermissionDenied)
|
|
return
|
|
}
|
|
files, err := store.MeetingNotes.ListFiles(principal.TenantID, principal.Uid, meetingID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingNotesDetailPayload(principal.TenantID, meeting, files, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingNoteFiles(wrt http.ResponseWriter, req *http.Request, meetingID, tail string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "notes/files" && req.Method == http.MethodPost:
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
FileID string `json:"file_id"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.FileID) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
|
|
return
|
|
}
|
|
noteFileID := "mnote_" + store.Store.GetUidString()
|
|
file, err := store.MeetingNotes.CreateFile(principal.TenantID, principal.Uid, meetingID, noteFileID, body.FileID, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil))
|
|
return
|
|
case strings.HasPrefix(tail, "notes/files/"):
|
|
noteFileID := strings.TrimSpace(strings.TrimPrefix(tail, "notes/files/"))
|
|
if noteFileID == "" || strings.Contains(noteFileID, "/") {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting note file route not found", nil)
|
|
return
|
|
}
|
|
if req.Method == http.MethodPut {
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
FileID string `json:"file_id"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.FileID) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
|
|
return
|
|
}
|
|
file, err := store.MeetingNotes.ReplaceFile(principal.TenantID, principal.Uid, meetingID, noteFileID, body.FileID, idempotencyKey, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil))
|
|
return
|
|
}
|
|
if req.Method == http.MethodDelete {
|
|
version, err := strconv.Atoi(strings.TrimSpace(req.URL.Query().Get("version")))
|
|
if err != nil || version <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
|
|
return
|
|
}
|
|
file, err := store.MeetingNotes.DeleteFile(principal.TenantID, principal.Uid, meetingID, noteFileID, strings.TrimSpace(req.Header.Get("Idempotency-Key")), version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil))
|
|
return
|
|
}
|
|
}
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
|
|
func serveIMMeetingGet(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Title *string `json:"title"`
|
|
RecorderUserID *string `json:"recorder_user_id"`
|
|
StartAt *string `json:"start_at"`
|
|
EndAt *string `json:"end_at"`
|
|
Location *string `json:"location"`
|
|
ReminderMinutes []int `json:"reminder_minutes"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
patch := types.MeetingPatch{Title: body.Title, Location: body.Location, ReminderMinutes: body.ReminderMinutes, Version: body.Version}
|
|
if body.RecorderUserID != nil {
|
|
recorderID := types.ParseUserId(strings.TrimSpace(*body.RecorderUserID))
|
|
if recorderID.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"})
|
|
return
|
|
}
|
|
if !ensureIMMeetingUserExists(principal.TenantID, recorderID) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"})
|
|
return
|
|
}
|
|
patch.RecorderID = &recorderID
|
|
}
|
|
if body.StartAt != nil {
|
|
startAt, err := parseIMTime(*body.StartAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"})
|
|
return
|
|
}
|
|
patch.StartAt = &startAt
|
|
}
|
|
if body.EndAt != nil {
|
|
endAt, err := parseIMTime(*body.EndAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"})
|
|
return
|
|
}
|
|
patch.EndAt = &endAt
|
|
}
|
|
meeting, err := store.Meetings.Update(principal.TenantID, principal.Uid, meetingID, patch, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.updated"); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID,
|
|
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
|
|
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
meeting, err := store.Meetings.Cancel(principal.TenantID, principal.Uid, meetingID, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.canceled"); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID,
|
|
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
|
|
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
participants, err := store.Meetings.Remind(principal.TenantID, principal.Uid, meetingID, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
meeting, getErr := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID)
|
|
if getErr != nil {
|
|
writeIMRelationError(wrt, req, getErr)
|
|
return
|
|
}
|
|
meeting.Participants = participants
|
|
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.reminded"); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.reminded", meetingID,
|
|
meetingParticipantUserIDs(participants), "会议提醒", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)})
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"meeting_id": meetingID,
|
|
"reminded": meetingParticipantPayloadsWithSummaries(participants,
|
|
meetingUserSummaries(principal.TenantID, []types.Meeting{{Participants: participants}})),
|
|
})
|
|
}
|
|
|
|
func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Response string `json:"response"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
meeting, err := store.Meetings.Respond(principal.TenantID, principal.Uid, meetingID, strings.TrimSpace(body.Response), body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.response.updated"); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID,
|
|
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
|
|
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
reports, nextCursor, err := store.DailyReports.List(principal.TenantID, principal.Uid,
|
|
req.URL.Query().Get("scope"),
|
|
req.URL.Query().Get("from"),
|
|
req.URL.Query().Get("to"),
|
|
req.URL.Query().Get("status"),
|
|
req.URL.Query().Get("cursor"),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"items": dailyReportListPayloads(reports),
|
|
"next_cursor": nextCursor,
|
|
"permissions": map[string]any{
|
|
"can_create": true,
|
|
"makeup_allowed_days": 7,
|
|
"can_view_managed": false,
|
|
"can_remind_unsubmitted": false,
|
|
},
|
|
})
|
|
case http.MethodPost:
|
|
var body struct {
|
|
ReportDate string `json:"report_date"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
reportDate, err := parseIMDate(body.ReportDate)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
report, err := store.DailyReports.CreateDraft(principal.TenantID, principal.Uid, "dr_"+token, reportDate)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.created", report.ReportID,
|
|
[]types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
|
|
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMDailyReportResource(wrt http.ResponseWriter, req *http.Request) {
|
|
reportID, tail, ok := dailyReportResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "daily report route not found", nil)
|
|
return
|
|
}
|
|
if reportID == "statistics" && tail == "" {
|
|
serveIMDailyReportStatistics(wrt, req)
|
|
return
|
|
}
|
|
if reportID == "reminders" && tail == "" {
|
|
serveIMDailyReportReminders(wrt, req)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "":
|
|
if req.Method == http.MethodGet {
|
|
serveIMDailyReportGet(wrt, req, reportID)
|
|
return
|
|
}
|
|
case "draft":
|
|
serveIMDailyReportSaveDraft(wrt, req, reportID)
|
|
return
|
|
case "submit":
|
|
serveIMDailyReportSubmit(wrt, req, reportID)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
|
|
func serveIMDailyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
report, err := store.DailyReports.Get(principal.TenantID, principal.Uid, reportID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.saved", report.ReportID,
|
|
[]types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
|
|
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMDailyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Content string `json:"content"`
|
|
Attachments []string `json:"attachments"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.DailyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.Content, body.Attachments, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.submitted", report.ReportID,
|
|
[]types.Uid{principal.Uid}, "日报已提交", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
|
|
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMDailyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.DailyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMDailyReportStatistics(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
date, err := parseIMDate(req.URL.Query().Get("date"))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
stats, err := store.DailyReports.Stats(principal.TenantID, principal.Uid, date, req.URL.Query().Get("department_id"))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"expected_count": stats.ExpectedCount,
|
|
"submitted_count": stats.SubmittedCount,
|
|
"missing_count": stats.MissingCount,
|
|
"scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))},
|
|
})
|
|
}
|
|
|
|
func serveIMDailyReportReminders(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Date string `json:"date"`
|
|
UserIDs []string `json:"user_ids"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
date, err := parseIMDate(body.Date)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
userIDs, ok := parseIMUserIDList(wrt, req, body.UserIDs, "user_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
reminded, err := store.DailyReports.Remind(principal.TenantID, principal.Uid, date, userIDs, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(reminded))
|
|
for _, uid := range reminded {
|
|
items = append(items, map[string]any{"user_id": uid.UserId(), "status": "queued"})
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.reminded", date.Format("2006-01-02"),
|
|
reminded, "日报提醒", date.Format("2006-01-02"), map[string]any{"date": date.Format("2006-01-02"), "reminded_count": len(reminded)})
|
|
writeIMSuccess(wrt, req, map[string]any{"date": date.Format("2006-01-02"), "items": items})
|
|
}
|
|
|
|
func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
reports, nextCursor, err := store.MonthlyReports.List(principal.TenantID, principal.Uid,
|
|
req.URL.Query().Get("scope"),
|
|
req.URL.Query().Get("from_month"),
|
|
req.URL.Query().Get("to_month"),
|
|
req.URL.Query().Get("status"),
|
|
req.URL.Query().Get("cursor"),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor, "has_more": nextCursor != ""})
|
|
case http.MethodPost:
|
|
var body struct {
|
|
Month string `json:"month"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if !validIMMonth(body.Month) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.CreateDraft(principal.TenantID, principal.Uid, "mr_"+token, strings.TrimSpace(body.Month))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.created", report.ReportID,
|
|
[]types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMMonthlyReportResource(wrt http.ResponseWriter, req *http.Request) {
|
|
reportID, tail, ok := monthlyReportResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "monthly report route not found", nil)
|
|
return
|
|
}
|
|
if reportID == "statistics" && tail == "" {
|
|
serveIMMonthlyReportStatistics(wrt, req)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "":
|
|
if req.Method == http.MethodGet {
|
|
serveIMMonthlyReportGet(wrt, req, reportID)
|
|
return
|
|
}
|
|
case "draft":
|
|
serveIMMonthlyReportSaveDraft(wrt, req, reportID)
|
|
return
|
|
case "submit":
|
|
serveIMMonthlyReportSubmit(wrt, req, reportID)
|
|
return
|
|
case "reopen":
|
|
serveIMMonthlyReportReopen(wrt, req, reportID)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
|
|
func serveIMMonthlyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.Get(principal.TenantID, principal.Uid, reportID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.saved", report.ReportID,
|
|
[]types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMMonthlyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
WorkSummary string `json:"work_summary"`
|
|
NextPlan string `json:"next_plan"`
|
|
Attachments []string `json:"attachments"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.submitted", report.ReportID,
|
|
[]types.Uid{principal.Uid}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.reopened", report.ReportID,
|
|
[]types.Uid{principal.Uid}, "月报已重开", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMMonthlyReportReopen(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.Reopen(principal.TenantID, principal.Uid, reportID, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMMonthlyReportStatistics(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
month := strings.TrimSpace(req.URL.Query().Get("month"))
|
|
if !validIMMonth(month) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"})
|
|
return
|
|
}
|
|
stats, err := store.MonthlyReports.Stats(principal.TenantID, principal.Uid, month, req.URL.Query().Get("department_id"))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"expected_count": stats.ExpectedCount,
|
|
"submitted_count": stats.SubmittedCount,
|
|
"missing_count": stats.MissingCount,
|
|
"scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))},
|
|
})
|
|
}
|
|
|
|
func serveIMMailboxConfig(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
config, err := store.Mailbox.Config(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
logs.Warn.Println("im mailbox config: using default config after store error", principal.TenantID, err)
|
|
config = defaultIMMailboxConfig()
|
|
}
|
|
writeIMSuccess(wrt, req, mailboxConfigPayload(config))
|
|
}
|
|
|
|
func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Subject string `json:"subject"`
|
|
Content string `json:"content"`
|
|
Anonymous bool `json:"anonymous"`
|
|
AttachmentFileIDs []string `json:"attachment_file_ids"`
|
|
NoticeVersion string `json:"notice_version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
submission, err := store.Mailbox.Submit(principal.TenantID, principal.Uid, &types.MailboxSubmission{
|
|
SubmissionID: "mb_" + token,
|
|
SenderID: principal.Uid,
|
|
Subject: strings.TrimSpace(body.Subject),
|
|
Content: strings.TrimSpace(body.Content),
|
|
Anonymous: body.Anonymous,
|
|
AttachmentFileIDs: body.AttachmentFileIDs,
|
|
NoticeVersion: strings.TrimSpace(body.NoticeVersion),
|
|
}, idempotencyKey)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, "MAILBOX_UNAVAILABLE", "mailbox unavailable", nil)
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "mailbox", "mailbox.submitted", submission.SubmissionID,
|
|
[]types.Uid{principal.Uid}, "总经理信箱", submission.Subject, map[string]any{"submission_id": submission.SubmissionID, "status": submission.Status, "anonymous": submission.Anonymous})
|
|
writeIMSuccess(wrt, req, mailboxSubmissionResultPayload(submission))
|
|
}
|
|
|
|
func serveIMMe(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
profile, err := store.Profiles.Get(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"user_id": principal.Uid.UserId(),
|
|
"profile": userProfilePayload10(profile),
|
|
"profile_version": profile.Version,
|
|
"realname": map[string]any{"status": "unknown"},
|
|
"youth_mode": map[string]any{"enabled": false},
|
|
})
|
|
}
|
|
|
|
func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
profile, err := store.Profiles.Get(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userProfilePayload10(profile))
|
|
case http.MethodPatch:
|
|
var body struct {
|
|
AvatarFileID *string `json:"avatar_file_id"`
|
|
Nickname *string `json:"nickname"`
|
|
BirthDate *string `json:"birth_date"`
|
|
Signature *string `json:"signature"`
|
|
Gender *string `json:"gender"`
|
|
Region *string `json:"region"`
|
|
Email *string `json:"email"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.BirthDate != nil && strings.TrimSpace(*body.BirthDate) != "" {
|
|
if date, err := parseIMDate(*body.BirthDate); err != nil || date.After(time.Now().UTC()) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "birth_date"})
|
|
return
|
|
}
|
|
}
|
|
if body.AvatarFileID != nil {
|
|
if avatar := strings.TrimSpace(*body.AvatarFileID); avatar != "" {
|
|
if err := store.Files.LinkAttachments(principal.TenantID, principal.Uid.UserId(), types.ZeroUid, []string{avatar}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{
|
|
AvatarFileID: body.AvatarFileID,
|
|
Nickname: body.Nickname,
|
|
BirthDate: body.BirthDate,
|
|
Signature: body.Signature,
|
|
Gender: body.Gender,
|
|
Region: body.Region,
|
|
Email: body.Email,
|
|
Version: body.Version,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userProfilePayload10(profile))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
settings, err := store.Profiles.GetPrivacy(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
|
|
case http.MethodPatch:
|
|
var body struct {
|
|
AddMeMethods map[string]bool `json:"add_me_methods"`
|
|
ProfileVisibility *string `json:"profile_visibility"`
|
|
LastSeenVisibility *string `json:"last_seen_visibility"`
|
|
ReadReceipts *bool `json:"read_receipts"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{
|
|
AddMeMethods: body.AddMeMethods,
|
|
ProfileVisibility: body.ProfileVisibility,
|
|
LastSeenVisibility: body.LastSeenVisibility,
|
|
ReadReceipts: body.ReadReceipts,
|
|
Version: body.Version,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
tenant, ok := resolveIMHTTPTenant(wrt, req, "default")
|
|
if !ok {
|
|
return
|
|
}
|
|
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
|
|
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel")))
|
|
if channel == "" {
|
|
channel = "stable"
|
|
}
|
|
if platform != "ios" && platform != "android" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
|
|
return
|
|
}
|
|
release, err := store.AppReleases.Latest(tenant.ID, platform, channel)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, appReleasePayload(release))
|
|
}
|
|
|
|
func serveIMYouthMode(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
settings, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, youthModePayload(settings))
|
|
}
|
|
|
|
func serveIMYouthModeEnable(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imYouthModeEnableRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.PolicyVersion <= 0 || strings.TrimSpace(body.ActionToken) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "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,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, devicePushTokenPayload(token))
|
|
}
|
|
|
|
func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
|
|
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
start := 0
|
|
if cursor != "" {
|
|
parsed, err := strconv.Atoi(cursor)
|
|
if err != nil || parsed < 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
start = parsed
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
nextIndex := start
|
|
for i := start; i < len(subs); i++ {
|
|
if query != "" && !conversationMatches(subs[i], query) {
|
|
continue
|
|
}
|
|
items = append(items, conversationPayload(subs[i]))
|
|
nextIndex = i + 1
|
|
if len(items) == limit {
|
|
break
|
|
}
|
|
}
|
|
nextCursor := ""
|
|
if nextIndex < len(subs) {
|
|
nextCursor = strconv.Itoa(nextIndex)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMP2PConversation(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imP2PConversationRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
peer := types.ParseUserId(strings.TrimSpace(body.PeerUserID))
|
|
if peer.IsZero() || peer == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "peer_user_id"})
|
|
return
|
|
}
|
|
topic, err := store.Conversations.EnsureP2P(principal.TenantID, principal.Uid, peer)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
|
}
|
|
|
|
func serveIMForwardTargets(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("query")))
|
|
if query == "" {
|
|
query = strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
|
|
}
|
|
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
start := 0
|
|
if cursor != "" {
|
|
parsed, err := strconv.Atoi(cursor)
|
|
if err != nil || parsed < 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
start = parsed
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
if start == 0 && forwardTargetMatches("slf", "文件助手", query) {
|
|
items = append(items, map[string]any{
|
|
"topic": "slf",
|
|
"type": "file_assistant",
|
|
"display_name": "文件助手",
|
|
"avatar_file_id": "",
|
|
"can_receive_forward": true,
|
|
})
|
|
}
|
|
nextIndex := start
|
|
for i := start; i < len(subs); i++ {
|
|
target := forwardTargetPayload(subs[i])
|
|
if target == nil {
|
|
nextIndex = i + 1
|
|
continue
|
|
}
|
|
if query != "" && !forwardTargetMatches(target["topic"].(string), target["display_name"].(string), query) && !conversationMatches(subs[i], query) {
|
|
continue
|
|
}
|
|
items = append(items, target)
|
|
nextIndex = i + 1
|
|
if len(items) == limit {
|
|
break
|
|
}
|
|
}
|
|
nextCursor := ""
|
|
if nextIndex < len(subs) {
|
|
nextCursor = strconv.Itoa(nextIndex)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
|
|
}
|
|
|
|
func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
items, nextCursor, err := store.Security.ListSystemNotifications(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
eventID := systemNotificationIDFromPath(req.URL.Path)
|
|
if eventID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "id"})
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Header.Get("Idempotency-Key")) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
var body imSystemNotificationReadRequest
|
|
if req.Body != nil && req.ContentLength != 0 {
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
}
|
|
readAt := time.Now().UTC().Round(time.Millisecond)
|
|
if body.ReadAt != "" {
|
|
parsed, err := time.Parse(time.RFC3339Nano, body.ReadAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "read_at"})
|
|
return
|
|
}
|
|
readAt = parsed.UTC().Round(time.Millisecond)
|
|
}
|
|
if err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt); err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)})
|
|
}
|
|
|
|
func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
serveIMFavoriteList(wrt, req)
|
|
case http.MethodPost:
|
|
serveIMFavoriteBatchCreate(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Favorites.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
itemPayload, ok := favoritePayloadForPrincipal(principal, item)
|
|
if ok {
|
|
payload = append(payload, itemPayload)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesBatchCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.Messages) == 0 || len(body.Messages) > 100 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "messages"})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
created, existing := 0, 0
|
|
failed := make([]map[string]any, 0)
|
|
for _, src := range body.Messages {
|
|
sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic)
|
|
seq := int64(src.Seq)
|
|
if !sourceOK || seq <= 0 {
|
|
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": imReasonMalformedRequest})
|
|
continue
|
|
}
|
|
if err := requireTopicReader(principal, sourceTopic); err != nil {
|
|
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, seq)
|
|
if err != nil {
|
|
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, seq)
|
|
fav := &types.FavoriteItem{
|
|
TenantID: principal.TenantID,
|
|
FavoriteID: "fav_" + store.Store.GetUidString(),
|
|
UserID: principal.Uid,
|
|
ItemType: imMessageType(msg),
|
|
SourceTopic: sourceTopic,
|
|
SourceSeq: seq,
|
|
Snapshot: favoriteSnapshot(msg),
|
|
AttachmentRefs: favoriteAttachmentRefs(msg),
|
|
Availability: messageAvailability(*msg),
|
|
IdempotencyKey: idempotencyKey,
|
|
CollectedAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
item, err := store.Favorites.Create(principal.TenantID, fav)
|
|
if err != nil {
|
|
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
if item.FavoriteID == fav.FavoriteID {
|
|
created++
|
|
} else {
|
|
existing++
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"created_count": created, "existing_count": existing, "failed": failed})
|
|
}
|
|
|
|
func serveIMFavoriteResource(wrt http.ResponseWriter, req *http.Request) {
|
|
const prefix = "/v1/im/favorites/"
|
|
idx := strings.Index(req.URL.Path, prefix)
|
|
if idx < 0 {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "favorite route not found", nil)
|
|
return
|
|
}
|
|
tail := strings.Trim(strings.TrimPrefix(req.URL.Path[idx:], prefix), "/")
|
|
switch {
|
|
case tail == "batch":
|
|
serveIMFavoriteBatchCreate(wrt, req)
|
|
case tail == "batch-delete":
|
|
serveIMFavoriteBatchDelete(wrt, req)
|
|
case tail == "forward":
|
|
serveIMFavoriteForward(wrt, req)
|
|
default:
|
|
serveIMFavoriteDetail(wrt, req, tail)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload, ok := favoritePayloadForPrincipal(principal, *item)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
case http.MethodDelete:
|
|
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"favorite_id": favoriteID, "deleted": true})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteBatchDelete(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesBatchDeleteRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.FavoriteIDs))
|
|
for _, favoriteID := range body.FavoriteIDs {
|
|
favoriteID = strings.TrimSpace(favoriteID)
|
|
item := map[string]any{"favorite_id": favoriteID}
|
|
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
|
|
item["ok"] = false
|
|
item["reason"] = relationErrorReason(err)
|
|
} else {
|
|
item["ok"] = true
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": results})
|
|
}
|
|
|
|
func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesForwardRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.FavoriteIDs) == 0 || len(body.FavoriteIDs) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs))
|
|
comment := strings.TrimSpace(body.Comment)
|
|
for _, target := range body.TargetTopics {
|
|
rawTarget := strings.TrimSpace(target)
|
|
target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
|
|
if target == "" || !targetOK {
|
|
results = append(results, map[string]any{"target_topic": rawTarget, "status": "failed", "reason": imReasonMalformedRequest})
|
|
continue
|
|
}
|
|
if err := requireTopicWriter(principal, target); err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
for _, favoriteID := range body.FavoriteIDs {
|
|
favoriteID = strings.TrimSpace(favoriteID)
|
|
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
msg := &types.Message{
|
|
TenantID: principal.TenantID,
|
|
Topic: target,
|
|
From: principal.Uid.String(),
|
|
Head: types.KVMap{
|
|
"type": item.ItemType,
|
|
"x-im-type": item.ItemType,
|
|
"x-im-forwarded": true,
|
|
},
|
|
Content: favoriteForwardContent(item, comment),
|
|
}
|
|
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
|
|
}
|
|
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId})
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": results})
|
|
}
|
|
|
|
func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imMessagesForwardRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
mode := strings.TrimSpace(body.Mode)
|
|
if mode == "" {
|
|
mode = "separate"
|
|
}
|
|
if mode != "separate" && 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
|
|
}
|
|
if mode == "bundle" {
|
|
bundle, sourceFailures := buildForwardBundle(principal, body.Sources, target, sourceReadable, body.Comment)
|
|
if len(sourceFailures) > 0 {
|
|
failed = append(failed, sourceFailures...)
|
|
continue
|
|
}
|
|
clientMid := "fwd_" + store.Store.GetUidString()
|
|
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
|
|
}
|
|
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
|
|
if err != nil {
|
|
failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
forwarded.SeqId = saved.SeqId
|
|
broadcastHTTPForwardMessage(principal.TenantID, forwarded)
|
|
items = append(items, 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"],
|
|
})
|
|
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 := "fwd_" + store.Store.GetUidString()
|
|
forwarded := &types.Message{
|
|
TenantID: principal.TenantID,
|
|
Topic: target,
|
|
From: principal.Uid.String(),
|
|
Head: forwardedMessageHead(msg, clientMid),
|
|
Content: forwardedMessageContent(msg, body.Comment),
|
|
}
|
|
if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil {
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
|
|
if err != nil {
|
|
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
|
|
continue
|
|
}
|
|
forwarded.SeqId = saved.SeqId
|
|
broadcastHTTPForwardMessage(principal.TenantID, forwarded)
|
|
items = append(items, map[string]any{
|
|
"source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)},
|
|
"target_topic": target,
|
|
"client_mid": clientMid,
|
|
"seq": saved.SeqId,
|
|
"status": "sent",
|
|
})
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "failed": failed})
|
|
}
|
|
|
|
func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(req.URL.Path, "/draft"):
|
|
serveIMTopicDraft(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/calls"):
|
|
serveIMTopicCalls(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/messages/search"):
|
|
serveIMMessageSearch(wrt, req)
|
|
case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"):
|
|
serveIMMessageContext(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/media"):
|
|
serveIMTopicMedia(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/receipts"):
|
|
serveIMMessageReceipts(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/recall"):
|
|
serveIMMessageRecall(wrt, req)
|
|
case strings.HasSuffix(req.URL.Path, "/revisions"):
|
|
serveIMMessageRevision(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message action route not found", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMTopicDraft(wrt http.ResponseWriter, req *http.Request) {
|
|
topic := topicFromDraftPath(req.URL.Path)
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
draft, err := store.Conversations.GetDraft(principal.TenantID, principal.Uid, topic)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "content": "", "entities": []any{}, "version": 0})
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, topicDraftPayload(draft))
|
|
case http.MethodPut:
|
|
var body imTopicDraftRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
encoded, err := json.Marshal(body.Entities)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "entities"})
|
|
return
|
|
}
|
|
if int64(len([]byte(body.Content))) > globals.maxMessageSize {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
|
|
return
|
|
}
|
|
draft, err := store.Conversations.PutDraft(principal.TenantID, principal.Uid, topic, body.Content, encoded, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, topicDraftPayload(draft))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMCalls(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, "", strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMTopicCalls(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromCallsPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessagesSearchPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, messageSearchPayload(msg, strings.TrimSpace(req.URL.Query().Get("q"))))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
topic, seq, ok := messageContextPath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
|
|
return
|
|
}
|
|
principal, normalizedTopic, ok := authenticateTopicReader(wrt, req, topic)
|
|
if !ok {
|
|
return
|
|
}
|
|
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
|
|
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
|
|
msgs, err := store.Messages.GetAll(principal.TenantID, normalizedTopic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, messageContextPayload(msg))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": normalizedTopic, "anchor_seq": seq, "items": items})
|
|
}
|
|
|
|
func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMediaPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
mediaType := strings.TrimSpace(req.URL.Query().Get("type"))
|
|
if mediaType == "" {
|
|
mediaType = "image"
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), mediaType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, mediaMessagePayload(msg))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageReceiptsPath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message receipt route not found", nil)
|
|
return
|
|
}
|
|
if seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "seq"})
|
|
return
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 100, 200)
|
|
cursor, _ := strconv.Atoi(req.URL.Query().Get("cursor"))
|
|
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: limit + 1})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
readItems := make([]map[string]any, 0, limit)
|
|
deliveredItems := make([]map[string]any, 0, limit)
|
|
nextCursor := 0
|
|
for i, member := range members {
|
|
if i < cursor {
|
|
continue
|
|
}
|
|
if len(readItems) >= limit && len(deliveredItems) >= limit {
|
|
nextCursor = i
|
|
break
|
|
}
|
|
item := receiptMemberPayload(member)
|
|
readReceipts := false
|
|
memberID := types.ZeroUid
|
|
if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 {
|
|
memberID = types.Uid(decoded)
|
|
} else {
|
|
memberID = types.ParseUid(member.User)
|
|
}
|
|
if !memberID.IsZero() {
|
|
if privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, memberID); privacyErr == nil {
|
|
readReceipts = privacy.ReadReceipts
|
|
}
|
|
}
|
|
if readReceipts && member.ReadSeqId >= seq && len(readItems) < limit {
|
|
readItems = append(readItems, item)
|
|
}
|
|
if member.RecvSeqId >= seq && len(deliveredItems) < limit {
|
|
deliveredItems = append(deliveredItems, item)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"seq": seq,
|
|
"read": readItems,
|
|
"delivered": deliveredItems,
|
|
"next_cursor": nextCursor,
|
|
})
|
|
}
|
|
|
|
func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageActionPath(req.URL.Path, "recall")
|
|
if !ok || seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
var body imMessageRecallRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 {
|
|
if body.Version <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
|
|
}
|
|
return
|
|
}
|
|
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, true)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !authorized {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required", nil)
|
|
return
|
|
}
|
|
if time.Since(msg.CreatedAt) > 120*time.Second {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired", nil)
|
|
return
|
|
}
|
|
recalledAt := time.Now().UTC().Round(time.Millisecond)
|
|
updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, body.Version, recalledAt)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
eventID := stableIMEventID("message", topic, seq, updated.Version, "recalled")
|
|
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
|
|
"message.recalled", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, messageMutationPayload(updated, "recalled"))
|
|
}
|
|
|
|
func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageActionPath(req.URL.Path, "revisions")
|
|
if !ok || seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
var body imMessageRevisionRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.Version <= 0 || body.Content == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version/content"})
|
|
return
|
|
}
|
|
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, false)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !authorized {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit permission required", nil)
|
|
return
|
|
}
|
|
if time.Since(msg.CreatedAt) > 300*time.Second {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit window expired", nil)
|
|
return
|
|
}
|
|
if !isIMEditableTextMessage(msg) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "only text messages can be edited", nil)
|
|
return
|
|
}
|
|
head := body.Head
|
|
if head == nil {
|
|
head = types.KVMap{}
|
|
}
|
|
head["type"] = "text"
|
|
if body.ClientMid != "" {
|
|
head["x-im-client-mid"] = body.ClientMid
|
|
}
|
|
editedAt := time.Now().UTC().Round(time.Millisecond)
|
|
updated, err := store.Messages.Edit(principal.TenantID, topic, seq, principal.Uid, body.Version, head, body.Content, body.Reason, editedAt)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
eventID := stableIMEventID("message", topic, seq, updated.Version, "edited")
|
|
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
|
|
"message.edited", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited"))
|
|
}
|
|
|
|
func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, tail := conversationResourceFromPath(req.URL.Path)
|
|
if tail == "settings" {
|
|
serveIMConversationSettings(wrt, req, principal, topic)
|
|
return
|
|
}
|
|
if tail == "messages" {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false})
|
|
return
|
|
}
|
|
if tail != "context" {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "conversation route not found", nil)
|
|
return
|
|
}
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
topic, peer, 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 serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
|
|
case http.MethodPatch:
|
|
var body imConversationSettingsRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
muted := body.Muted
|
|
if muted == nil {
|
|
muted = body.MuteNotifications
|
|
}
|
|
patch := types.ConversationSettingsPatch{
|
|
Pinned: body.Pinned,
|
|
Muted: muted,
|
|
Remark: body.Remark,
|
|
MessageRetention: body.MessageRetention,
|
|
Version: body.Version,
|
|
}
|
|
settings, err := store.Conversations.UpdateSettings(principal.TenantID, principal.Uid, topic, patch)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
serveIMFriendRequestList(wrt, req)
|
|
case http.MethodPost:
|
|
serveIMFriendRequestCreate(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFriendRequestList(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
items, nextCursor, err := store.Relations.ListFriendRequests(principal.TenantID, principal.Uid,
|
|
strings.TrimSpace(req.URL.Query().Get("direction")),
|
|
strings.TrimSpace(req.URL.Query().Get("status")),
|
|
strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
payload = append(payload, friendRequestListItemPayload(item))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFriendRequestCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
target := types.ParseUserId(body.TargetUserID)
|
|
message := strings.TrimSpace(body.Message)
|
|
if target.IsZero() || target == principal.Uid || len([]rune(message)) > 50 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
relation, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, target, principal.Uid.P2PName(target))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if relation == nil || relation.Relationship != types.RelationshipNone {
|
|
reason := "FRIEND_REQUEST_CONFLICT"
|
|
if relation != nil && (relation.Relationship == types.RelationshipBlockedByMe || relation.Relationship == types.RelationshipBlockedByPeer) {
|
|
reason = imReasonPermissionDenied
|
|
}
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, reason, "friend request is not allowed for current relationship", nil)
|
|
return
|
|
}
|
|
source := strings.TrimSpace(body.Source)
|
|
sourceTopic := strings.TrimSpace(body.SourceTopic)
|
|
switch source {
|
|
case "search", "qr":
|
|
if sourceTopic != "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "source_topic is only valid for group source", nil)
|
|
return
|
|
}
|
|
case "group":
|
|
if types.GetTopicCat(sourceTopic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "valid group source_topic is required", nil)
|
|
return
|
|
}
|
|
requesterSub, requesterErr := store.Subs.Get(principal.TenantID, sourceTopic, principal.Uid, false)
|
|
targetSub, targetErr := store.Subs.Get(principal.TenantID, sourceTopic, target, false)
|
|
if requesterErr != nil || targetErr != nil {
|
|
if requesterErr != nil {
|
|
writeIMRelationError(wrt, req, requesterErr)
|
|
} else {
|
|
writeIMRelationError(wrt, req, targetErr)
|
|
}
|
|
return
|
|
}
|
|
if requesterSub == nil || targetSub == nil ||
|
|
!(requesterSub.ModeGiven & requesterSub.ModeWant).IsJoiner() || !(targetSub.ModeGiven & targetSub.ModeWant).IsJoiner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "group membership required", nil)
|
|
return
|
|
}
|
|
settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, sourceTopic)
|
|
if settingsErr != nil {
|
|
writeIMRelationError(wrt, req, settingsErr)
|
|
return
|
|
}
|
|
if settings == nil || !settings.MemberPrivateChat {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member private chat is disabled", nil)
|
|
return
|
|
}
|
|
default:
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil)
|
|
return
|
|
}
|
|
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !imPrivacyAllowsAdd(privacy, body.Source) {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "user does not accept friend requests from this source", nil)
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
requestID := "frq_" + store.Store.GetUidString()
|
|
created, err := store.Relations.CreateFriendRequest(principal.TenantID, &types.FriendRequest{
|
|
TenantID: principal.TenantID,
|
|
RequestID: requestID,
|
|
ApplicantID: principal.Uid,
|
|
TargetID: target,
|
|
Message: message,
|
|
Source: source,
|
|
SourceTopic: sourceTopic,
|
|
Status: types.FriendRequestPending,
|
|
IdempotencyKey: idempotencyKey,
|
|
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
dispatchIMBusinessEvents(principal.TenantID)
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, target, created.RequestID,
|
|
"friend.request.created", "新的好友申请", message); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, friendRequestPayload(created))
|
|
}
|
|
|
|
func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid,
|
|
strings.TrimSpace(req.URL.Query().Get("query")),
|
|
strings.TrimSpace(req.URL.Query().Get("initial")),
|
|
strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
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 {
|
|
item = mergeGroupListSettingsPayload(item, settings)
|
|
}
|
|
if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category {
|
|
continue
|
|
}
|
|
if query != "" && !groupListItemMatches(item, query) {
|
|
continue
|
|
}
|
|
if seen < cursor {
|
|
seen++
|
|
continue
|
|
}
|
|
if len(items) == limit {
|
|
nextCursor = strconv.Itoa(seen)
|
|
break
|
|
}
|
|
items = append(items, item)
|
|
seen++
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
|
|
}
|
|
|
|
func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imGroupCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if len([]rune(name)) < 1 || len([]rune(name)) > 32 || len(body.MemberUserIDs) > 99 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
description := strings.TrimSpace(body.Description)
|
|
if len([]rune(description)) > 200 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
topicName := genTopicName()
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
public := map[string]any{"name": name, "fn": name, "version": 1}
|
|
if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" {
|
|
public["avatar_file_id"] = avatar
|
|
}
|
|
if description != "" {
|
|
public["description"] = description
|
|
}
|
|
topic := &types.Topic{
|
|
ObjHeader: types.ObjHeader{Id: topicName},
|
|
TenantID: principal.TenantID,
|
|
State: types.StateOK,
|
|
Access: types.DefaultAccess{Auth: types.ModeCPublic, Anon: types.ModeNone},
|
|
Public: public,
|
|
}
|
|
topic.GiveAccess(principal.Uid, types.ModeCFull, types.ModeCFull)
|
|
settings := &types.GroupSettings{
|
|
Topic: topicName,
|
|
OwnerUserID: principal.Uid,
|
|
Name: name,
|
|
AvatarFileID: strings.TrimSpace(body.AvatarFileID),
|
|
Description: description,
|
|
GroupStatus: types.GroupStatusActive,
|
|
MemberUploadFiles: true,
|
|
MemberPrivateChat: true,
|
|
MemberInvite: true,
|
|
MaxAdminCount: groupMaxAdminCount,
|
|
MemberCount: 1,
|
|
Version: 1,
|
|
CreatedBy: principal.Uid,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if settings.AvatarFileID != "" {
|
|
if err := linkIMGroupAvatarAttachment(principal.TenantID, topicName, settings.AvatarFileID); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"})
|
|
return
|
|
}
|
|
}
|
|
memberIDs := make([]types.Uid, 0, len(body.MemberUserIDs))
|
|
seen := map[types.Uid]bool{principal.Uid: true}
|
|
for _, rawID := range body.MemberUserIDs {
|
|
uid := types.ParseUserId(strings.TrimSpace(rawID))
|
|
if uid.IsZero() || seen[uid] {
|
|
continue
|
|
}
|
|
seen[uid] = true
|
|
memberIDs = append(memberIDs, uid)
|
|
}
|
|
memberPrivate := groupMemberPrivate("", false, false, 1, now)
|
|
if err := store.GroupBusiness.CreateGroup(principal.TenantID, topic, principal.Uid, memberPrivate,
|
|
settings, memberIDs, memberPrivate, now); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
created, err := store.Topics.Get(principal.TenantID, topicName)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
selfSub, err := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub))
|
|
}
|
|
|
|
func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
|
|
topic, tail, ok := groupResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
|
return
|
|
}
|
|
if tail == "settings" {
|
|
serveIMGroupSettings(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "member-settings" {
|
|
serveIMGroupMemberSettings(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "members/me/profile" {
|
|
serveIMGroupMyProfile(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "leave" {
|
|
serveIMGroupLeave(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "qrcodes" {
|
|
serveIMGroupQRCodes(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "invitations" {
|
|
serveIMGroupInvitations(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "join-requests" {
|
|
serveIMGroupJoinRequests(wrt, req, topic)
|
|
return
|
|
}
|
|
if strings.HasPrefix(tail, "join-requests/") {
|
|
serveIMGroupJoinRequestAction(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "summary" {
|
|
serveIMGroupSummary(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "announcements/latest" || tail == "announcements/current" || strings.HasPrefix(tail, "announcements/") {
|
|
serveIMGroupAnnouncements(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "files" || strings.HasPrefix(tail, "files/") {
|
|
serveIMGroupFiles(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "governance" {
|
|
serveIMGroupGovernance(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "role-limits" {
|
|
serveIMGroupRoleLimits(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "admins/batch" || strings.HasPrefix(tail, "admins/") {
|
|
serveIMGroupAdmins(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "owner-transfer" {
|
|
serveIMGroupOwnerTransfer(wrt, req, topic)
|
|
return
|
|
}
|
|
if tail == "mutes" || strings.HasPrefix(tail, "mutes/") {
|
|
serveIMGroupMutes(wrt, req, topic, tail)
|
|
return
|
|
}
|
|
if tail == "members" {
|
|
serveIMGroupMembers(wrt, req, topic)
|
|
return
|
|
}
|
|
if strings.HasPrefix(tail, "members/") && strings.HasSuffix(tail, "/profile") {
|
|
memberID, ok := groupMemberProfilePath(tail)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
|
return
|
|
}
|
|
serveIMGroupMemberProfile(wrt, req, topic, memberID)
|
|
return
|
|
}
|
|
if tail == "" {
|
|
serveIMGroupDetail(wrt, req, topic)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
|
}
|
|
|
|
func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
selfAccess := types.ModeNone
|
|
if selfSub != nil {
|
|
selfAccess = selfSub.ModeGiven & selfSub.ModeWant
|
|
}
|
|
if selfSub == nil || !selfAccess.IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
|
|
cursor := 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"))
|
|
cacheKey := groupMemberPageCacheKey(topic, principal.Uid, selfAccess, query, roleFilter, eligibleRole, limit, cursor, mentionableOnly, muteEligible)
|
|
if cached, ok := store.GetCachedGroupMemberPage(principal.TenantID, cacheKey); ok {
|
|
writeIMSuccess(wrt, req, cached)
|
|
return
|
|
}
|
|
members, nextCursor, err := store.GroupBusiness.ListMembers(principal.TenantID, topic, types.GroupMemberListOptions{
|
|
Role: roleFilter,
|
|
EligibleRole: eligibleRole,
|
|
Query: query,
|
|
Cursor: cursor,
|
|
Limit: limit,
|
|
Mentionable: mentionableOnly,
|
|
})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
for _, member := range members {
|
|
access := groupMemberAccess(member)
|
|
item := groupBusinessMemberPayload(member)
|
|
if eligibleRole != "" {
|
|
eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole)
|
|
item["eligible"] = eligible
|
|
if reason != "" {
|
|
item["ineligible_reason"] = reason
|
|
}
|
|
}
|
|
if muteEligible {
|
|
eligible, reason := groupMuteEligibility(selfAccess, access)
|
|
item["eligible"] = eligible
|
|
if reason != "" {
|
|
item["ineligible_reason"] = reason
|
|
}
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
permissions := map[string]any{
|
|
"can_mention_all": selfAccess.IsAdmin(),
|
|
}
|
|
payload := map[string]any{
|
|
"topic": topic,
|
|
"items": items,
|
|
"next_cursor": nextCursor,
|
|
"permissions": permissions,
|
|
}
|
|
store.CacheGroupMemberPage(principal.TenantID, cacheKey, payload)
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if group == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
payload := groupDetailPayload(group, selfSub)
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
|
|
payload = mergeGroupSettingsPayload(payload, settings)
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
case http.MethodPatch:
|
|
access := selfSub.ModeGiven & selfSub.ModeWant
|
|
if !access.IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupPatchRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if group == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
public := groupPublicMap(group.Public)
|
|
currentVersion := intFromAny(public["version"])
|
|
if currentVersion <= 0 {
|
|
currentVersion = 1
|
|
}
|
|
if body.Version > 0 && body.Version != currentVersion {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
|
|
return
|
|
}
|
|
if body.Name != nil {
|
|
name := strings.TrimSpace(*body.Name)
|
|
if len([]rune(name)) < 1 || len([]rune(name)) > 32 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "name"})
|
|
return
|
|
}
|
|
public["name"] = name
|
|
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
|
|
}
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
updated, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
payload := groupDetailPayload(updated, selfSub)
|
|
if settingsUpdated {
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
|
|
payload = mergeGroupSettingsPayload(payload, settings)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
payload := groupSettingsPayload(group, selfSub)
|
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
|
|
payload = mergeGroupSettingsPayload(payload, settings)
|
|
payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub)
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPatch {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imGroupMemberSettingsRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
private := groupPrivateMap(selfSub.Private)
|
|
if body.Pinned != nil {
|
|
private["pinned"] = *body.Pinned
|
|
}
|
|
if body.MuteNotifications != nil {
|
|
private["mute_notifications"] = *body.MuteNotifications
|
|
}
|
|
private["version"] = intFromAny(private["version"]) + 1
|
|
private["updated_at"] = time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano)
|
|
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Pinned: body.Pinned, MuteNotifications: body.MuteNotifications}); err != nil {
|
|
if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil {
|
|
logs.Warn.Printf("group[%s]: failed to roll back member settings: %v", topic, rollbackErr)
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
selfSub.Private = private
|
|
writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub))
|
|
}
|
|
|
|
func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPut && req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
private := groupPrivateMap(selfSub.Private)
|
|
currentVersion := intFromAny(private["profile_version"])
|
|
if currentVersion <= 0 {
|
|
currentVersion = 1
|
|
}
|
|
if req.Method == http.MethodGet {
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"nickname": stringFromAny(private["nickname"]),
|
|
"version": currentVersion,
|
|
"updated_at": stringFromAny(private["profile_updated_at"]),
|
|
})
|
|
return
|
|
}
|
|
var body imGroupMemberProfileUpdateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
nickname := strings.TrimSpace(body.Nickname)
|
|
if len([]rune(nickname)) > 32 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"})
|
|
return
|
|
}
|
|
if body.Version > 0 && body.Version != currentVersion {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
|
|
return
|
|
}
|
|
private["nickname"] = nickname
|
|
private["profile_version"] = currentVersion + 1
|
|
updatedAt := time.Now().UTC().Round(time.Millisecond)
|
|
private["profile_updated_at"] = updatedAt.Format(time.RFC3339Nano)
|
|
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId())
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
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
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
|
|
}
|
|
|
|
func serveIMGroupLeave(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if (selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "owner must transfer or dismiss group before leaving", nil)
|
|
return
|
|
}
|
|
if err := store.GroupBusiness.Leave(principal.TenantID, topic, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"})
|
|
}
|
|
|
|
func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsSharer() && !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupQRCodeCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
expiresIn := body.ExpiresInSeconds
|
|
if expiresIn <= 0 {
|
|
expiresIn = imQRCodeDefaultExpiresInSeconds
|
|
}
|
|
if expiresIn > imQRCodeMaxExpiresInSeconds {
|
|
expiresIn = imQRCodeMaxExpiresInSeconds
|
|
}
|
|
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
|
|
token, err := generateIMQRCodeTokenForTopic(principal.TenantID, principal.Uid, topic, "join_group", expiresAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"qr_token": token,
|
|
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group",
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
"group_snapshot": groupDetailPayload(group, selfSub),
|
|
})
|
|
}
|
|
|
|
func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
access := selfSub.ModeGiven & selfSub.ModeWant
|
|
if !access.IsAdmin() {
|
|
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
invitationID := "ginv_" + store.Store.GetUidString()
|
|
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: "",
|
|
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"] = inv.Status
|
|
item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano)
|
|
if imReliableHTTPEventsEnabled(req) {
|
|
publishIMConversationSystemNotice(principal.TenantID, principal.Uid.P2PName(uid),
|
|
"system_action", "group_invitation_created", "邀请你加入群聊。",
|
|
[]imSystemMessageActor{{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}},
|
|
[]imSystemMessageAction{{Type: "group_invitation_accept", Label: "立即加入", TargetID: inv.InvitationID}},
|
|
map[string]any{"invitation_id": inv.InvitationID, "topic": topic, "message": inv.Message})
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results})
|
|
}
|
|
|
|
func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
access := selfSub.ModeGiven & selfSub.ModeWant
|
|
switch {
|
|
case tail == "files" && req.Method == http.MethodGet:
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.GroupBusiness.ListFiles(principal.TenantID, topic,
|
|
strings.TrimSpace(req.URL.Query().Get("type")),
|
|
strings.TrimSpace(req.URL.Query().Get("q")),
|
|
strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"items": groupFilePayloads(items),
|
|
"next_cursor": nextCursor,
|
|
"has_more": nextCursor != "",
|
|
})
|
|
case tail == "files" && req.Method == http.MethodPost:
|
|
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
|
|
}
|
|
file, err := store.GroupBusiness.CreateFile(principal.TenantID, &types.GroupFile{
|
|
Topic: topic,
|
|
FileID: fileID,
|
|
SourceTopic: strings.TrimSpace(body.SourceTopic),
|
|
SourceSeq: body.SourceSeq,
|
|
UploaderID: principal.Uid,
|
|
FileName: strings.TrimSpace(body.FileName),
|
|
MIMEType: strings.TrimSpace(body.MIMEType),
|
|
FileType: normalizeIMFileType(body.FileType),
|
|
FileSize: body.FileSize,
|
|
Status: "active",
|
|
IdempotencyKey: idempotencyKey,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileCreate, file, principal.Uid); err != nil {
|
|
logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err)
|
|
}
|
|
writeIMSuccess(wrt, req, groupFilePayload(file))
|
|
case strings.HasPrefix(tail, "files/") && req.Method == http.MethodDelete:
|
|
groupFileID := strings.TrimSpace(strings.TrimPrefix(tail, "files/"))
|
|
if groupFileID == "" || strings.Contains(groupFileID, "/") {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
|
|
return
|
|
}
|
|
canManage := access.IsAdmin() || access.IsOwner()
|
|
file, err := store.GroupBusiness.DeleteFile(principal.TenantID, topic, groupFileID, principal.Uid, canManage, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileDelete, file, principal.Uid); err != nil {
|
|
logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err)
|
|
}
|
|
writeIMSuccess(wrt, req, groupFilePayload(file))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request) {
|
|
invitationID, tail, ok := groupInvitationResourcePath(req.URL.Path)
|
|
if !ok || tail != "accept" {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group invitation route not found", nil)
|
|
return
|
|
}
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
inv, err := store.GroupBusiness.AcceptInvitation(principal.TenantID, invitationID, principal.Uid, groupMemberPrivate("", false, false, 1, now), now)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
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"})
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, groupInvitationPayload(inv))
|
|
}
|
|
|
|
func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method == http.MethodPost {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
} else {
|
|
writeIMRelationError(wrt, req, err)
|
|
}
|
|
return
|
|
}
|
|
if settings == nil || settings.GroupStatus != types.GroupStatusActive {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
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
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, groupJoinRequestPayload(item))
|
|
return
|
|
}
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if req.Method == http.MethodGet {
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
status := strings.TrimSpace(req.URL.Query().Get("status"))
|
|
if status == "" {
|
|
status = "pending"
|
|
}
|
|
items, nextCursor, err := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, status, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
|
|
if err != nil {
|
|
if !errors.Is(err, types.ErrNotFound) {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items = nil
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupJoinRequestPayloads(items), "next_cursor": nextCursor})
|
|
}
|
|
}
|
|
|
|
func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
requestID, action, ok := groupJoinRequestActionPath(tail)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request route not found", nil)
|
|
return
|
|
}
|
|
var body imGroupJoinRequestActionRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
updated, err := store.GroupBusiness.ResolveJoinRequest(principal.TenantID, topic, requestID, principal.Uid,
|
|
action, "", body.Version, groupMemberPrivate("", false, false, 1, now), now)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
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"})
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, groupJoinRequestPayload(updated))
|
|
}
|
|
|
|
func serveIMGroupSummary(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
requests, _, _ := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, types.GroupJoinRequestStatusPending, "", 100)
|
|
announcement, _ := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"pending_join_request_count": len(requests),
|
|
"announcement": groupAnnouncementPayload(announcement),
|
|
"permissions": groupPermissions(selfSub.ModeGiven & selfSub.ModeWant),
|
|
})
|
|
}
|
|
|
|
func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "announcements/latest" && req.Method == http.MethodGet:
|
|
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
logs.Warn.Println("im group announcement latest: returning empty after store error", principal.TenantID, topic, err)
|
|
ann = nil
|
|
}
|
|
writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, ann, selfSub))
|
|
case tail == "announcements/current" && req.Method == http.MethodPut:
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupAnnouncementPutRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
content := strings.TrimSpace(body.Content)
|
|
if content == "" || len([]rune(content)) > 2000 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
announcement, err := store.GroupBusiness.PutAnnouncement(principal.TenantID, &types.GroupAnnouncement{
|
|
AnnouncementID: "ann_" + store.Store.GetUidString(),
|
|
Topic: topic,
|
|
AuthorUserID: principal.Uid,
|
|
Content: content,
|
|
Status: types.GroupAnnouncementStatusCurrent,
|
|
PublishedAt: now,
|
|
}, body.Version)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
|
return
|
|
}
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if err = publishGroupAnnouncementTextMessage(req.Context(), principal.TenantID, topic, principal.Uid, announcement); err != nil {
|
|
logs.Warn.Println("im group announcement: failed to publish text message", principal.TenantID, topic, announcement.AnnouncementID, err)
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
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:
|
|
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
logs.Warn.Println("im group announcement get: returning empty after store error", principal.TenantID, topic, err)
|
|
ann = nil
|
|
}
|
|
writeIMSuccess(wrt, req, 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}},
|
|
map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version})
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupRoleLimits(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"max_admin_count": groupMaxAdminCount, "current_admin_count": countGroupAdmins(members)})
|
|
}
|
|
|
|
func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "admins/batch" && req.Method == http.MethodPost:
|
|
var body imGroupAdminsBatchRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.UserIDs) == 0 || len(body.UserIDs) > groupMaxAdminCount {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
|
|
return
|
|
}
|
|
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if countGroupAdmins(members)+len(body.UserIDs) > groupMaxAdminCount {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "GROUP_ADMIN_LIMIT_EXCEEDED", "group admin limit exceeded", map[string]any{"max": groupMaxAdminCount, "current": countGroupAdmins(members), "requested": len(body.UserIDs)})
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.UserIDs))
|
|
for _, rawID := range body.UserIDs {
|
|
uid := types.ParseUserId(strings.TrimSpace(rawID))
|
|
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
|
|
if uid.IsZero() || uid == principal.Uid {
|
|
item["ok"] = false
|
|
item["reason"] = imReasonMalformedRequest
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
item["user_id"] = uid.UserId()
|
|
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
|
|
types.GroupMemberRoleAdmin, types.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
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, map[string]any{"succeeded": results, "admins": groupAdminItems(members), "version": body.Version + 1, "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
|
|
case strings.HasPrefix(tail, "admins/") && req.Method == http.MethodDelete:
|
|
userID := strings.TrimPrefix(tail, "admins/")
|
|
uid := types.ParseUserId(strings.TrimSpace(userID))
|
|
if uid.IsZero() || uid == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
|
|
types.GroupMemberRoleMember, types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "role": "member", "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupOwnerTransfer(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupOwnerTransferRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
target := types.ParseUserId(strings.TrimSpace(body.TargetUserID))
|
|
if target.IsZero() || target == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_user_id"})
|
|
return
|
|
}
|
|
targetSub, err := store.Subs.Get(principal.TenantID, topic, target, false)
|
|
if err != nil || targetSub == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
|
return
|
|
}
|
|
if err = store.GroupBusiness.TransferOwner(principal.TenantID, topic, principal.Uid, target,
|
|
body.Version, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId(), "version": body.Version + 1})
|
|
}
|
|
|
|
func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "mutes" && req.Method == http.MethodGet:
|
|
items, nextCursor, err := store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
|
|
case tail == "mutes" && req.Method == http.MethodPost:
|
|
var body imGroupMuteBatchRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.Members) == 0 || len(body.Members) > 100 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "members"})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
mutes := make([]types.GroupMute, 0, len(body.Members))
|
|
for _, member := range body.Members {
|
|
uid := types.ParseUserId(strings.TrimSpace(member.UserID))
|
|
if uid.IsZero() {
|
|
continue
|
|
}
|
|
var expiresAt *time.Time
|
|
if raw := strings.TrimSpace(member.ExpiresAt); raw != "" {
|
|
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
|
expiresAt = &parsed
|
|
}
|
|
}
|
|
mute, err := store.GroupBusiness.UpsertMute(principal.TenantID, &types.GroupMute{Topic: topic, UserID: uid, MutedBy: principal.Uid, MutedAt: now, ExpiresAt: expiresAt, Reason: strings.TrimSpace(body.Reason), Status: types.GroupMuteStatusActive})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
mutes = append(mutes, *mute)
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
|
|
case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete:
|
|
uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/")))
|
|
if uid.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
if err := store.GroupBusiness.DeleteMute(principal.TenantID, topic, uid, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "muted": false})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
|
|
"field": "topic",
|
|
"expected": "group topic, e.g. grp...",
|
|
"actual": topic,
|
|
})
|
|
return
|
|
}
|
|
if memberID.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, memberID)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx))
|
|
}
|
|
|
|
func serveIMMeQRCodes(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imQRCodeCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(body.Purpose)
|
|
if purpose == "" {
|
|
purpose = "add_friend"
|
|
}
|
|
if purpose != "add_friend" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
|
|
return
|
|
}
|
|
expiresIn := body.ExpiresInSeconds
|
|
if expiresIn <= 0 {
|
|
expiresIn = imQRCodeDefaultExpiresInSeconds
|
|
}
|
|
if expiresIn > imQRCodeMaxExpiresInSeconds {
|
|
expiresIn = imQRCodeMaxExpiresInSeconds
|
|
}
|
|
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
|
|
token, err := generateIMQRCodeToken(principal.TenantID, principal.Uid, purpose, expiresAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
data := map[string]any{
|
|
"qr_token": token,
|
|
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose),
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
}
|
|
if user != nil {
|
|
data["display_profile"] = userSummaryPayload(user, "")
|
|
}
|
|
writeIMSuccess(wrt, req, data)
|
|
}
|
|
|
|
func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imQRCodeResolveRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
claims, err := parseIMQRCodeToken(strings.TrimSpace(body.Token), strings.TrimSpace(body.Purpose), time.Now().UTC())
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "qr code invalid", nil)
|
|
return
|
|
}
|
|
if claims.TenantID != principal.TenantID {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
if claims.Purpose == "join_group" {
|
|
if types.GetTopicCat(claims.Topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"})
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, claims.Topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if group == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
selfSub, _ := store.Subs.Get(principal.TenantID, claims.Topic, principal.Uid, false)
|
|
status := "can_request"
|
|
if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() {
|
|
status = "joined"
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"purpose": claims.Purpose,
|
|
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
|
|
"group": groupDetailPayload(group, selfSub),
|
|
"status": status,
|
|
"source": "qr",
|
|
})
|
|
return
|
|
}
|
|
peer := types.ParseUserId(claims.UserID)
|
|
if peer.IsZero() || peer == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"})
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, peer)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, peer)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
canAdd := ctx.Relationship == types.RelationshipNone && imPrivacyAllowsAdd(privacy, "qr")
|
|
action := imRelationshipAction(ctx.Relationship)
|
|
if ctx.Relationship == types.RelationshipNone && !canAdd {
|
|
action = "none"
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"purpose": claims.Purpose,
|
|
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
|
|
"user": userSearchItemPayload(types.UserSearchItem{User: user, Relationship: ctx.Relationship, RequestID: ctx.RequestID, CanAdd: canAdd, Action: action}),
|
|
"source": "qr",
|
|
"source_topic": "",
|
|
})
|
|
}
|
|
|
|
func serveIMUserSearch(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
searchType := strings.TrimSpace(req.URL.Query().Get("type"))
|
|
query := strings.TrimSpace(req.URL.Query().Get("q"))
|
|
if searchType == "phone" {
|
|
if phone, err := normalizeIMPhone(query, "CN"); err == nil {
|
|
query = phone
|
|
}
|
|
}
|
|
if query == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"})
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 50)
|
|
items, nextCursor, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, query, searchType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
payload = append(payload, userSearchItemPayload(item))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) {
|
|
identifier, tail, ok := userResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "profile":
|
|
serveIMUserProfile(wrt, req, identifier)
|
|
case "qrcodes":
|
|
serveIMUserQRCodes(wrt, req, identifier)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, err := resolveIMUserIdentifier(principal, identifier)
|
|
if err != nil {
|
|
writeIMResolveUserError(wrt, req, err)
|
|
return
|
|
}
|
|
if userID.IsZero() || userID == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, userID)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userProfilePayload(user, ctx))
|
|
}
|
|
|
|
func serveIMUserQRCodes(wrt http.ResponseWriter, req *http.Request, identifier string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, err := resolveIMUserIdentifier(principal, identifier)
|
|
if err != nil {
|
|
writeIMResolveUserError(wrt, req, err)
|
|
return
|
|
}
|
|
if userID.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
return
|
|
}
|
|
var body imQRCodeCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(body.Purpose)
|
|
if purpose == "" {
|
|
purpose = "add_friend"
|
|
}
|
|
if purpose != "add_friend" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, userID)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
|
|
return
|
|
}
|
|
if userID != principal.Uid {
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) || !imPrivacyAllowsAdd(privacy, "qr") {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
}
|
|
expiresIn := body.ExpiresInSeconds
|
|
if expiresIn <= 0 {
|
|
expiresIn = imQRCodeDefaultExpiresInSeconds
|
|
}
|
|
if expiresIn > imQRCodeMaxExpiresInSeconds {
|
|
expiresIn = imQRCodeMaxExpiresInSeconds
|
|
}
|
|
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
|
|
token, err := generateIMQRCodeToken(principal.TenantID, userID, purpose, expiresAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"qr_token": token,
|
|
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose),
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
"display_profile": userSummaryPayload(user, ""),
|
|
})
|
|
}
|
|
|
|
func resolveIMUserIdentifier(principal *imPrincipal, identifier string) (types.Uid, error) {
|
|
identifier = strings.TrimSpace(identifier)
|
|
if identifier == "" {
|
|
return types.ZeroUid, types.ErrMalformed
|
|
}
|
|
if userID := types.ParseUserId(identifier); !userID.IsZero() {
|
|
return userID, nil
|
|
}
|
|
if store.Relations == nil {
|
|
return types.ZeroUid, types.ErrUnsupported
|
|
}
|
|
items, _, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, identifier, "exact_account", "", 2)
|
|
if err != nil {
|
|
return types.ZeroUid, err
|
|
}
|
|
for _, item := range items {
|
|
if item.User == nil {
|
|
continue
|
|
}
|
|
if userID := item.User.Uid(); !userID.IsZero() {
|
|
return userID, nil
|
|
}
|
|
}
|
|
return types.ZeroUid, types.ErrNotFound
|
|
}
|
|
|
|
func writeIMResolveUserError(wrt http.ResponseWriter, req *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, types.ErrMalformed):
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
|
case errors.Is(err, types.ErrNotFound):
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
default:
|
|
writeIMRelationError(wrt, req, err)
|
|
}
|
|
}
|
|
|
|
func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
requestID, action := friendRequestActionFromPath(req.URL.Path)
|
|
status := ""
|
|
switch action {
|
|
case "accept":
|
|
status = types.FriendRequestAccepted
|
|
case "reject":
|
|
status = types.FriendRequestRejected
|
|
default:
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "friend request action not found", nil)
|
|
return
|
|
}
|
|
updated, err := store.Relations.ResolveFriendRequest(principal.TenantID, requestID, principal.Uid, status, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if 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})
|
|
}
|
|
dispatchIMBusinessEvents(principal.TenantID)
|
|
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 {
|
|
if errors.Is(err, types.ErrUnsupported) {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
} else {
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
|
|
}
|
|
return nil, false
|
|
}
|
|
return principal, true
|
|
}
|
|
|
|
func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) {
|
|
principal, _, _, err := resolveIMHTTPBearerToken(req)
|
|
return principal, err
|
|
}
|
|
|
|
func resolveIMHTTPBearerToken(req *http.Request) (*imPrincipal, []byte, time.Duration, error) {
|
|
authz := strings.TrimSpace(req.Header.Get("Authorization"))
|
|
token, ok := strings.CutPrefix(authz, "Bearer ")
|
|
if !ok || strings.TrimSpace(token) == "" || store.Store == nil {
|
|
return nil, nil, 0, types.ErrPermissionDenied
|
|
}
|
|
handler := store.Store.GetLogicalAuthHandler("token")
|
|
if handler == nil || !handler.IsInitialized() {
|
|
return nil, nil, 0, types.ErrUnsupported
|
|
}
|
|
secret := []byte(strings.TrimSpace(token))
|
|
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil {
|
|
secret = decoded
|
|
}
|
|
rec, _, err := handler.Authenticate(auth.AuthContext{}, secret)
|
|
if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
|
|
return nil, nil, 0, types.ErrPermissionDenied
|
|
}
|
|
return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, secret, time.Duration(rec.Lifetime), nil
|
|
}
|
|
|
|
func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30000, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, token, ttl, err := resolveIMHTTPBearerToken(req)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
|
|
return
|
|
}
|
|
if ttl < time.Second {
|
|
ttl = time.Second
|
|
}
|
|
enabled, err := store.RevokeLoginToken(principal.TenantID, token, ttl)
|
|
if err != nil || !enabled {
|
|
logs.Warn.Println("im logout: token revocation failed", principal.TenantID, principal.Uid, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
|
|
}
|
|
|
|
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
|
|
user, err := store.Users.Get(tenantID, uid)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if user == nil || strings.TrimSpace(user.Phone) == "" {
|
|
return "", types.ErrNotFound
|
|
}
|
|
return strings.TrimSpace(user.Phone), nil
|
|
}
|
|
|
|
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
|
|
if imSensitiveChallengeRateLimitEnabled {
|
|
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if active != nil && now.Before(active.ResendAfter) {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
"challenge_id": active.ChallengeID,
|
|
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
|
|
})
|
|
return
|
|
}
|
|
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
|
|
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
|
|
} else if !allowed {
|
|
retryAfter := int(ttl.Seconds())
|
|
if retryAfter < 1 {
|
|
retryAfter = int(imVerificationCreateRateWindow.Seconds())
|
|
}
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
"retry_after_seconds": retryAfter,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
challengeID, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
code := "123456"
|
|
vc := &types.VerificationCode{
|
|
TenantID: principal.TenantID,
|
|
ChallengeID: challengeID,
|
|
Scene: "sensitive_action",
|
|
Action: action,
|
|
PhoneHash: phoneHash,
|
|
PhoneMask: maskIMPhone(phone),
|
|
TargetUser: principal.Uid,
|
|
CodeHash: hashIMSecret(principal.TenantID, "sensitive_action", challengeID, phoneHash, code),
|
|
State: types.VerificationStatePending,
|
|
MaxAttempts: imVerificationMaxAttempts,
|
|
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
|
|
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
|
|
}
|
|
if err := store.Verifications.Create(principal.TenantID, vc); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil {
|
|
logs.Warn.Println("im security verification delivery failed; keeping fixed test code active:", action, vc.PhoneMask, err)
|
|
}
|
|
logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"challenge_id": vc.ChallengeID,
|
|
"phone_mask": vc.PhoneMask,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
"resend_after_seconds": imVerificationResendAfterSeconds,
|
|
})
|
|
}
|
|
|
|
func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target string) (string, time.Time, error) {
|
|
if action == "" {
|
|
return "", time.Time{}, types.ErrMalformed
|
|
}
|
|
tokenID, err := randomIMToken(12)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
secret, err := randomIMToken(32)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(imActionTokenExpiresInSeconds * time.Second)
|
|
if err := store.Security.CreateActionToken(tenantID, &types.ActionToken{
|
|
TenantID: tenantID,
|
|
UserID: uid,
|
|
TokenID: tokenID,
|
|
Action: action,
|
|
Target: target,
|
|
TokenHash: hashIMActionToken(tenantID, uid, action, target, tokenID, secret),
|
|
State: 0,
|
|
ExpiresAt: expiresAt,
|
|
}); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
return tokenID + "." + secret, expiresAt, nil
|
|
}
|
|
|
|
func defaultIMActionTarget(action, fallback string) string {
|
|
switch action {
|
|
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password":
|
|
return "youth_mode"
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string {
|
|
h := sha256.New()
|
|
h.Write([]byte(fmt.Sprintf("%d", tenantID)))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(fmt.Sprintf("%d", store.DecodeUid(uid))))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(action))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(target))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(tokenID))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(secret))
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
func realNamePayload(rv *types.RealNameVerification) map[string]any {
|
|
canSubmit := rv.Status == "unverified" || rv.Status == "rejected"
|
|
data := map[string]any{
|
|
"status": rv.Status,
|
|
"submission_id": rv.SubmissionID,
|
|
"masked_name": rv.MaskedName,
|
|
"masked_id_number": rv.MaskedIDNumber,
|
|
"can_submit": canSubmit,
|
|
}
|
|
if rv.VerifiedAt != nil {
|
|
data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano)
|
|
}
|
|
if rv.ReviewReason != nil {
|
|
data["review_reason"] = *rv.ReviewReason
|
|
}
|
|
return data
|
|
}
|
|
|
|
func realNameAppealPayload(appeal *types.RealNameAppeal) map[string]any {
|
|
if appeal == nil {
|
|
return map[string]any{}
|
|
}
|
|
attachments := appeal.Attachments
|
|
if len(attachments) == 0 && len(appeal.AttachmentsJSON) > 0 {
|
|
_ = json.Unmarshal(appeal.AttachmentsJSON, &attachments)
|
|
}
|
|
return map[string]any{
|
|
"appeal_id": appeal.AppealID,
|
|
"reason": appeal.Reason,
|
|
"attachments": attachments,
|
|
"status": appeal.Status,
|
|
"created_at": appeal.CreatedAt.Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func groupInvitationPayload(inv *types.GroupInvitation) map[string]any {
|
|
if inv == nil {
|
|
return map[string]any{}
|
|
}
|
|
data := map[string]any{
|
|
"invitation_id": inv.InvitationID,
|
|
"topic": inv.Topic,
|
|
"inviter_id": inv.InviterUserID.UserId(),
|
|
"target_user_id": inv.TargetUserID.UserId(),
|
|
"status": inv.Status,
|
|
"result": inv.Result,
|
|
"expires_at": inv.ExpiresAt.Format(time.RFC3339Nano),
|
|
}
|
|
if inv.HandledAt != nil {
|
|
data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupFilePayloads(files []types.GroupFile) []map[string]any {
|
|
items := make([]map[string]any, 0, len(files))
|
|
for i := range files {
|
|
items = append(items, groupFilePayload(&files[i]))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func groupFilePayload(file *types.GroupFile) map[string]any {
|
|
if file == nil {
|
|
return map[string]any{}
|
|
}
|
|
data := map[string]any{
|
|
"group_file_id": file.GroupFileID,
|
|
"topic": file.Topic,
|
|
"file_id": file.FileID,
|
|
"file_name": file.FileName,
|
|
"mime_type": file.MIMEType,
|
|
"file_type": file.FileType,
|
|
"file_size": file.FileSize,
|
|
"uploader_id": file.UploaderID.UserId(),
|
|
"status": file.Status,
|
|
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if file.SourceTopic != "" {
|
|
data["source_topic"] = file.SourceTopic
|
|
}
|
|
if file.SourceSeq > 0 {
|
|
data["source_seq"] = file.SourceSeq
|
|
}
|
|
if file.DeletedAt != nil {
|
|
data["deleted_at"] = file.DeletedAt.UTC().Format(time.RFC3339Nano)
|
|
data["deleted_by"] = file.DeletedBy.UserId()
|
|
}
|
|
return data
|
|
}
|
|
|
|
func normalizeIMFileType(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "image", "video", "audio", "document", "file":
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
default:
|
|
return "file"
|
|
}
|
|
}
|
|
|
|
func normalizeIMUploadedFileID(raw string) string {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if cut, _, ok := strings.Cut(value, "?"); ok {
|
|
value = cut
|
|
}
|
|
if cut, _, ok := strings.Cut(value, "#"); ok {
|
|
value = cut
|
|
}
|
|
if strings.Contains(value, "/") && store.Store != nil && store.Store.GetMediaHandler() != nil {
|
|
if fid := store.Store.GetMediaHandler().GetIdFromUrl(value); !fid.IsZero() {
|
|
return fid.String()
|
|
}
|
|
}
|
|
if fid := types.ParseUid(value); !fid.IsZero() {
|
|
return fid.String()
|
|
}
|
|
if dot := strings.IndexByte(value, '.'); dot > 0 {
|
|
if fid := types.ParseUid(value[:dot]); !fid.IsZero() {
|
|
return fid.String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func linkIMGroupAvatarAttachment(tenantID types.TenantID, topic, avatarFileID string) error {
|
|
fileID := normalizeIMUploadedFileID(avatarFileID)
|
|
if fileID == "" {
|
|
return types.ErrMalformed
|
|
}
|
|
return store.Files.LinkAttachments(tenantID, topic, types.ZeroUid, []string{fileID})
|
|
}
|
|
|
|
func parseIMLimit(raw string, fallback, max int) int {
|
|
limit := fallback
|
|
if raw != "" {
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
if limit > max {
|
|
limit = max
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func parseIMTime(raw string) (time.Time, error) {
|
|
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return parsed.UTC().Round(time.Millisecond), nil
|
|
}
|
|
|
|
func parseIMDate(raw string) (time.Time, error) {
|
|
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.UTC)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func parseIMOptionalDate(raw string, fallback time.Time) (time.Time, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return time.Date(fallback.UTC().Year(), fallback.UTC().Month(), fallback.UTC().Day(), 0, 0, 0, 0, time.UTC), nil
|
|
}
|
|
return parseIMDate(raw)
|
|
}
|
|
|
|
func validIMOptionalDateQuery(wrt http.ResponseWriter, req *http.Request, field string) bool {
|
|
value := strings.TrimSpace(req.URL.Query().Get(field))
|
|
if value == "" {
|
|
return true
|
|
}
|
|
if _, err := parseIMDate(value); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validIMMonth(raw string) bool {
|
|
if _, err := time.ParseInLocation("2006-01", strings.TrimSpace(raw), time.UTC); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) {
|
|
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if key == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return "", false
|
|
}
|
|
return key, true
|
|
}
|
|
|
|
func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []string, field string) ([]types.Uid, bool) {
|
|
uids := make([]types.Uid, 0, len(values))
|
|
seen := make(map[types.Uid]bool, len(values))
|
|
for _, value := range values {
|
|
uid := types.ParseUserId(strings.TrimSpace(value))
|
|
if uid.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field})
|
|
return nil, false
|
|
}
|
|
if seen[uid] {
|
|
continue
|
|
}
|
|
seen[uid] = true
|
|
uids = append(uids, uid)
|
|
}
|
|
return uids, true
|
|
}
|
|
|
|
func uidListContains(uids []types.Uid, uid types.Uid) bool {
|
|
for _, item := range uids {
|
|
if item == uid {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ensureIMMeetingUserExists(tenantID types.TenantID, uid types.Uid) bool {
|
|
if uid.IsZero() || store.Users == nil {
|
|
return !uid.IsZero()
|
|
}
|
|
user, err := store.Users.Get(tenantID, uid)
|
|
if err != nil {
|
|
return errors.Is(err, types.ErrUnsupported)
|
|
}
|
|
return user != nil
|
|
}
|
|
|
|
func parseIMNonNegativeCursor(raw string) (int, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0, nil
|
|
}
|
|
cursor, err := strconv.Atoi(raw)
|
|
if err != nil || cursor < 0 {
|
|
return 0, errors.New("invalid cursor")
|
|
}
|
|
return cursor, nil
|
|
}
|
|
|
|
func parseIMBool(raw string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "1", "true", "yes", "y":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func conversationMatches(sub types.Subscription, query string) bool {
|
|
if strings.Contains(strings.ToLower(sub.Topic), query) {
|
|
return true
|
|
}
|
|
encoded, _ := json.Marshal(sub.GetPublic())
|
|
return strings.Contains(strings.ToLower(string(encoded)), query)
|
|
}
|
|
|
|
func conversationPayload(sub types.Subscription) map[string]any {
|
|
unread := sub.GetSeqId() - sub.ReadSeqId
|
|
if unread < 0 {
|
|
unread = 0
|
|
}
|
|
return map[string]any{
|
|
"topic": sub.Topic,
|
|
"public": sub.GetPublic(),
|
|
"private": sub.Private,
|
|
"updated_at": sub.LastModified().UTC().Format(time.RFC3339Nano),
|
|
"seq": sub.GetSeqId(),
|
|
"read": sub.ReadSeqId,
|
|
"recv": sub.RecvSeqId,
|
|
"unread": unread,
|
|
"with": sub.GetWith(),
|
|
}
|
|
}
|
|
|
|
func conversationSettingsPayload(settings *types.ConversationSettings) map[string]any {
|
|
if settings == nil {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{
|
|
"topic": settings.Topic,
|
|
"pinned": settings.Pinned,
|
|
"muted": settings.Muted,
|
|
"mute_notifications": settings.Muted,
|
|
"remark": settings.Remark,
|
|
"message_retention": settings.MessageRetention,
|
|
"version": settings.Version,
|
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"permissions": map[string]any{"can_update": true},
|
|
}
|
|
}
|
|
|
|
func topicDraftPayload(draft *types.TopicDraft) map[string]any {
|
|
if draft == nil {
|
|
return map[string]any{}
|
|
}
|
|
var entities any = []any{}
|
|
if len(draft.EntitiesJSON) > 0 {
|
|
_ = json.Unmarshal(draft.EntitiesJSON, &entities)
|
|
}
|
|
return map[string]any{
|
|
"topic": draft.Topic,
|
|
"content": draft.Content,
|
|
"entities": entities,
|
|
"version": draft.Version,
|
|
"updated_at": draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func forwardTargetPayload(sub types.Subscription) map[string]any {
|
|
topicType := ""
|
|
switch types.GetTopicCat(sub.Topic) {
|
|
case types.TopicCatGrp:
|
|
topicType = "group"
|
|
case types.TopicCatP2P:
|
|
topicType = "user"
|
|
default:
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"topic": sub.Topic,
|
|
"type": topicType,
|
|
"display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic),
|
|
"avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()),
|
|
"can_receive_forward": true,
|
|
}
|
|
}
|
|
|
|
func forwardTargetMatches(topic, displayName, query string) bool {
|
|
if query == "" {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(topic), query) || strings.Contains(strings.ToLower(displayName), query)
|
|
}
|
|
|
|
func imDisplayNameFromPublic(public any, fallback string) string {
|
|
if value, ok := imPublicString(public, "fn"); ok {
|
|
return value
|
|
}
|
|
if value, ok := imPublicString(public, "name"); ok {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func imAvatarFileIDFromPublic(public any) string {
|
|
if value, ok := imPublicString(public, "avatar_file_id"); ok {
|
|
return value
|
|
}
|
|
if value, ok := imPublicString(public, "photo"); ok {
|
|
return value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func imPublicString(public any, key string) (string, bool) {
|
|
values, ok := public.(map[string]any)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
value, ok := values[key].(string)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
return value, value != ""
|
|
}
|
|
|
|
func receiptMemberPayload(sub types.Subscription) map[string]any {
|
|
return map[string]any{
|
|
"user_id": sub.User,
|
|
"public": sub.GetPublic(),
|
|
"read": sub.ReadSeqId,
|
|
"recv": sub.RecvSeqId,
|
|
}
|
|
}
|
|
|
|
func groupListItemPayload(sub types.Subscription) map[string]any {
|
|
public := groupPublicMap(sub.GetPublic())
|
|
access := sub.ModeGiven & sub.ModeWant
|
|
return map[string]any{
|
|
"topic": sub.Topic,
|
|
"name": groupName(public, sub.Topic),
|
|
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
|
|
"member_count": sub.GetSubCnt(),
|
|
"my_role": groupMemberRole(access),
|
|
"updated_at": sub.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func mergeGroupListSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
|
|
if payload == nil {
|
|
payload = map[string]any{}
|
|
}
|
|
if settings == nil {
|
|
return payload
|
|
}
|
|
payload["topic"] = settings.Topic
|
|
payload["name"] = settings.Name
|
|
payload["avatar_file_id"] = settings.AvatarFileID
|
|
payload["member_count"] = settings.MemberCount
|
|
payload["updated_at"] = settings.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
|
return payload
|
|
}
|
|
|
|
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
public := map[string]any{}
|
|
topic := ""
|
|
memberCount := 0
|
|
ownerID := ""
|
|
if group != nil {
|
|
public = groupPublicMap(group.Public)
|
|
topic = group.Id
|
|
memberCount = group.SubCnt
|
|
if owner := types.ParseUid(group.Owner); !owner.IsZero() {
|
|
ownerID = owner.UserId()
|
|
}
|
|
}
|
|
version := intFromAny(public["version"])
|
|
if version <= 0 {
|
|
version = 1
|
|
}
|
|
access := types.ModeNone
|
|
if selfSub != nil {
|
|
access = selfSub.ModeGiven & selfSub.ModeWant
|
|
if topic == "" {
|
|
topic = selfSub.Topic
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"topic": topic,
|
|
"name": groupName(public, topic),
|
|
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
|
|
"description": stringFromAny(public["description"]),
|
|
"owner": ownerID,
|
|
"member_count": memberCount,
|
|
"version": version,
|
|
"my_role": groupMemberRole(access),
|
|
"permissions": groupPermissions(access),
|
|
}
|
|
}
|
|
|
|
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
data := groupDetailPayload(group, selfSub)
|
|
private := map[string]any{}
|
|
if selfSub != nil {
|
|
private = groupPrivateMap(selfSub.Private)
|
|
}
|
|
data["pinned"] = boolFromAny(private["pinned"])
|
|
data["mute_notifications"] = boolFromAny(private["mute_notifications"])
|
|
data["message_retention"] = stringFromAny(private["message_retention"])
|
|
if data["message_retention"] == "" {
|
|
data["message_retention"] = "forever"
|
|
}
|
|
data["governance"] = defaultGroupGovernance()
|
|
return data
|
|
}
|
|
|
|
func groupMemberSettingsPayload(sub *types.Subscription) map[string]any {
|
|
private := map[string]any{}
|
|
if sub != nil {
|
|
private = groupPrivateMap(sub.Private)
|
|
}
|
|
return map[string]any{
|
|
"pinned": boolFromAny(private["pinned"]),
|
|
"mute_notifications": boolFromAny(private["mute_notifications"]),
|
|
"version": intFromAny(private["version"]),
|
|
"updated_at": stringFromAny(private["updated_at"]),
|
|
}
|
|
}
|
|
|
|
func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any {
|
|
access := sub.ModeGiven & sub.ModeWant
|
|
user := &types.User{ObjHeader: types.ObjHeader{Id: sub.User}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()}
|
|
data := userSummaryPayload(user, "")
|
|
if private := groupPrivateMap(sub.Private); stringFromAny(private["nickname"]) != "" {
|
|
data["group_nickname"] = stringFromAny(private["nickname"])
|
|
}
|
|
data["role"] = groupMemberRole(access)
|
|
data["mentionable"] = mentionable
|
|
data["acs"] = map[string]any{
|
|
"mode": access.String(),
|
|
"want": sub.ModeWant.String(),
|
|
"given": sub.ModeGiven.String(),
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupBusinessMemberPayload(member types.GroupMember) 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, "")
|
|
if nickname := strings.TrimSpace(member.Nickname); nickname != "" {
|
|
data["group_nickname"] = nickname
|
|
}
|
|
data["role"] = groupMemberRole(access)
|
|
data["mentionable"] = access.IsJoiner() && access.IsReader()
|
|
data["acs"] = map[string]any{
|
|
"mode": access.String(),
|
|
"want": member.ModeWant.String(),
|
|
"given": member.ModeGiven.String(),
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupMemberAccess(member types.GroupMember) types.AccessMode {
|
|
access := member.ModeGiven & member.ModeWant
|
|
if access != types.ModeNone {
|
|
return access
|
|
}
|
|
switch member.Role {
|
|
case types.GroupMemberRoleOwner:
|
|
return types.ModeCFull
|
|
case types.GroupMemberRoleAdmin:
|
|
return types.ModeCPublic | types.ModeApprove
|
|
default:
|
|
return types.ModeCPublic
|
|
}
|
|
}
|
|
|
|
func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
|
|
data := groupMemberPayload(*sub, true)
|
|
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, "") {
|
|
if key == "avatar_file_id" || key == "display_name" {
|
|
data[key] = value
|
|
}
|
|
}
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyOptionalProfileFields(data, public)
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
copyOptionalProfileFields(data, map[string]any(public))
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupMemberRole(access types.AccessMode) string {
|
|
switch {
|
|
case access.IsOwner():
|
|
return "owner"
|
|
case access.IsAdmin():
|
|
return "admin"
|
|
default:
|
|
return "member"
|
|
}
|
|
}
|
|
|
|
func groupCategory(role string) string {
|
|
switch role {
|
|
case "owner":
|
|
return "owned"
|
|
case "admin":
|
|
return "managed"
|
|
default:
|
|
return "joined"
|
|
}
|
|
}
|
|
|
|
func groupPermissions(access types.AccessMode) map[string]any {
|
|
return map[string]any{
|
|
"can_edit_profile": access.IsAdmin(),
|
|
"can_invite_members": access.IsSharer() || access.IsAdmin(),
|
|
"can_manage_members": access.IsAdmin(),
|
|
"can_manage_settings": access.IsAdmin(),
|
|
"can_leave": !access.IsOwner() && access.IsJoiner(),
|
|
}
|
|
}
|
|
|
|
func defaultGroupGovernance() map[string]any {
|
|
return map[string]any{
|
|
"join_approval": false,
|
|
"mute_all": false,
|
|
"member_upload_files": true,
|
|
"member_private_chat": true,
|
|
"member_invite": true,
|
|
"version": 1,
|
|
}
|
|
}
|
|
|
|
func groupGovernanceMap(value any) map[string]any {
|
|
governance := defaultGroupGovernance()
|
|
for key, value := range groupPrivateMap(value) {
|
|
governance[key] = value
|
|
}
|
|
return governance
|
|
}
|
|
|
|
func groupGovernancePayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
aux := map[string]any{}
|
|
if group != nil {
|
|
aux = groupAuxMap(group.Aux)
|
|
}
|
|
governance := groupGovernanceMap(aux["governance"])
|
|
editable := false
|
|
if selfSub != nil {
|
|
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
|
|
}
|
|
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
|
|
governance[key+"_editable"] = editable
|
|
}
|
|
return governance
|
|
}
|
|
|
|
func groupGovernancePayloadFromSettings(settings *types.GroupSettings, selfSub *types.Subscription) map[string]any {
|
|
governance := defaultGroupGovernance()
|
|
if settings != nil {
|
|
governance["join_approval"] = settings.JoinApproval
|
|
governance["mute_all"] = settings.MuteAll
|
|
governance["member_upload_files"] = settings.MemberUploadFiles
|
|
governance["member_private_chat"] = settings.MemberPrivateChat
|
|
governance["member_invite"] = settings.MemberInvite
|
|
governance["version"] = settings.Version
|
|
}
|
|
editable := false
|
|
if selfSub != nil {
|
|
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
|
|
}
|
|
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
|
|
governance[key+"_editable"] = editable
|
|
}
|
|
return governance
|
|
}
|
|
|
|
func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
|
|
if payload == nil {
|
|
payload = map[string]any{}
|
|
}
|
|
if settings == nil {
|
|
return payload
|
|
}
|
|
payload["topic"] = settings.Topic
|
|
payload["name"] = settings.Name
|
|
payload["avatar_file_id"] = settings.AvatarFileID
|
|
payload["description"] = settings.Description
|
|
payload["owner"] = settings.OwnerUserID.UserId()
|
|
payload["member_count"] = settings.MemberCount
|
|
if _, exists := payload["version"]; !exists {
|
|
payload["version"] = settings.Version
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func groupJoinRequestPayload(req *types.GroupJoinRequest) map[string]any {
|
|
if req == nil {
|
|
return map[string]any{}
|
|
}
|
|
data := map[string]any{
|
|
"request_id": req.RequestID,
|
|
"user_id": req.ApplicantUserID.UserId(),
|
|
"inviter_id": req.InviterUserID.UserId(),
|
|
"source": req.Source,
|
|
"message": req.Message,
|
|
"status": req.Status,
|
|
"version": req.Version,
|
|
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": req.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if req.HandledBy != types.ZeroUid {
|
|
data["handled_by"] = req.HandledBy.UserId()
|
|
}
|
|
if req.HandledAt != nil {
|
|
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
if req.RejectReason != "" {
|
|
data["reject_reason"] = req.RejectReason
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupJoinRequestPayloads(items []types.GroupJoinRequest) []map[string]any {
|
|
out := make([]map[string]any, 0, len(items))
|
|
for i := range items {
|
|
out = append(out, groupJoinRequestPayload(&items[i]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any {
|
|
if ann == nil {
|
|
return map[string]any{
|
|
"announcement_id": "",
|
|
"content": "",
|
|
"author": "",
|
|
"version": 0,
|
|
"published_at": "",
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"announcement_id": ann.AnnouncementID,
|
|
"content": ann.Content,
|
|
"author": ann.AuthorUserID.UserId(),
|
|
"version": ann.Version,
|
|
"published_at": ann.PublishedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func 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 messageMutationPayload(msg *types.Message, status string) map[string]any {
|
|
data := map[string]any{
|
|
"status": status,
|
|
"topic": msg.Topic,
|
|
"seq": msg.SeqId,
|
|
"version": msg.Version,
|
|
"updated_at": msg.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if msg.RecalledAt != nil {
|
|
data["recalled_at"] = msg.RecalledAt.UTC().Format(time.RFC3339Nano)
|
|
data["recalled_by"] = msg.RecalledBy
|
|
}
|
|
if status == "edited" {
|
|
data["head"] = msg.Head
|
|
data["content"] = msg.Content
|
|
}
|
|
return data
|
|
}
|
|
|
|
func favoritePayload(item types.FavoriteItem) map[string]any {
|
|
return map[string]any{
|
|
"favorite_id": item.FavoriteID,
|
|
"type": item.ItemType,
|
|
"snapshot": item.Snapshot,
|
|
"source": map[string]any{"topic": item.SourceTopic, "seq": item.SourceSeq},
|
|
"collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano),
|
|
"availability": item.Availability,
|
|
}
|
|
}
|
|
|
|
func favoritePayloadForPrincipal(principal *imPrincipal, item types.FavoriteItem) (map[string]any, bool) {
|
|
if principal == nil {
|
|
return nil, false
|
|
}
|
|
sourceTopic, ok := normalizeIMFavoriteSourceTopic(principal.Uid, item.SourceTopic)
|
|
if !ok {
|
|
if legacyFavoriteSnapshotOnly(item) {
|
|
if item.Availability == "" {
|
|
item.Availability = "available"
|
|
}
|
|
return favoritePayload(item), true
|
|
}
|
|
return nil, false
|
|
}
|
|
if err := requireTopicReader(principal, sourceTopic); err != nil {
|
|
return nil, false
|
|
}
|
|
item.SourceTopic = sourceTopic
|
|
if store.Messages != nil {
|
|
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, item.SourceSeq)
|
|
if err == nil && msg != nil {
|
|
item.Availability = messageAvailability(*msg)
|
|
if item.Availability == "available" {
|
|
item.ItemType = imMessageType(msg)
|
|
item.Snapshot = favoriteSnapshot(msg)
|
|
item.AttachmentRefs = favoriteAttachmentRefs(msg)
|
|
}
|
|
} else if errors.Is(err, types.ErrNotFound) {
|
|
item.Availability = "removed"
|
|
}
|
|
}
|
|
return favoritePayload(item), true
|
|
}
|
|
|
|
func legacyFavoriteSnapshotOnly(item types.FavoriteItem) bool {
|
|
source := strings.TrimSpace(item.SourceTopic)
|
|
return strings.HasPrefix(source, "favSeed") && item.Snapshot != nil
|
|
}
|
|
|
|
func messageSearchPayload(msg types.Message, query string) map[string]any {
|
|
text := messageText(msg)
|
|
snippet := textSnippet(text, query)
|
|
return map[string]any{
|
|
"seq": msg.SeqId,
|
|
"from": 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 messageContextPayload(msg types.Message) map[string]any {
|
|
return map[string]any{
|
|
"seq": msg.SeqId,
|
|
"from": msg.From,
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"type": imMessageType(&msg),
|
|
"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": msg.From,
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"availability": messageAvailability(msg),
|
|
}
|
|
if content, ok := snapshot["content"].(map[string]any); ok {
|
|
for key, value := range content {
|
|
data[key] = value
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func callRecordPayloads(records []types.CallRecord, viewer types.Uid) []map[string]any {
|
|
items := make([]map[string]any, 0, len(records))
|
|
for _, record := range records {
|
|
items = append(items, callRecordPayload(record, viewer))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any {
|
|
peer := record.CalleeID
|
|
direction := "outgoing"
|
|
if record.CalleeID == viewer {
|
|
peer = record.CallerID
|
|
direction = "incoming"
|
|
}
|
|
data := map[string]any{
|
|
"call_id": record.CallID,
|
|
"topic": record.Topic,
|
|
"seq": record.SeqID,
|
|
"caller_id": record.CallerID.UserId(),
|
|
"callee_id": record.CalleeID.UserId(),
|
|
"peer_user_id": peer.UserId(),
|
|
"direction": direction,
|
|
"call_type": record.CallType,
|
|
"status": record.Status,
|
|
"result": record.Status,
|
|
"duration_ms": record.DurationMS,
|
|
"started_at": record.StartedAt.UTC().Format(time.RFC3339Nano),
|
|
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if record.EndReason != "" {
|
|
data["end_reason"] = record.EndReason
|
|
}
|
|
if record.AcceptedAt != nil {
|
|
data["accepted_at"] = record.AcceptedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
if record.FinishedAt != nil {
|
|
data["finished_at"] = record.FinishedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any {
|
|
items := make([]map[string]any, 0, len(modules))
|
|
for _, module := range modules {
|
|
if !isAllowedIMWorkbenchRoute(module.Route) {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{
|
|
"code": module.Code,
|
|
"name": module.Name,
|
|
"icon_file_id": module.IconFileID,
|
|
"route": module.Route,
|
|
"sort_order": module.SortOrder,
|
|
"unread_count": module.UnreadCount,
|
|
"enabled": module.Enabled,
|
|
"permission": module.Permission,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func populateWorkbenchModuleUnreadCounts(tenantID types.TenantID, uid types.Uid, modules []map[string]any) error {
|
|
if store.Security == nil {
|
|
return nil
|
|
}
|
|
for _, module := range modules {
|
|
code, _ := module["code"].(string)
|
|
switch code {
|
|
case "meetings":
|
|
count, err := store.Security.CountUnreadSystemNotificationsByEventPrefix(tenantID, uid, "meeting.")
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
|
|
return err
|
|
}
|
|
if err == nil {
|
|
module["unread_count"] = count
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid, modules []map[string]any) (map[string]any, map[string]any, error) {
|
|
newFriendsPending := 0
|
|
if store.Relations != nil {
|
|
count, err := store.Relations.CountPendingFriendRequests(tenantID, uid)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
|
|
return nil, nil, err
|
|
}
|
|
if err == nil {
|
|
newFriendsPending = count
|
|
}
|
|
}
|
|
|
|
systemUnread := 0
|
|
if store.Security != nil {
|
|
count, err := store.Security.CountUnreadSystemNotifications(tenantID, uid)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
|
|
return nil, nil, err
|
|
}
|
|
if err == nil {
|
|
systemUnread = count
|
|
}
|
|
}
|
|
|
|
workbenchTotalUnread := 0
|
|
for _, module := range modules {
|
|
enabled, _ := module["enabled"].(bool)
|
|
if !enabled {
|
|
continue
|
|
}
|
|
workbenchTotalUnread += intFromAny(module["unread_count"])
|
|
}
|
|
|
|
entryBadges := map[string]any{
|
|
"contacts_total_unread": newFriendsPending,
|
|
"new_friends_pending_count": newFriendsPending,
|
|
"groups_unread_count": 0,
|
|
"file_assistant_unread_count": 0,
|
|
"system_unread_count": systemUnread,
|
|
"workbench_total_unread": workbenchTotalUnread,
|
|
}
|
|
entries := map[string]any{
|
|
"new_friends": map[string]any{
|
|
"enabled": true,
|
|
"route": "/contacts/new-friends",
|
|
"unread_count": newFriendsPending,
|
|
"unread_source": "business",
|
|
},
|
|
"groups": map[string]any{
|
|
"enabled": true,
|
|
"route": "/contacts/groups",
|
|
"unread_count": 0,
|
|
"unread_source": "business",
|
|
},
|
|
"file_assistant": map[string]any{
|
|
"enabled": true,
|
|
"route": "/chat/file-assistant",
|
|
"topic": "slf",
|
|
"unread_count": 0,
|
|
"unread_source": "topic",
|
|
},
|
|
"system_messages": map[string]any{
|
|
"enabled": true,
|
|
"route": "/chat/system",
|
|
"topic": "sys",
|
|
"unread_count": systemUnread,
|
|
"unread_source": "business",
|
|
},
|
|
}
|
|
return entryBadges, entries, nil
|
|
}
|
|
|
|
func organizationDepartmentPayloads(departments []types.OrganizationDepartment) []map[string]any {
|
|
items := make([]map[string]any, 0, len(departments))
|
|
for _, department := range departments {
|
|
items = append(items, organizationDepartmentPayload(department))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func organizationDepartmentPayload(department types.OrganizationDepartment) map[string]any {
|
|
return map[string]any{
|
|
"department_id": department.DepartmentID,
|
|
"name": department.Name,
|
|
"parent_id": department.ParentID,
|
|
"child_count": department.ChildCount,
|
|
"member_count": department.MemberCount,
|
|
"version": department.Version,
|
|
}
|
|
}
|
|
|
|
func organizationMemberPayloads(members []types.OrganizationMember) []map[string]any {
|
|
items := make([]map[string]any, 0, len(members))
|
|
for _, member := range members {
|
|
items = append(items, organizationMemberPayload(member))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func organizationMemberPayload(member types.OrganizationMember) map[string]any {
|
|
payload := map[string]any{
|
|
"user_id": member.UserID.UserId(),
|
|
"department_id": member.DepartmentID,
|
|
"display_name": member.DisplayName,
|
|
"position": member.Position,
|
|
"avatar_file_id": member.AvatarFileID,
|
|
"status": member.Status,
|
|
"contactable": member.Contactable,
|
|
"version": member.Version,
|
|
}
|
|
if member.Mobile != "" {
|
|
payload["mobile"] = member.Mobile
|
|
}
|
|
if member.Email != "" {
|
|
payload["email"] = member.Email
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func organizationSearchPayloads(items []types.OrganizationSearchItem) []map[string]any {
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
switch item.Type {
|
|
case "department":
|
|
if item.Department != nil {
|
|
payload = append(payload, map[string]any{
|
|
"type": "department",
|
|
"department": organizationDepartmentPayload(*item.Department),
|
|
})
|
|
}
|
|
case "user":
|
|
if item.Member != nil {
|
|
payload = append(payload, map[string]any{
|
|
"type": "user",
|
|
"user": organizationMemberPayload(*item.Member),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func attendanceConfigPayload(config *types.AttendanceConfig) map[string]any {
|
|
return map[string]any{
|
|
"date": config.Date.Format("2006-01-02"),
|
|
"timezone": config.Timezone,
|
|
"rule": attendanceRulePayload(config.Rule),
|
|
"shifts": attendanceShiftPayloads(config.Shifts),
|
|
"allowed_actions": config.AllowedActions,
|
|
"today_record": attendanceRecordPayload(config.TodayRecord),
|
|
"permissions": map[string]any{
|
|
"can_checkin": true,
|
|
"can_view_managed": config.CanViewManaged,
|
|
"can_appeal": config.Rule.AppealAllowed,
|
|
},
|
|
}
|
|
}
|
|
|
|
func attendanceRulePayload(rule types.AttendanceRule) map[string]any {
|
|
data := map[string]any{
|
|
"rule_id": rule.RuleID,
|
|
"name": rule.Name,
|
|
"workday": rule.Workday,
|
|
"location_required": rule.LocationRequired,
|
|
"wifi_required": rule.WifiRequired,
|
|
"allow_outside_checkin": rule.AllowOutsideCheckin,
|
|
"appeal_allowed": rule.AppealAllowed,
|
|
}
|
|
if rule.AppealDeadline != nil {
|
|
data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func attendanceShiftPayloads(shifts []types.AttendanceShift) []map[string]any {
|
|
items := make([]map[string]any, 0, len(shifts))
|
|
for _, shift := range shifts {
|
|
items = append(items, map[string]any{
|
|
"shift_id": shift.ShiftID,
|
|
"name": shift.Name,
|
|
"work_start": shift.WorkStart,
|
|
"work_end": shift.WorkEnd,
|
|
"checkin_start": shift.CheckinStart,
|
|
"checkout_end": shift.CheckoutEnd,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func attendanceRecordPayloads(records []types.AttendanceRecord) []map[string]any {
|
|
items := make([]map[string]any, 0, len(records))
|
|
for i := range records {
|
|
items = append(items, attendanceRecordPayload(&records[i]))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func attendanceRecordPayload(record *types.AttendanceRecord) map[string]any {
|
|
if record == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"record_id": record.RecordID,
|
|
"user_id": record.UserID.UserId(),
|
|
"date": record.Date.Format("2006-01-02"),
|
|
"rule_id": record.RuleID,
|
|
"shift_id": record.ShiftID,
|
|
"status": record.Status,
|
|
"checkins": attendanceCheckinPayloads(record.Checkins),
|
|
"exceptions": record.Exceptions,
|
|
"appeal_status": record.AppealStatus,
|
|
"version": record.Version,
|
|
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func attendanceCheckinPayloads(checkins []types.AttendanceCheckin) []map[string]any {
|
|
items := make([]map[string]any, 0, len(checkins))
|
|
for _, checkin := range checkins {
|
|
items = append(items, attendanceCheckinPayload(checkin))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func attendanceCheckinPayload(checkin types.AttendanceCheckin) map[string]any {
|
|
return map[string]any{
|
|
"checkin_id": checkin.CheckinID,
|
|
"action": checkin.Action,
|
|
"client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano),
|
|
"server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano),
|
|
"result": checkin.Result,
|
|
"location": checkin.Location,
|
|
"wifi": checkin.Wifi,
|
|
"device": checkin.Device,
|
|
}
|
|
}
|
|
|
|
func attendanceStatsPayload(stats *types.AttendanceRecordStats) map[string]any {
|
|
if stats == nil {
|
|
return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0}
|
|
}
|
|
return map[string]any{
|
|
"expected_count": stats.ExpectedCount,
|
|
"checked_count": stats.CheckedCount,
|
|
"exception_count": stats.ExceptionCount,
|
|
"not_checked_count": stats.NotCheckedCount,
|
|
}
|
|
}
|
|
|
|
func attendanceAppealPayloads(appeals []types.AttendanceAppeal) []map[string]any {
|
|
items := make([]map[string]any, 0, len(appeals))
|
|
for i := range appeals {
|
|
items = append(items, attendanceAppealPayload(&appeals[i]))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func attendanceAppealPayload(appeal *types.AttendanceAppeal) map[string]any {
|
|
if appeal == nil {
|
|
return nil
|
|
}
|
|
data := map[string]any{
|
|
"appeal_id": appeal.AppealID,
|
|
"record_id": appeal.RecordID,
|
|
"user_id": appeal.UserID.UserId(),
|
|
"appeal_type": appeal.AppealType,
|
|
"reason": appeal.Reason,
|
|
"target_action": appeal.TargetAction,
|
|
"attachment_file_ids": appeal.AttachmentFileIDs,
|
|
"status": appeal.Status,
|
|
"version": appeal.Version,
|
|
"submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
|
"created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if appeal.TargetTime != nil {
|
|
data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
|
|
return meetingPayloadWithUserSummaries(meeting, viewer, nil)
|
|
}
|
|
|
|
func meetingPayloadForTenant(tenantID types.TenantID, meeting *types.Meeting, viewer types.Uid) map[string]any {
|
|
if meeting == nil {
|
|
return map[string]any{}
|
|
}
|
|
return meetingPayloadWithUserSummaries(meeting, viewer, meetingUserSummaries(tenantID, []types.Meeting{*meeting}))
|
|
}
|
|
|
|
func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any {
|
|
if meeting == nil {
|
|
return map[string]any{}
|
|
}
|
|
participants := meetingParticipantPayloadsWithSummaries(meeting.Participants, userSummaries)
|
|
organizer := meetingUserSummary(meeting.OrganizerID, userSummaries)
|
|
recorderID := meeting.RecorderID
|
|
if recorderID.IsZero() {
|
|
recorderID = meeting.OrganizerID
|
|
}
|
|
recorder := meetingUserSummary(recorderID, userSummaries)
|
|
startAt := meeting.StartAt.UTC()
|
|
endAt := meeting.EndAt.UTC()
|
|
data := map[string]any{
|
|
"meeting_id": meeting.MeetingID,
|
|
"title": meeting.Title,
|
|
"organizer_id": meeting.OrganizerID.UserId(),
|
|
"organizer": organizer,
|
|
"recorder_user_id": recorderID.UserId(),
|
|
"recorder": recorder,
|
|
"start_at": startAt.Format(time.RFC3339Nano),
|
|
"end_at": endAt.Format(time.RFC3339Nano),
|
|
"meeting_time": meetingTimeRange(startAt, endAt),
|
|
"time_range": meetingTimeRange(startAt, endAt),
|
|
"location": meeting.Location,
|
|
"state": meeting.State,
|
|
"reminder_minutes": meeting.ReminderMinutes,
|
|
"version": meeting.Version,
|
|
"participants": participants,
|
|
"participant_count": len(participants),
|
|
"participant_names": meetingParticipantNames(participants),
|
|
"permissions": meetingPermissionsPayload(meeting, viewer),
|
|
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if displayName, ok := organizer["display_name"]; ok {
|
|
data["organizer_display_name"] = displayName
|
|
}
|
|
if displayName, ok := recorder["display_name"]; ok {
|
|
data["recorder_display_name"] = displayName
|
|
}
|
|
if meeting.CanceledAt != nil {
|
|
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
for _, participant := range meeting.Participants {
|
|
if participant.UserID == viewer {
|
|
data["my_response"] = participant.Response
|
|
break
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func meetingParticipantPayloads(participants []types.MeetingParticipant) []map[string]any {
|
|
return meetingParticipantPayloadsWithSummaries(participants, nil)
|
|
}
|
|
|
|
func meetingParticipantPayloadsWithSummaries(participants []types.MeetingParticipant, userSummaries map[types.Uid]map[string]any) []map[string]any {
|
|
items := make([]map[string]any, 0, len(participants))
|
|
for _, participant := range participants {
|
|
item := map[string]any{
|
|
"user_id": participant.UserID.UserId(),
|
|
"response": participant.Response,
|
|
}
|
|
if summary := meetingUserSummary(participant.UserID, userSummaries); summary != nil {
|
|
if displayName, ok := summary["display_name"]; ok {
|
|
item["display_name"] = displayName
|
|
}
|
|
if avatar, ok := summary["avatar_file_id"]; ok {
|
|
item["avatar_file_id"] = avatar
|
|
}
|
|
}
|
|
if participant.ResponseAt != nil {
|
|
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items
|
|
}
|
|
|
|
func meetingUserSummaries(tenantID types.TenantID, meetings []types.Meeting) map[types.Uid]map[string]any {
|
|
if store.Users == nil {
|
|
return nil
|
|
}
|
|
seen := make(map[types.Uid]bool)
|
|
uids := make([]types.Uid, 0, len(meetings)*2)
|
|
for _, meeting := range meetings {
|
|
if !meeting.OrganizerID.IsZero() && !seen[meeting.OrganizerID] {
|
|
seen[meeting.OrganizerID] = true
|
|
uids = append(uids, meeting.OrganizerID)
|
|
}
|
|
if !meeting.RecorderID.IsZero() && !seen[meeting.RecorderID] {
|
|
seen[meeting.RecorderID] = true
|
|
uids = append(uids, meeting.RecorderID)
|
|
}
|
|
for _, participant := range meeting.Participants {
|
|
if !participant.UserID.IsZero() && !seen[participant.UserID] {
|
|
seen[participant.UserID] = true
|
|
uids = append(uids, participant.UserID)
|
|
}
|
|
}
|
|
}
|
|
if len(uids) == 0 {
|
|
return nil
|
|
}
|
|
users, err := store.Users.GetAll(tenantID, uids...)
|
|
if err != nil {
|
|
logs.Warn.Printf("im http: failed to load meeting user summaries: %v", err)
|
|
return nil
|
|
}
|
|
summaries := make(map[types.Uid]map[string]any, len(users))
|
|
for i := range users {
|
|
uid := users[i].Uid()
|
|
if uid.IsZero() {
|
|
uid = types.ParseUid(users[i].Id)
|
|
}
|
|
if !uid.IsZero() {
|
|
summaries[uid] = userSummaryPayload(&users[i], "")
|
|
}
|
|
}
|
|
return summaries
|
|
}
|
|
|
|
func meetingUserSummary(uid types.Uid, summaries map[types.Uid]map[string]any) map[string]any {
|
|
if uid.IsZero() {
|
|
return nil
|
|
}
|
|
if summary, ok := summaries[uid]; ok && summary != nil {
|
|
return summary
|
|
}
|
|
return map[string]any{
|
|
"user_id": uid.UserId(),
|
|
"display_name": uid.UserId(),
|
|
}
|
|
}
|
|
|
|
func meetingParticipantNames(participants []map[string]any) []string {
|
|
names := make([]string, 0, len(participants))
|
|
for _, participant := range participants {
|
|
if name := strings.TrimSpace(fmt.Sprint(participant["display_name"])); name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func meetingNoteListItemPayload(meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any {
|
|
data := meetingPayloadWithUserSummaries(meeting, viewer, userSummaries)
|
|
noteStatus := types.MeetingNoteStatusEmpty
|
|
if len(files) > 0 {
|
|
noteStatus = types.MeetingNoteStatusAvailable
|
|
}
|
|
data["note_status"] = noteStatus
|
|
data["file_count"] = len(files)
|
|
data["permissions"] = meetingNotePermissionsPayload(meeting, viewer)
|
|
return data
|
|
}
|
|
|
|
func meetingNotesDetailPayload(tenantID types.TenantID, meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid) map[string]any {
|
|
userSummaries := meetingUserSummaries(tenantID, []types.Meeting{*meeting})
|
|
meetingData := meetingPayloadWithUserSummaries(meeting, viewer, userSummaries)
|
|
data := make(map[string]any, len(meetingData)+4)
|
|
for k, v := range meetingData {
|
|
data[k] = v
|
|
}
|
|
data["meeting"] = meetingData
|
|
data["note_status"] = types.MeetingNoteStatusEmpty
|
|
if len(files) > 0 {
|
|
data["note_status"] = types.MeetingNoteStatusAvailable
|
|
}
|
|
data["file_count"] = len(files)
|
|
data["permissions"] = meetingNotePermissionsPayload(meeting, viewer)
|
|
fileItems := make([]map[string]any, 0, len(files))
|
|
for i := range files {
|
|
fileItems = append(fileItems, meetingNoteFilePayload(&files[i], viewer, userSummaries))
|
|
}
|
|
data["files"] = fileItems
|
|
return data
|
|
}
|
|
|
|
func meetingNoteFilePayload(file *types.MeetingNoteFile, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any {
|
|
if file == nil {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{
|
|
"note_file_id": file.NoteFileID,
|
|
"meeting_id": file.MeetingID,
|
|
"file_id": file.FileID,
|
|
"file_name": file.FileName,
|
|
"mime_type": file.MIMEType,
|
|
"file_size": file.FileSize,
|
|
"file_etag": file.FileETag,
|
|
"status": file.Status,
|
|
"preview_status": file.PreviewStatus,
|
|
"preview_mode": file.PreviewMode,
|
|
"preview_file_id": file.PreviewFileID,
|
|
"uploader_user_id": file.UploaderID.UserId(),
|
|
"uploader": meetingUserSummary(file.UploaderID, userSummaries),
|
|
"version": file.Version,
|
|
"permissions": map[string]any{
|
|
"can_preview": file.Status == types.MeetingNoteFileStatusActive,
|
|
"can_download": file.Status == types.MeetingNoteFileStatusActive,
|
|
"can_replace": file.Status == types.MeetingNoteFileStatusActive && file.UploaderID == viewer,
|
|
"can_delete": file.Status == types.MeetingNoteFileStatusActive && file.UploaderID == viewer,
|
|
},
|
|
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func meetingNotePermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
|
|
recorderID := meeting.RecorderID
|
|
if recorderID.IsZero() {
|
|
recorderID = meeting.OrganizerID
|
|
}
|
|
readable := meetingNotesReadable(meeting, viewer)
|
|
return map[string]any{
|
|
"can_view": readable,
|
|
"can_upload": readable && recorderID == viewer,
|
|
"can_replace": readable && recorderID == viewer,
|
|
"can_delete": readable && recorderID == viewer,
|
|
"can_download": readable,
|
|
}
|
|
}
|
|
|
|
func meetingNotesReadable(meeting *types.Meeting, viewer types.Uid) bool {
|
|
if meeting == nil || viewer.IsZero() || meeting.State == types.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) {
|
|
return false
|
|
}
|
|
if meeting.OrganizerID == viewer || meeting.RecorderID == viewer {
|
|
return true
|
|
}
|
|
for _, participant := range meeting.Participants {
|
|
if participant.UserID == viewer {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func meetingTimeRange(startAt, endAt time.Time) string {
|
|
if startAt.IsZero() || endAt.IsZero() {
|
|
return ""
|
|
}
|
|
if startAt.Format("2006-01-02") == endAt.Format("2006-01-02") {
|
|
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("15:04")
|
|
}
|
|
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("2006-01-02 15:04")
|
|
}
|
|
|
|
func meetingParticipantUIDs(meeting *types.Meeting) []types.Uid {
|
|
if meeting == nil {
|
|
return nil
|
|
}
|
|
items := meetingParticipantUserIDs(meeting.Participants)
|
|
if !meeting.OrganizerID.IsZero() && !uidListContains(items, meeting.OrganizerID) {
|
|
items = append(items, meeting.OrganizerID)
|
|
}
|
|
if !meeting.RecorderID.IsZero() && !uidListContains(items, meeting.RecorderID) {
|
|
items = append(items, meeting.RecorderID)
|
|
}
|
|
return items
|
|
}
|
|
|
|
func meetingParticipantUserIDs(participants []types.MeetingParticipant) []types.Uid {
|
|
items := make([]types.Uid, 0, len(participants))
|
|
for _, participant := range participants {
|
|
if !participant.UserID.IsZero() {
|
|
items = append(items, participant.UserID)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func publishIMWorkbenchAndAudit(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string,
|
|
targets []types.Uid, title, summary string, data map[string]any) {
|
|
if err := enqueueKafkaWorkbenchEvent(ctx, tenantID, actor, module, action, resourceID, targets, title, summary, data); err != nil {
|
|
logs.Warn.Printf("im http: failed to enqueue workbench event action=%s resource=%s: %v", action, resourceID, err)
|
|
}
|
|
if err := enqueueKafkaAuditEvent(ctx, tenantID, "user", actor, action, module, resourceID, "succeeded", "", data); err != nil {
|
|
logs.Warn.Printf("im http: failed to enqueue audit event action=%s resource=%s: %v", action, resourceID, err)
|
|
}
|
|
}
|
|
|
|
func meetingPermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
|
|
isOrganizer := meeting.OrganizerID == viewer
|
|
isScheduled := meeting.State == types.MeetingStateScheduled
|
|
canRespond := false
|
|
for _, participant := range meeting.Participants {
|
|
if participant.UserID == viewer && !isOrganizer {
|
|
canRespond = true
|
|
break
|
|
}
|
|
}
|
|
if time.Now().UTC().After(meeting.EndAt) {
|
|
canRespond = false
|
|
}
|
|
return map[string]any{
|
|
"can_update": isOrganizer && isScheduled,
|
|
"can_cancel": isOrganizer && isScheduled,
|
|
"can_remind": isOrganizer && isScheduled,
|
|
"can_respond": canRespond && isScheduled,
|
|
}
|
|
}
|
|
|
|
func dailyReportListPayloads(reports []types.DailyReport) []map[string]any {
|
|
items := make([]map[string]any, 0, len(reports))
|
|
for _, report := range reports {
|
|
items = append(items, dailyReportListPayload(report))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func dailyReportListPayload(report types.DailyReport) map[string]any {
|
|
data := map[string]any{
|
|
"report_id": report.ReportID,
|
|
"author_id": report.AuthorID.UserId(),
|
|
"report_date": report.ReportDate.Format("2006-01-02"),
|
|
"content_short": reportPreview(report.Content),
|
|
"status": report.Status,
|
|
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"read_state": report.ReadState,
|
|
"version": report.Version,
|
|
}
|
|
if report.SubmittedAt != nil {
|
|
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func dailyReportDetailPayload(report *types.DailyReport, viewer types.Uid) map[string]any {
|
|
data := dailyReportListPayload(*report)
|
|
data["content"] = report.Content
|
|
data["attachments"] = report.Attachments
|
|
data["updated_at"] = report.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
|
data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano)
|
|
data["permissions"] = map[string]any{
|
|
"can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft,
|
|
"can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft,
|
|
"can_view": report.AuthorID == viewer,
|
|
}
|
|
return data
|
|
}
|
|
|
|
func monthlyReportListPayloads(reports []types.MonthlyReport) []map[string]any {
|
|
items := make([]map[string]any, 0, len(reports))
|
|
for _, report := range reports {
|
|
items = append(items, monthlyReportListPayload(report))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func monthlyReportListPayload(report types.MonthlyReport) map[string]any {
|
|
action := "view"
|
|
if report.Status == types.DailyReportStatusDraft {
|
|
action = "edit"
|
|
}
|
|
data := map[string]any{
|
|
"report_id": report.ReportID,
|
|
"author_id": report.AuthorID.UserId(),
|
|
"month": report.Month,
|
|
"work_summary_short": reportPreview(report.WorkSummary),
|
|
"next_plan_short": reportPreview(report.NextPlan),
|
|
"status": report.Status,
|
|
"read_state": report.ReadState,
|
|
"version": report.Version,
|
|
"action": action,
|
|
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if report.SubmittedAt != nil {
|
|
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func reportPreview(text string) string {
|
|
text = strings.Join(strings.Fields(text), " ")
|
|
runes := []rune(text)
|
|
if len(runes) <= reportPreviewMaxRunes {
|
|
return text
|
|
}
|
|
return string(runes[:reportPreviewMaxRunes]) + "..."
|
|
}
|
|
|
|
func monthlyReportDetailPayload(report *types.MonthlyReport, viewer types.Uid) map[string]any {
|
|
data := monthlyReportListPayload(*report)
|
|
data["work_summary"] = report.WorkSummary
|
|
data["next_plan"] = report.NextPlan
|
|
data["attachments"] = report.Attachments
|
|
data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano)
|
|
data["permissions"] = map[string]any{
|
|
"can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft,
|
|
"can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft,
|
|
"can_view": report.AuthorID == viewer,
|
|
}
|
|
data["validation"] = map[string]any{
|
|
"work_summary_required": true,
|
|
"next_plan_required": true,
|
|
"max_length": 10000,
|
|
}
|
|
return data
|
|
}
|
|
|
|
func mailboxConfigPayload(config *types.MailboxConfig) map[string]any {
|
|
return map[string]any{
|
|
"enabled": config.Enabled,
|
|
"allow_anonymous": config.AllowAnonymous,
|
|
"required_fields": config.RequiredFields,
|
|
"max_length": config.MaxLength,
|
|
"attachment_policy": config.AttachmentPolicy,
|
|
"notice": map[string]any{
|
|
"title": config.Notice.Title,
|
|
"content": config.Notice.Content,
|
|
"updated_at": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"version": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
},
|
|
}
|
|
}
|
|
|
|
func defaultIMMailboxConfig() *types.MailboxConfig {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
return &types.MailboxConfig{
|
|
Enabled: false,
|
|
AllowAnonymous: false,
|
|
RequiredFields: []string{"subject", "content"},
|
|
MaxLength: 1000,
|
|
AttachmentPolicy: map[string]any{
|
|
"max_count": 0,
|
|
"enabled": false,
|
|
},
|
|
Notice: types.MailboxNotice{
|
|
Title: "总经理信箱",
|
|
Content: "当前暂未开放。",
|
|
UpdatedAt: now,
|
|
},
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
func mailboxSubmissionResultPayload(submission *types.MailboxSubmission) map[string]any {
|
|
return map[string]any{
|
|
"submission_id": submission.SubmissionID,
|
|
"status": submission.Status,
|
|
"submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func userProfilePayload10(profile *types.UserProfile) map[string]any {
|
|
data := map[string]any{
|
|
"avatar_file_id": profile.AvatarFileID,
|
|
"nickname": profile.Nickname,
|
|
"birth_date": profile.BirthDate,
|
|
"signature": profile.Signature,
|
|
"gender": profile.Gender,
|
|
"region": profile.Region,
|
|
"email": profile.Email,
|
|
"public_account": profile.PublicAccount,
|
|
"version": profile.Version,
|
|
"updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"field_rules": map[string]any{
|
|
"nickname": map[string]any{"min_length": 1, "max_length": 32},
|
|
"signature": map[string]any{"max_length": 120},
|
|
"region": map[string]any{"max_length": 128},
|
|
"birth_date": map[string]any{"format": "YYYY-MM-DD"},
|
|
"gender": []string{"male", "female", "other", ""},
|
|
"email": map[string]any{"max_length": 128},
|
|
},
|
|
}
|
|
return data
|
|
}
|
|
|
|
func validIMSecurityChallengeScene(scene string) bool {
|
|
switch strings.TrimSpace(scene) {
|
|
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password":
|
|
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,
|
|
"version": settings.Version,
|
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"editable": map[string]any{
|
|
"add_me_methods": true,
|
|
"profile_visibility": true,
|
|
"last_seen_visibility": true,
|
|
"read_receipts": true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func imPrivacyAllowsAdd(settings *types.UserPrivacySettings, source string) bool {
|
|
if settings == nil {
|
|
return false
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
|
case "phone", "qr", "group":
|
|
source = strings.ToLower(strings.TrimSpace(source))
|
|
default:
|
|
source = "account"
|
|
}
|
|
allowed, exists := settings.AddMeMethods[source]
|
|
return !exists || allowed
|
|
}
|
|
|
|
func imPrivacyAllowsProfile(settings *types.UserPrivacySettings, relationship string) bool {
|
|
if settings == nil {
|
|
return false
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(settings.ProfileVisibility)) {
|
|
case "everyone":
|
|
return true
|
|
case "friends", "":
|
|
return relationship == types.RelationshipFriend
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func imCanViewLastSeen(tenantID types.TenantID, viewer, target types.Uid) bool {
|
|
if viewer.IsZero() || target.IsZero() {
|
|
return false
|
|
}
|
|
if viewer == target {
|
|
return true
|
|
}
|
|
// Legacy deployments without the product privacy stores retain 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) map[string]any {
|
|
return map[string]any{
|
|
"platform": release.Platform,
|
|
"channel": release.Channel,
|
|
"latest_version": release.LatestVersion,
|
|
"min_supported_version": release.MinSupportedVersion,
|
|
"update_type": release.UpdateType,
|
|
"release_notes": release.ReleaseNotes,
|
|
"store_url": release.StoreURL,
|
|
"package_url": release.PackageURL,
|
|
"checksum": release.Checksum,
|
|
"remind_after_seconds": release.RemindAfterSeconds,
|
|
"published_at": release.PublishedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
|
|
data := map[string]any{
|
|
"enabled": settings.Enabled,
|
|
"policy_version": settings.PolicyVersion,
|
|
"restrictions": settings.Restrictions,
|
|
"restriction_items": youthModeRestrictionItems(settings.Restrictions),
|
|
"password_set": settings.PasswordSet,
|
|
"can_enable": settings.CanEnable,
|
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if settings.EnabledAt != nil {
|
|
data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func youthModeRestrictionItems(restrictions []string) []map[string]any {
|
|
catalog := map[string]map[string]any{
|
|
"night_quiet": {
|
|
"code": "night_quiet",
|
|
"title": "深夜免打扰",
|
|
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
|
"enforced": false,
|
|
},
|
|
"content_filter": {
|
|
"code": "content_filter",
|
|
"title": "内容过滤",
|
|
"description": "对不适合青少年的内容进行识别和限制展示。",
|
|
"enforced": false,
|
|
},
|
|
"payment_guard": {
|
|
"code": "payment_guard",
|
|
"title": "支付守护",
|
|
"description": "限制或确认支付、充值等敏感消费行为。",
|
|
"enforced": false,
|
|
},
|
|
}
|
|
items := make([]map[string]any, 0, len(restrictions))
|
|
for _, code := range restrictions {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
continue
|
|
}
|
|
if item, ok := catalog[code]; ok {
|
|
items = append(items, item)
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{
|
|
"code": code,
|
|
"title": code,
|
|
"description": "",
|
|
"enforced": false,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
|
|
return map[string]any{
|
|
"device_id": token.DeviceID,
|
|
"provider": token.Provider,
|
|
"app_version": token.AppVersion,
|
|
"platform": token.Platform,
|
|
"updated_at": token.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func riskChallengePayload(challenge *types.RiskChallenge) map[string]any {
|
|
return map[string]any{
|
|
"risk_challenge_id": challenge.ChallengeID,
|
|
"action": challenge.Action,
|
|
"level": challenge.Level,
|
|
"notice_code": challenge.NoticeCode,
|
|
"notice_params": challenge.NoticeParams,
|
|
"state": challenge.State,
|
|
"decision": challenge.Decision,
|
|
"expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": challenge.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func abuseReasonPayload(reason types.AbuseReason) map[string]any {
|
|
return map[string]any{
|
|
"target_type": reason.TargetType,
|
|
"reason_code": reason.ReasonCode,
|
|
"label": reason.Label,
|
|
"requires_description": reason.RequiresDescription,
|
|
"allows_evidence": reason.AllowsEvidence,
|
|
}
|
|
}
|
|
|
|
func defaultIMAbuseReasons(targetType string) []types.AbuseReason {
|
|
targetType = strings.ToLower(strings.TrimSpace(targetType))
|
|
if targetType == "" {
|
|
targetType = "message"
|
|
}
|
|
return []types.AbuseReason{
|
|
{TargetType: targetType, ReasonCode: "spam", Label: "垃圾广告", RequiresDescription: false, AllowsEvidence: true, SortOrder: 10},
|
|
{TargetType: targetType, ReasonCode: "fraud", Label: "欺诈风险", RequiresDescription: false, AllowsEvidence: true, SortOrder: 20},
|
|
{TargetType: targetType, ReasonCode: "harassment", Label: "骚扰辱骂", RequiresDescription: false, AllowsEvidence: true, SortOrder: 30},
|
|
{TargetType: targetType, ReasonCode: "other", Label: "其他", RequiresDescription: true, AllowsEvidence: true, SortOrder: 99},
|
|
}
|
|
}
|
|
|
|
func isAllowedIMAbuseTargetType(targetType string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(targetType)) {
|
|
case "user", "group", "message":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func abuseReportPayload(report *types.AbuseReport) map[string]any {
|
|
return map[string]any{
|
|
"report_id": report.ReportID,
|
|
"status": report.Status,
|
|
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func feedbackConfigPayload(config *types.FeedbackConfig) map[string]any {
|
|
return map[string]any{
|
|
"categories": config.Categories,
|
|
"max_length": config.MaxLength,
|
|
"attachment_policy": config.AttachmentPolicy,
|
|
"allow_contact": config.AllowContact,
|
|
"diagnostics_notice": config.DiagnosticsNotice,
|
|
"updated_at": config.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]any {
|
|
return map[string]any{
|
|
"feedback_id": feedback.FeedbackID,
|
|
"status": feedback.Status,
|
|
"created_at": feedback.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func publicUploadPayload(upload *types.PublicUpload) map[string]any {
|
|
return map[string]any{
|
|
"file_id": upload.FileID,
|
|
"purpose": upload.Purpose,
|
|
"mime_type": upload.MimeType,
|
|
"size": upload.Size,
|
|
"expires_at": upload.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func tenantApplicationPayload(application *types.TenantApplication) map[string]any {
|
|
return map[string]any{
|
|
"application_id": application.ApplicationID,
|
|
"status": application.Status,
|
|
"estimated_review_hours": application.EstimatedReviewHour,
|
|
"submitted_at": application.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]any {
|
|
return map[string]any{
|
|
"feedback_id": feedback.FeedbackID,
|
|
"status": feedback.Status,
|
|
"submitted_at": feedback.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func isAllowedIMWorkbenchRoute(route string) bool {
|
|
switch route {
|
|
case "/organization", "/attendance", "/meetings", "/meeting-notes", "/reports/daily", "/reports/monthly", "/executive-mailbox":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any {
|
|
data := map[string]any{
|
|
"topic": ctx.Topic,
|
|
"peer_user_id": ctx.PeerID.UserId(),
|
|
"relationship": ctx.Relationship,
|
|
"can_send": ctx.CanSend,
|
|
"capabilities": map[string]any{
|
|
"can_send_text": ctx.CanSend,
|
|
"can_send_media": ctx.CanSend,
|
|
"can_send_card": ctx.CanSend,
|
|
"can_call": imCanCall(ctx.CanSend),
|
|
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
|
"can_handle_request": ctx.Relationship == types.RelationshipPendingInbound,
|
|
},
|
|
}
|
|
if ctx.RequestID != "" {
|
|
data["request_id"] = ctx.RequestID
|
|
}
|
|
if ctx.Remark != "" {
|
|
data["remark"] = ctx.Remark
|
|
}
|
|
if ctx.Peer != nil {
|
|
peer := userSummaryPayload(ctx.Peer, ctx.Remark)
|
|
peer["public"] = ctx.Peer.Public
|
|
peer["trusted"] = ctx.Peer.Trusted
|
|
data["peer"] = peer
|
|
if displayName, ok := peer["display_name"]; ok {
|
|
data["display_name"] = displayName
|
|
}
|
|
if avatarFileID, ok := peer["avatar_file_id"]; ok {
|
|
data["avatar_file_id"] = avatarFileID
|
|
}
|
|
}
|
|
if _, ok := data["display_name"]; !ok {
|
|
data["display_name"] = ctx.PeerID.UserId()
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendRequestPayload(req *types.FriendRequest) map[string]any {
|
|
data := map[string]any{
|
|
"request_id": req.RequestID,
|
|
"applicant_id": req.ApplicantID.UserId(),
|
|
"target_user_id": req.TargetID.UserId(),
|
|
"message": req.Message,
|
|
"source": req.Source,
|
|
"status": req.Status,
|
|
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if topic := req.ApplicantID.P2PName(req.TargetID); topic != "" {
|
|
data["topic"] = topic
|
|
}
|
|
if req.SourceTopic != "" {
|
|
data["source_topic"] = req.SourceTopic
|
|
}
|
|
if req.HandledAt != nil {
|
|
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]any {
|
|
data := friendRequestPayload(&item.Request)
|
|
data["direction"] = item.Direction
|
|
if item.Peer != nil {
|
|
data["peer"] = userSummaryPayload(item.Peer, "")
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendListItemPayload(tenantID types.TenantID, viewer types.Uid, item types.FriendListItem) map[string]any {
|
|
online := false
|
|
onlineVisibility := "hidden"
|
|
if item.Friendship.Status == types.RelationshipFriend && imCanViewLastSeen(tenantID, viewer, item.Friendship.PeerID) {
|
|
onlineVisibility = "visible"
|
|
online = redisUserOnline(tenantID, item.Friendship.PeerID, false)
|
|
}
|
|
data := map[string]any{
|
|
"user_id": item.Friendship.PeerID.UserId(),
|
|
"relationship": item.Friendship.Status,
|
|
"remark": item.Friendship.Remark,
|
|
"topic": item.Friendship.Topic,
|
|
"online": online,
|
|
"online_visibility": onlineVisibility,
|
|
"created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": imCanCall(item.Friendship.Status == types.RelationshipFriend)},
|
|
"display_name_source": "profile",
|
|
}
|
|
if item.Friendship.Remark != "" {
|
|
data["display_name"] = item.Friendship.Remark
|
|
data["display_name_source"] = "remark"
|
|
}
|
|
if item.Peer != nil {
|
|
for key, value := range userSummaryPayload(item.Peer, item.Friendship.Remark) {
|
|
_, exists := data[key]
|
|
if !exists || key == "avatar_file_id" || (key == "display_name" && item.Friendship.Remark == "") {
|
|
data[key] = value
|
|
}
|
|
}
|
|
data["peer"] = userSummaryPayload(item.Peer, item.Friendship.Remark)
|
|
if peer, ok := data["peer"].(map[string]any); ok {
|
|
peer["online"] = online
|
|
}
|
|
}
|
|
if item.Friendship.Remark == "" && data["display_name"] == data["user_id"] {
|
|
data["display_name_source"] = "user_id"
|
|
}
|
|
data["initial"] = displayInitial(data["display_name"])
|
|
return data
|
|
}
|
|
|
|
func userSearchItemPayload(item types.UserSearchItem) map[string]any {
|
|
data := userSummaryPayload(item.User, "")
|
|
if public, ok := item.User.Public.(map[string]any); ok {
|
|
copyOptionalProfileFields(data, public)
|
|
} else if public, ok := item.User.Public.(types.KVMap); ok {
|
|
copyOptionalProfileFields(data, map[string]any(public))
|
|
}
|
|
data["relationship"] = item.Relationship
|
|
data["can_add"] = item.CanAdd
|
|
data["action"] = item.Action
|
|
if item.RequestID != "" {
|
|
data["request_id"] = item.RequestID
|
|
}
|
|
return data
|
|
}
|
|
|
|
func imRelationshipAction(relationship string) string {
|
|
switch relationship {
|
|
case types.RelationshipFriend:
|
|
return "chat"
|
|
case types.RelationshipPendingInbound, types.RelationshipPendingOutbound:
|
|
return "pending"
|
|
case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer:
|
|
return "none"
|
|
default:
|
|
return "add"
|
|
}
|
|
}
|
|
|
|
func blockListItemPayload(item types.BlockListItem) map[string]any {
|
|
data := map[string]any{
|
|
"user_id": item.Block.BlockedID.UserId(),
|
|
"blocked_at": item.Block.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": item.Block.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if item.User != nil {
|
|
data["user"] = userSummaryPayload(item.User, "")
|
|
}
|
|
return data
|
|
}
|
|
|
|
func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
|
|
data := userSummaryPayload(user, ctx.Remark)
|
|
data["relationship"] = ctx.Relationship
|
|
data["topic"] = ctx.Topic
|
|
data["capabilities"] = map[string]any{
|
|
"can_send": ctx.CanSend,
|
|
"can_call": imCanCall(ctx.CanSend),
|
|
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
|
}
|
|
if ctx.Remark != "" {
|
|
data["remark"] = ctx.Remark
|
|
}
|
|
if ctx.RequestID != "" {
|
|
data["request_id"] = ctx.RequestID
|
|
}
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyOptionalProfileFields(data, public)
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
copyOptionalProfileFields(data, map[string]any(public))
|
|
}
|
|
return data
|
|
}
|
|
|
|
func copyOptionalProfileFields(data map[string]any, public map[string]any) {
|
|
for _, key := range []string{"gender", "region", "public_account", "signature"} {
|
|
if value, ok := public[key]; ok {
|
|
data[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
func userSummaryPayload(user *types.User, remark string) map[string]any {
|
|
uid := types.ParseUid(user.Id)
|
|
userID := uid.UserId()
|
|
if uid.IsZero() {
|
|
userID = user.Id
|
|
}
|
|
data := map[string]any{"user_id": userID}
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyUserPublicSummary(data, public)
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
copyUserPublicSummary(data, map[string]any(public))
|
|
}
|
|
if remark != "" {
|
|
data["display_name"] = remark
|
|
}
|
|
if _, ok := data["display_name"]; !ok {
|
|
data["display_name"] = userID
|
|
}
|
|
return data
|
|
}
|
|
|
|
func copyUserPublicSummary(data map[string]any, public map[string]any) {
|
|
for _, key := range []string{"display_name", "fn", "name", "nickname"} {
|
|
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
|
data["display_name"] = value
|
|
break
|
|
}
|
|
}
|
|
for _, key := range []string{"avatar_file_id", "photo", "avatar"} {
|
|
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
|
data["avatar_file_id"] = value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func displayInitial(value any) string {
|
|
name := strings.TrimSpace(fmt.Sprint(value))
|
|
if name == "" {
|
|
return "#"
|
|
}
|
|
for _, r := range name {
|
|
return strings.ToUpper(string(r))
|
|
}
|
|
return "#"
|
|
}
|
|
|
|
func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonMalformedRequest, "resource not found", nil)
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
writeIMError(wrt, req, http.StatusForbidden, 20005, imReasonPermissionDenied, "permission denied", nil)
|
|
case errors.Is(err, types.ErrInvalidResponse):
|
|
writeIMError(wrt, req, http.StatusConflict, 20009, "VERSION_CONFLICT", "version conflict", nil)
|
|
case errors.Is(err, types.ErrMalformed):
|
|
writeIMError(wrt, req, http.StatusBadRequest, 20000, imReasonMalformedRequest, "malformed request", nil)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
}
|
|
}
|
|
|
|
func relationErrorReason(err error) string {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
return "NOT_FOUND"
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
return imReasonPermissionDenied
|
|
case errors.Is(err, types.ErrMalformed):
|
|
return imReasonMalformedRequest
|
|
default:
|
|
return imReasonInternal
|
|
}
|
|
}
|
|
|
|
func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, needRead bool) (*imPrincipal, *types.Subscription, bool) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return nil, nil, false
|
|
}
|
|
dispatchIMGroupEvents(principal.TenantID)
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, nil, false
|
|
}
|
|
access := types.ModeNone
|
|
if sub != nil {
|
|
access = sub.ModeGiven & sub.ModeWant
|
|
}
|
|
if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
|
|
return nil, nil, false
|
|
}
|
|
return principal, sub, true
|
|
}
|
|
|
|
func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return nil, "", false
|
|
}
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return nil, "", false
|
|
}
|
|
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"
|
|
}
|
|
}
|
|
return "text"
|
|
}
|
|
|
|
func favoriteSnapshot(msg *types.Message) map[string]any {
|
|
if msg == nil {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{
|
|
"topic": msg.Topic,
|
|
"seq": msg.SeqId,
|
|
"from": msg.From,
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"type": imMessageType(msg),
|
|
"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 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 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 writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
|
|
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
|
|
Code: 0,
|
|
Data: data,
|
|
RequestID: req.Header.Get("X-Request-ID"),
|
|
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func writeIMError(wrt http.ResponseWriter, req *http.Request, status, code int, reason, message string, details any) {
|
|
writeIMJSON(wrt, status, imHTTPResponse{
|
|
Code: code,
|
|
Reason: reason,
|
|
Message: message,
|
|
Details: details,
|
|
RequestID: req.Header.Get("X-Request-ID"),
|
|
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func writeIMJSON(wrt http.ResponseWriter, status int, resp imHTTPResponse) {
|
|
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
wrt.WriteHeader(status)
|
|
if err := json.NewEncoder(wrt).Encode(resp); err != nil {
|
|
logs.Warn.Println("im http: failed to write response", err)
|
|
}
|
|
}
|
|
|
|
func isAllowedVerificationScene(scene string) bool {
|
|
switch scene {
|
|
case "register", "login", "reset_password", "sensitive_action":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeIMPhone(phone, country string) (string, error) {
|
|
if strings.TrimSpace(country) == "" {
|
|
country = "CN"
|
|
}
|
|
number, err := phonenumbers.Parse(strings.TrimSpace(phone), country)
|
|
if err != nil || !phonenumbers.IsValidNumber(number) {
|
|
return "", types.ErrMalformed
|
|
}
|
|
return phonenumbers.Format(number, phonenumbers.E164), nil
|
|
}
|
|
|
|
func maskIMPhone(phone string) string {
|
|
if len(phone) <= 7 {
|
|
return phone
|
|
}
|
|
return phone[:4] + "****" + phone[len(phone)-3:]
|
|
}
|
|
|
|
func randomIMNumericCode(length int) (string, error) {
|
|
max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
|
|
n, err := rand.Int(rand.Reader, max)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
code := fmt.Sprintf("%0*d", length, n.Int64())
|
|
return code, nil
|
|
}
|
|
|
|
func randomIMToken(size int) (string, error) {
|
|
buf := make([]byte, size)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
func hashIMSecret(tenantID types.TenantID, parts ...string) string {
|
|
h := sha256.New()
|
|
h.Write(globals.apiKeySalt)
|
|
h.Write([]byte(fmt.Sprintf(":%d:", tenantID)))
|
|
for _, part := range parts {
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(part))
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
func generateIMQRCodeToken(tenantID types.TenantID, uid types.Uid, purpose string, expiresAt time.Time) (string, error) {
|
|
return generateIMQRCodeTokenForTopic(tenantID, uid, "", purpose, expiresAt)
|
|
}
|
|
|
|
func generateIMQRCodeTokenForTopic(tenantID types.TenantID, uid types.Uid, topic, purpose string, expiresAt time.Time) (string, error) {
|
|
nonce, err := randomIMToken(12)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
claims := imQRCodeClaims{
|
|
Version: 1,
|
|
TenantID: tenantID,
|
|
UserID: uid.UserId(),
|
|
Topic: topic,
|
|
Purpose: purpose,
|
|
Expires: expiresAt.Unix(),
|
|
Nonce: nonce,
|
|
}
|
|
payload, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
|
mac := signIMQRCodePayload(encodedPayload)
|
|
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac), nil
|
|
}
|
|
|
|
func parseIMQRCodeToken(token, expectedPurpose string, now time.Time) (*imQRCodeClaims, error) {
|
|
payload, signature, ok := strings.Cut(token, ".")
|
|
if !ok || payload == "" || signature == "" {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
got, err := base64.RawURLEncoding.DecodeString(signature)
|
|
if err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
want := signIMQRCodePayload(payload)
|
|
if !hmac.Equal(got, want) {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
var claims imQRCodeClaims
|
|
if err = json.Unmarshal(raw, &claims); err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
if claims.Version != 1 || claims.TenantID == 0 || claims.UserID == "" || claims.Purpose == "" || claims.Expires <= now.Unix() {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
if expectedPurpose != "" && claims.Purpose != expectedPurpose {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
return &claims, nil
|
|
}
|
|
|
|
func signIMQRCodePayload(payload string) []byte {
|
|
mac := hmac.New(sha256.New, globals.apiKeySalt)
|
|
mac.Write([]byte("im:qrcode:v1"))
|
|
mac.Write([]byte{0})
|
|
mac.Write([]byte(payload))
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
func verificationChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/auth/verification-codes/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func passwordResetChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/auth/password-reset/challenges/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func securityChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/security/challenges/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func riskChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/security/risk-challenges/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "confirm" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func cleanStringList(values []string, limit int) []string {
|
|
if limit <= 0 {
|
|
limit = len(values)
|
|
}
|
|
capacity := len(values)
|
|
if capacity > limit {
|
|
capacity = limit
|
|
}
|
|
cleaned := make([]string, 0, capacity)
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
cleaned = append(cleaned, value)
|
|
if len(cleaned) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func isAllowedPublicUploadPurpose(purpose string) bool {
|
|
switch strings.TrimSpace(purpose) {
|
|
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
|
|
types.PublicUploadPurposeUserAvatar,
|
|
types.PublicUploadPurposeGroupAvatar:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isAllowedPublicUploadMime(purpose, mimeType string) bool {
|
|
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
|
|
switch purpose {
|
|
case types.PublicUploadPurposeEnterpriseLicense:
|
|
return strings.HasPrefix(mimeType, "image/jpeg") ||
|
|
strings.HasPrefix(mimeType, "image/png") ||
|
|
strings.HasPrefix(mimeType, "image/webp") ||
|
|
mimeType == "application/pdf"
|
|
case types.PublicUploadPurposeTenantFeedback:
|
|
return strings.HasPrefix(mimeType, "image/jpeg") ||
|
|
strings.HasPrefix(mimeType, "image/png") ||
|
|
strings.HasPrefix(mimeType, "image/webp")
|
|
case types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar:
|
|
return strings.HasPrefix(mimeType, "image/jpeg") ||
|
|
strings.HasPrefix(mimeType, "image/png") ||
|
|
strings.HasPrefix(mimeType, "image/webp")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func detectIMUploadMime(file interface {
|
|
io.Reader
|
|
io.Seeker
|
|
}, contentType string) (string, error) {
|
|
buff := make([]byte, 512)
|
|
n, err := file.Read(buff)
|
|
if err != nil && err != io.EOF {
|
|
return "", err
|
|
}
|
|
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
|
return "", err
|
|
}
|
|
mimeType := http.DetectContentType(buff[:n])
|
|
if mimeType == "application/octet-stream" {
|
|
if parsed, _, err := mime.ParseMediaType(contentType); err == nil && parsed != "" {
|
|
mimeType = parsed
|
|
}
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(mimeType)), nil
|
|
}
|
|
|
|
func isValidCreditCode(code string) bool {
|
|
if len(code) != 18 {
|
|
return false
|
|
}
|
|
for _, r := range code {
|
|
if !(r >= '0' && r <= '9' || r >= 'A' && r <= 'Z') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isValidIMEmail(email string) bool {
|
|
if len(email) > 255 || strings.ContainsAny(email, "\r\n\t ") {
|
|
return false
|
|
}
|
|
return regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`).MatchString(email)
|
|
}
|
|
|
|
func cleanTenantCodeHint(code string) string {
|
|
code = strings.TrimSpace(code)
|
|
if len(code) > 64 {
|
|
return code[:64]
|
|
}
|
|
return code
|
|
}
|
|
|
|
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) {
|
|
targetType := strings.ToLower(strings.TrimSpace(target.Type))
|
|
snapshot := map[string]any{"type": targetType}
|
|
switch targetType {
|
|
case "user":
|
|
uid := types.ParseUserId(strings.TrimSpace(target.UserID))
|
|
if uid.IsZero() || uid == reporter {
|
|
return "", 0, "", 0, nil, types.ErrMalformed
|
|
}
|
|
if store.Users != nil {
|
|
if _, err := store.Users.Get(tenantID, uid); err != nil {
|
|
return "", 0, "", 0, nil, err
|
|
}
|
|
}
|
|
snapshot["user_id"] = uid.UserId()
|
|
return targetType, uid, "", 0, snapshot, nil
|
|
case "group":
|
|
topic := strings.TrimSpace(target.Topic)
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
return "", 0, "", 0, nil, types.ErrMalformed
|
|
}
|
|
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
|
|
return "", 0, "", 0, nil, err
|
|
}
|
|
snapshot["topic"] = topic
|
|
return targetType, 0, topic, 0, snapshot, nil
|
|
case "message":
|
|
topic := strings.TrimSpace(target.Topic)
|
|
seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64)
|
|
if topic == "" || seq <= 0 || err != nil {
|
|
return "", 0, "", 0, nil, types.ErrMalformed
|
|
}
|
|
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
|
|
return "", 0, "", 0, nil, err
|
|
}
|
|
msg, err := store.Messages.GetOne(tenantID, topic, seq)
|
|
if err != nil {
|
|
return "", 0, "", 0, nil, err
|
|
}
|
|
snapshot["topic"] = topic
|
|
snapshot["seq"] = seq
|
|
snapshot["from"] = 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)
|
|
_, err = store.Messages.Save(tenantID, &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,
|
|
}, 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)
|
|
}
|
|
}
|
|
|
|
func systemNotificationIDFromPath(path string) string {
|
|
const marker = "/v1/im/system-notifications/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
eventID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "read" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(eventID)
|
|
}
|
|
|
|
func groupInvitationResourcePath(path string) (string, string, bool) {
|
|
const marker = "/v1/im/group-invitations/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
invitationID, tail, ok := strings.Cut(rest, "/")
|
|
if !ok || strings.TrimSpace(invitationID) == "" {
|
|
return "", "", false
|
|
}
|
|
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func messageReceiptsPath(path string) (string, int64, bool) {
|
|
const marker = "/v1/im/topics/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return "", 0, false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
topic, rest, ok := strings.Cut(rest, "/messages/")
|
|
if !ok || topic == "" {
|
|
return "", 0, false
|
|
}
|
|
seqValue, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "receipts" {
|
|
return "", 0, false
|
|
}
|
|
seq, err := strconv.ParseInt(seqValue, 10, 64)
|
|
if err != nil {
|
|
return topic, 0, true
|
|
}
|
|
return topic, seq, true
|
|
}
|
|
|
|
func topicFromMessagesSearchPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/messages/search"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromMediaPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/media"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromCallsPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/calls"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromDraftPath(path string) string {
|
|
const prefix = "/v1/im/topics/"
|
|
const suffix = "/draft"
|
|
return topicFromPathWithSuffix(path, prefix, suffix)
|
|
}
|
|
|
|
func topicFromPathWithSuffix(path, prefix, suffix string) string {
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 || !strings.HasSuffix(path, suffix) {
|
|
return ""
|
|
}
|
|
rest := strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
|
|
return strings.Trim(rest, "/")
|
|
}
|
|
|
|
func messageContextPath(path string) (string, int64, bool) {
|
|
const prefix = "/v1/im/topics/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", 0, false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
|
if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" {
|
|
return "", 0, false
|
|
}
|
|
seq, err := strconv.ParseInt(parts[2], 10, 64)
|
|
if err != nil || seq <= 0 {
|
|
return "", 0, false
|
|
}
|
|
return parts[0], seq, true
|
|
}
|
|
|
|
func messageActionPath(path, action string) (string, int64, bool) {
|
|
const marker = "/v1/im/topics/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return "", 0, false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
topic, rest, ok := strings.Cut(rest, "/messages/")
|
|
if !ok || topic == "" {
|
|
return "", 0, false
|
|
}
|
|
seqValue, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != action {
|
|
return "", 0, false
|
|
}
|
|
seq, err := strconv.ParseInt(seqValue, 10, 64)
|
|
if err != nil {
|
|
return topic, 0, true
|
|
}
|
|
return topic, seq, true
|
|
}
|
|
|
|
func conversationContextTopicFromPath(path string) string {
|
|
const prefix = "/v1/im/conversations/"
|
|
const suffix = "/context"
|
|
if !strings.Contains(path, prefix) || !strings.HasSuffix(path, suffix) {
|
|
return ""
|
|
}
|
|
idx := strings.Index(path, prefix)
|
|
return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
|
|
}
|
|
|
|
func conversationResourceFromPath(path string) (string, string) {
|
|
const prefix = "/v1/im/conversations/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", ""
|
|
}
|
|
rest := strings.Trim(strings.TrimPrefix(path[idx:], prefix), "/")
|
|
topic, tail, ok := strings.Cut(rest, "/")
|
|
if !ok {
|
|
return topic, ""
|
|
}
|
|
return strings.TrimSpace(topic), strings.Trim(strings.TrimSpace(tail), "/")
|
|
}
|
|
|
|
func groupResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/groups/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
topic, tail, ok := strings.Cut(rest, "/")
|
|
if topic == "" {
|
|
return "", "", false
|
|
}
|
|
if !ok {
|
|
return topic, "", true
|
|
}
|
|
return topic, strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func userResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/users/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
identifier, tail, ok := strings.Cut(rest, "/")
|
|
identifier = strings.TrimSpace(identifier)
|
|
if !ok || identifier == "" || tail == "" {
|
|
return "", "", false
|
|
}
|
|
return identifier, strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func organizationDepartmentResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/organization/departments/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
departmentID, tail, ok := strings.Cut(rest, "/")
|
|
if !ok || departmentID == "" || tail == "" {
|
|
return "", "", false
|
|
}
|
|
return strings.TrimSpace(departmentID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func meetingResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/meetings/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
meetingID, tail, ok := strings.Cut(rest, "/")
|
|
if meetingID == "" {
|
|
return "", "", false
|
|
}
|
|
if !ok {
|
|
return strings.TrimSpace(meetingID), "", true
|
|
}
|
|
return strings.TrimSpace(meetingID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func dailyReportResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/reports/daily/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
reportID, tail, ok := strings.Cut(rest, "/")
|
|
if reportID == "" {
|
|
return "", "", false
|
|
}
|
|
if !ok {
|
|
return strings.TrimSpace(reportID), "", true
|
|
}
|
|
return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func monthlyReportResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/reports/monthly/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
reportID, tail, ok := strings.Cut(rest, "/")
|
|
if reportID == "" {
|
|
return "", "", false
|
|
}
|
|
if !ok {
|
|
return strings.TrimSpace(reportID), "", true
|
|
}
|
|
return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func groupMemberProfilePath(tail string) (types.Uid, bool) {
|
|
parts := strings.Split(strings.Trim(tail, "/"), "/")
|
|
if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" {
|
|
return types.ZeroUid, false
|
|
}
|
|
uid := types.ParseUserId(strings.TrimSpace(parts[1]))
|
|
return uid, !uid.IsZero()
|
|
}
|
|
|
|
func groupJoinRequestActionPath(tail string) (string, string, bool) {
|
|
parts := strings.Split(strings.Trim(tail, "/"), "/")
|
|
if len(parts) != 3 || parts[0] != "join-requests" || parts[1] == "" {
|
|
return "", "", false
|
|
}
|
|
switch parts[2] {
|
|
case "accept", "reject":
|
|
return strings.TrimSpace(parts[1]), parts[2], true
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func friendRequestActionFromPath(path string) (string, string) {
|
|
const prefix = "/v1/im/friend-requests/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
requestID, action, ok := strings.Cut(rest, "/")
|
|
if !ok {
|
|
return "", ""
|
|
}
|
|
return strings.TrimSpace(requestID), strings.TrimSpace(action)
|
|
}
|
|
|
|
func userIDFromTail(path, prefix string) types.Uid {
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return types.ZeroUid
|
|
}
|
|
return types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(path[idx:], prefix)))
|
|
}
|
|
|
|
func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
|
|
if types.GetTopicCat(topic) != types.TopicCatP2P || uid.IsZero() {
|
|
return types.ZeroUid
|
|
}
|
|
left, right, err := types.ParseP2P(topic)
|
|
if err != nil {
|
|
return types.ZeroUid
|
|
}
|
|
if left == uid {
|
|
return right
|
|
}
|
|
if right == uid {
|
|
return left
|
|
}
|
|
return types.ZeroUid
|
|
}
|
|
|
|
func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) {
|
|
topic = strings.TrimSpace(topic)
|
|
if len(topic) < 3 || uid.IsZero() {
|
|
return "", types.ZeroUid
|
|
}
|
|
if types.GetTopicCat(topic) == types.TopicCatMe {
|
|
peer := types.ParseUserId(topic)
|
|
if peer.IsZero() || peer == uid {
|
|
return "", types.ZeroUid
|
|
}
|
|
return uid.P2PName(peer), peer
|
|
}
|
|
peer := peerFromP2PTopic(topic, uid)
|
|
if peer.IsZero() {
|
|
return "", types.ZeroUid
|
|
}
|
|
return topic, peer
|
|
}
|
|
|
|
func 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)
|
|
}
|