6504 lines
215 KiB
Go
6504 lines
215 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"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/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"
|
|
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
|
|
imReasonAuthRequired = "AUTH_REQUIRED"
|
|
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
|
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
|
imReasonPermissionDenied = "PERMISSION_DENIED"
|
|
imReasonInternal = "INTERNAL_ERROR"
|
|
)
|
|
|
|
const (
|
|
imVerificationCodeLength = 6
|
|
imVerificationExpiresInSeconds = 300
|
|
imVerificationResendAfterSeconds = 60
|
|
imVerificationMaxAttempts = 5
|
|
imActionTokenExpiresInSeconds = 300
|
|
imIndependentPasswordMaxAttempts = 5
|
|
imIndependentPasswordLockSeconds = 300
|
|
imQRCodeDefaultExpiresInSeconds = 86400
|
|
imQRCodeMaxExpiresInSeconds = 7 * 86400
|
|
groupMaxAdminCount = 10
|
|
)
|
|
|
|
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 imTenantResolveRequest struct {
|
|
TenantCode string `json:"tenant_code"`
|
|
Platform string `json:"platform,omitempty"`
|
|
AppVersion string `json:"app_version,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 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 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 int `json:"seq,string"`
|
|
} `json:"messages"`
|
|
}
|
|
|
|
type imFavoritesBatchDeleteRequest struct {
|
|
FavoriteIDs []string `json:"favorite_ids"`
|
|
}
|
|
|
|
type imFavoritesForwardRequest struct {
|
|
FavoriteIDs []string `json:"favorite_ids"`
|
|
TargetTopics []string `json:"target_topics"`
|
|
Comment string `json:"comment,omitempty"`
|
|
}
|
|
|
|
type imFriendRequestCreateRequest struct {
|
|
TargetUserID string `json:"target_user_id"`
|
|
Message string `json:"message,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
SourceTopic string `json:"source_topic,omitempty"`
|
|
}
|
|
|
|
type imQRCodeCreateRequest struct {
|
|
Purpose string `json:"purpose"`
|
|
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
|
|
}
|
|
|
|
type imQRCodeResolveRequest struct {
|
|
Token string `json:"token"`
|
|
Purpose string `json:"purpose,omitempty"`
|
|
}
|
|
|
|
type imQRCodeClaims struct {
|
|
Version int `json:"v"`
|
|
TenantID types.TenantID `json:"tid"`
|
|
UserID string `json:"uid"`
|
|
Topic string `json:"topic,omitempty"`
|
|
Purpose string `json:"pur"`
|
|
Expires int64 `json:"exp"`
|
|
Nonce string `json:"non"`
|
|
}
|
|
|
|
type imGroupQRCodeCreateRequest struct {
|
|
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
|
|
}
|
|
|
|
type imFriendUpdateRequest struct {
|
|
Remark string `json:"remark"`
|
|
}
|
|
|
|
type imBlockBatchDeleteRequest struct {
|
|
UserIDs []string `json:"user_ids"`
|
|
}
|
|
|
|
type imGroupCreateRequest struct {
|
|
Name string `json:"name"`
|
|
MemberUserIDs []string `json:"member_user_ids,omitempty"`
|
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
|
}
|
|
|
|
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 imGroupAnnouncementPutRequest struct {
|
|
Content string `json:"content"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupGovernancePatchRequest struct {
|
|
JoinApproval *bool `json:"join_approval,omitempty"`
|
|
MuteAll *bool `json:"mute_all,omitempty"`
|
|
MemberUploadFiles *bool `json:"member_upload_files,omitempty"`
|
|
MemberPrivateChat *bool `json:"member_private_chat,omitempty"`
|
|
MemberInvite *bool `json:"member_invite,omitempty"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupAdminsBatchRequest struct {
|
|
UserIDs []string `json:"user_ids"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupOwnerTransferRequest struct {
|
|
TargetUserID string `json:"target_user_id"`
|
|
Version int `json:"version,omitempty"`
|
|
}
|
|
|
|
type imGroupMuteBatchRequest struct {
|
|
Members []struct {
|
|
UserID string `json:"user_id"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
} `json:"members"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
|
|
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
|
|
return
|
|
}
|
|
|
|
var body imTenantResolveRequest
|
|
if req.Body != nil {
|
|
dec := json.NewDecoder(req.Body)
|
|
dec.DisallowUnknownFields()
|
|
err := dec.Decode(&body)
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
err = nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
} else if err == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
|
if tenantCode == "" {
|
|
tenantCode = strings.TrimSpace(body.TenantCode)
|
|
}
|
|
if tenantCode == "" || len(tenantCode) > 64 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
|
|
"field": "tenant_code",
|
|
})
|
|
return
|
|
}
|
|
if store.Tenants == nil {
|
|
logs.Err.Println("im tenant resolve: tenant store is not initialized")
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
|
|
tenant, err := store.Tenants.GetByCode(tenantCode)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return
|
|
}
|
|
logs.Warn.Println("im tenant resolve: failed to resolve tenant", tenantCode, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if !tenant.IsActive() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return
|
|
}
|
|
|
|
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": map[string]any{
|
|
"websocket": strings.TrimRight(globals.servingAt, "/") + "/v0/channels",
|
|
"api": strings.TrimRight(globals.servingAt, "/") + "/v1/im",
|
|
"file": strings.TrimRight(globals.servingAt, "/") + "/v0/file",
|
|
},
|
|
"auth_methods": []string{"password", "sms"},
|
|
"password_policy": map[string]any{
|
|
"min_length": 8,
|
|
"max_length": 64,
|
|
},
|
|
"app_update": map[string]any{"type": "none"},
|
|
})
|
|
}
|
|
|
|
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")
|
|
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, err := randomIMNumericCode(imVerificationCodeLength)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
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.GetByCred(tenant.ID, "tel", phone); err != nil {
|
|
logs.Warn.Println("im verification create: credential lookup failed", tenant.Code, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
} 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
|
|
}
|
|
|
|
logs.Info.Println("im verification code generated:", 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, "basic")
|
|
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 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
|
|
}
|
|
if _, ok := authenticateIMHTTPBearer(wrt, req); !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" {
|
|
payload["risk_challenge_id"] = "url_" + shortTokenHash(strings.ToLower(strings.TrimSpace(body.URL)))
|
|
}
|
|
writeIMSuccess(wrt, req, payload)
|
|
}
|
|
|
|
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imSecurityChallengeRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
action := strings.TrimSpace(body.Scene)
|
|
if action == "" || len(action) > 64 || strings.TrimSpace(body.Channel) != "sms" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
phone, err := currentUserPhone(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30041, imReasonRealNameRequired, "verified phone required", nil)
|
|
return
|
|
}
|
|
createSensitiveChallenge(wrt, req, principal, action, phone)
|
|
}
|
|
|
|
func serveIMSecurityChallengeVerify(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
challengeID := securityChallengeIDFromPath(req.URL.Path)
|
|
if challengeID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
var body imVerificationVerifyRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
vc, _, ok := verifyIMCodeChallenge(wrt, req, principal.TenantID, challengeID, strings.TrimSpace(body.Code), "sensitive_action")
|
|
if !ok {
|
|
return
|
|
}
|
|
if vc.TargetUser != principal.Uid {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, vc.Action, defaultIMActionTarget(vc.Action, vc.ChallengeID))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"action_token": token,
|
|
"action": vc.Action,
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func serveIMRealName(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "unverified", "can_submit": true})
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, realNamePayload(rv))
|
|
}
|
|
|
|
func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
var body imRealNameSubmissionRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
idType := strings.TrimSpace(body.IDType)
|
|
idNumber := strings.ToUpper(strings.TrimSpace(body.IDNumber))
|
|
if !validIMRealName(name, idType, idNumber) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
current, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
|
|
if err == nil && (current.Status == "pending" || current.Status == "verified") {
|
|
writeIMSuccess(wrt, req, realNamePayload(current))
|
|
return
|
|
}
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32]
|
|
rv := &types.RealNameVerification{
|
|
TenantID: principal.TenantID,
|
|
UserID: principal.Uid,
|
|
SubmissionID: submissionID,
|
|
Status: "pending",
|
|
NameCipher: protectIMSensitive(principal.TenantID, name),
|
|
IDType: idType,
|
|
IDNumberCipher: protectIMSensitive(principal.TenantID, idNumber),
|
|
IDNumberHash: hashIMSecret(principal.TenantID, idType, idNumber, "realname-id"),
|
|
MaskedName: maskIMName(name),
|
|
MaskedIDNumber: maskIMIDNumber(idNumber),
|
|
}
|
|
if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil {
|
|
if errors.Is(err, types.ErrDuplicate) {
|
|
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending", "can_submit": false})
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"})
|
|
}
|
|
|
|
func 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
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"features": map[string]any{
|
|
"create_group": true,
|
|
"user_search": true,
|
|
"scan_qr": true,
|
|
"system_notifications": true,
|
|
"message_recall": false,
|
|
"message_edit": false,
|
|
},
|
|
"permissions": map[string]any{
|
|
"create_group": true,
|
|
"user_search": true,
|
|
"scan_qr": true,
|
|
},
|
|
"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"},
|
|
},
|
|
"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
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"layout_version": layoutVersion,
|
|
"modules": workbenchModulePayloads(modules),
|
|
"enterprise": map[string]any{
|
|
"tenant_code": strconv.FormatInt(int64(principal.TenantID), 10),
|
|
"feature_flags": map[string]any{
|
|
"organization": true,
|
|
"meetings": true,
|
|
"daily_reports": true,
|
|
"monthly_reports": true,
|
|
"executive_mailbox": true,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func serveIMOrganizationDepartments(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
parentID := strings.TrimSpace(req.URL.Query().Get("parent_id"))
|
|
if parentID == "" {
|
|
parentID = "root"
|
|
}
|
|
items, nextCursor, err := store.Organization.ListDepartments(principal.TenantID, principal.Uid, parentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"items": organizationDepartmentPayloads(items),
|
|
"next_cursor": nextCursor,
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Title string `json:"title"`
|
|
StartAt string `json:"start_at"`
|
|
EndAt string `json:"end_at"`
|
|
Location string `json:"location"`
|
|
AttendeeUserIDs []string `json:"attendee_user_ids"`
|
|
ReminderMinutes []int `json:"reminder_minutes"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
startAt, err := parseIMTime(body.StartAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"})
|
|
return
|
|
}
|
|
endAt, err := parseIMTime(body.EndAt)
|
|
if err != nil || !endAt.After(startAt) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"})
|
|
return
|
|
}
|
|
meetingID, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
attendeeIDs, ok := parseIMUserIDList(wrt, req, body.AttendeeUserIDs, "attendee_user_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{
|
|
MeetingID: "mtg_" + meetingID,
|
|
OrganizerID: principal.Uid,
|
|
Title: strings.TrimSpace(body.Title),
|
|
StartAt: startAt,
|
|
EndAt: endAt,
|
|
Location: strings.TrimSpace(body.Location),
|
|
ReminderMinutes: body.ReminderMinutes,
|
|
IdempotencyKey: idempotencyKey,
|
|
}, attendeeIDs)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) {
|
|
meetingID, tail, ok := meetingResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting route not found", nil)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "":
|
|
if req.Method == http.MethodGet {
|
|
serveIMMeetingGet(wrt, req, meetingID)
|
|
return
|
|
}
|
|
if req.Method == http.MethodPatch {
|
|
serveIMMeetingUpdate(wrt, req, meetingID)
|
|
return
|
|
}
|
|
case "cancel":
|
|
serveIMMeetingCancel(wrt, req, meetingID)
|
|
return
|
|
case "reminders":
|
|
serveIMMeetingReminders(wrt, req, meetingID)
|
|
return
|
|
case "my-response":
|
|
serveIMMeetingMyResponse(wrt, req, meetingID)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
|
|
func serveIMMeetingGet(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Title *string `json:"title"`
|
|
StartAt *string `json:"start_at"`
|
|
EndAt *string `json:"end_at"`
|
|
Location *string `json:"location"`
|
|
ReminderMinutes []int `json:"reminder_minutes"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
patch := types.MeetingPatch{Title: body.Title, Location: body.Location, ReminderMinutes: body.ReminderMinutes, Version: body.Version}
|
|
if body.StartAt != nil {
|
|
startAt, err := parseIMTime(*body.StartAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"})
|
|
return
|
|
}
|
|
patch.StartAt = &startAt
|
|
}
|
|
if body.EndAt != nil {
|
|
endAt, err := parseIMTime(*body.EndAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"})
|
|
return
|
|
}
|
|
patch.EndAt = &endAt
|
|
}
|
|
meeting, err := store.Meetings.Update(principal.TenantID, principal.Uid, meetingID, patch, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
meeting, err := store.Meetings.Cancel(principal.TenantID, principal.Uid, meetingID, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
participants, err := store.Meetings.Remind(principal.TenantID, principal.Uid, meetingID, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"meeting_id": meetingID,
|
|
"reminded": meetingParticipantPayloads(participants),
|
|
})
|
|
}
|
|
|
|
func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetingID string) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Response string `json:"response"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
meeting, err := store.Meetings.Respond(principal.TenantID, principal.Uid, meetingID, strings.TrimSpace(body.Response), body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
|
|
}
|
|
|
|
func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
reports, nextCursor, err := store.DailyReports.List(principal.TenantID, principal.Uid,
|
|
req.URL.Query().Get("scope"),
|
|
req.URL.Query().Get("from"),
|
|
req.URL.Query().Get("to"),
|
|
req.URL.Query().Get("status"),
|
|
req.URL.Query().Get("cursor"),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"items": dailyReportListPayloads(reports),
|
|
"next_cursor": nextCursor,
|
|
"permissions": map[string]any{
|
|
"can_create": true,
|
|
"makeup_allowed_days": 7,
|
|
"can_view_managed": true,
|
|
"can_remind_unsubmitted": true,
|
|
},
|
|
})
|
|
case http.MethodPost:
|
|
var body struct {
|
|
ReportDate string `json:"report_date"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
reportDate, err := parseIMDate(body.ReportDate)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
report, err := store.DailyReports.CreateDraft(principal.TenantID, principal.Uid, "dr_"+token, reportDate)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMDailyReportResource(wrt http.ResponseWriter, req *http.Request) {
|
|
reportID, tail, ok := dailyReportResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "daily report route not found", nil)
|
|
return
|
|
}
|
|
if reportID == "statistics" && tail == "" {
|
|
serveIMDailyReportStatistics(wrt, req)
|
|
return
|
|
}
|
|
if reportID == "reminders" && tail == "" {
|
|
serveIMDailyReportReminders(wrt, req)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "":
|
|
if req.Method == http.MethodGet {
|
|
serveIMDailyReportGet(wrt, req, reportID)
|
|
return
|
|
}
|
|
case "draft":
|
|
serveIMDailyReportSaveDraft(wrt, req, reportID)
|
|
return
|
|
case "submit":
|
|
serveIMDailyReportSubmit(wrt, req, reportID)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
|
|
func serveIMDailyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
report, err := store.DailyReports.Get(principal.TenantID, principal.Uid, reportID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMDailyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Content string `json:"content"`
|
|
Attachments []string `json:"attachments"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.DailyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.Content, body.Attachments, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMDailyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.DailyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMDailyReportStatistics(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
date, err := parseIMDate(req.URL.Query().Get("date"))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
stats, err := store.DailyReports.Stats(principal.TenantID, principal.Uid, date, req.URL.Query().Get("department_id"))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"expected_count": stats.ExpectedCount,
|
|
"submitted_count": stats.SubmittedCount,
|
|
"missing_count": stats.MissingCount,
|
|
"scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))},
|
|
})
|
|
}
|
|
|
|
func serveIMDailyReportReminders(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Date string `json:"date"`
|
|
UserIDs []string `json:"user_ids"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
date, err := parseIMDate(body.Date)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
userIDs, ok := parseIMUserIDList(wrt, req, body.UserIDs, "user_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
reminded, err := store.DailyReports.Remind(principal.TenantID, principal.Uid, date, userIDs, idempotencyKey)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(reminded))
|
|
for _, uid := range reminded {
|
|
items = append(items, map[string]any{"user_id": uid.UserId(), "status": "queued"})
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"date": date.Format("2006-01-02"), "items": items})
|
|
}
|
|
|
|
func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
reports, nextCursor, err := store.MonthlyReports.List(principal.TenantID, principal.Uid,
|
|
req.URL.Query().Get("scope"),
|
|
req.URL.Query().Get("from_month"),
|
|
req.URL.Query().Get("to_month"),
|
|
req.URL.Query().Get("status"),
|
|
req.URL.Query().Get("cursor"),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor})
|
|
case http.MethodPost:
|
|
var body struct {
|
|
Month string `json:"month"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if !validIMMonth(body.Month) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"})
|
|
return
|
|
}
|
|
token, err := randomIMToken(12)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.CreateDraft(principal.TenantID, principal.Uid, "mr_"+token, strings.TrimSpace(body.Month))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMMonthlyReportResource(wrt http.ResponseWriter, req *http.Request) {
|
|
reportID, tail, ok := monthlyReportResourcePath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "monthly report route not found", nil)
|
|
return
|
|
}
|
|
if reportID == "statistics" && tail == "" {
|
|
serveIMMonthlyReportStatistics(wrt, req)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "":
|
|
if req.Method == http.MethodGet {
|
|
serveIMMonthlyReportGet(wrt, req, reportID)
|
|
return
|
|
}
|
|
case "draft":
|
|
serveIMMonthlyReportSaveDraft(wrt, req, reportID)
|
|
return
|
|
case "submit":
|
|
serveIMMonthlyReportSubmit(wrt, req, reportID)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
|
|
func serveIMMonthlyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.Get(principal.TenantID, principal.Uid, reportID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMMonthlyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
WorkSummary string `json:"work_summary"`
|
|
NextPlan string `json:"next_plan"`
|
|
Attachments []string `json:"attachments"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
report, err := store.MonthlyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
|
|
}
|
|
|
|
func 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 {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
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 {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, mailboxSubmissionResultPayload(submission))
|
|
}
|
|
|
|
func serveIMMe(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
profile, err := store.Profiles.Get(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"user_id": principal.Uid.UserId(),
|
|
"profile": userProfilePayload10(profile),
|
|
"profile_version": profile.Version,
|
|
"realname": map[string]any{"status": "unknown"},
|
|
"youth_mode": map[string]any{"enabled": false},
|
|
})
|
|
}
|
|
|
|
func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
profile, err := store.Profiles.Get(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userProfilePayload10(profile))
|
|
case http.MethodPatch:
|
|
var body struct {
|
|
AvatarFileID *string `json:"avatar_file_id"`
|
|
Nickname *string `json:"nickname"`
|
|
BirthDate *string `json:"birth_date"`
|
|
Signature *string `json:"signature"`
|
|
Gender *string `json:"gender"`
|
|
Region *string `json:"region"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.BirthDate != nil && strings.TrimSpace(*body.BirthDate) != "" {
|
|
if date, err := parseIMDate(*body.BirthDate); err != nil || date.After(time.Now().UTC()) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "birth_date"})
|
|
return
|
|
}
|
|
}
|
|
profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{
|
|
AvatarFileID: body.AvatarFileID,
|
|
Nickname: body.Nickname,
|
|
BirthDate: body.BirthDate,
|
|
Signature: body.Signature,
|
|
Gender: body.Gender,
|
|
Region: body.Region,
|
|
Version: body.Version,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userProfilePayload10(profile))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
settings, err := store.Profiles.GetPrivacy(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
|
|
case http.MethodPatch:
|
|
var body struct {
|
|
AddMeMethods map[string]bool `json:"add_me_methods"`
|
|
ProfileVisibility *string `json:"profile_visibility"`
|
|
LastSeenVisibility *string `json:"last_seen_visibility"`
|
|
ReadReceipts *bool `json:"read_receipts"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{
|
|
AddMeMethods: body.AddMeMethods,
|
|
ProfileVisibility: body.ProfileVisibility,
|
|
LastSeenVisibility: body.LastSeenVisibility,
|
|
ReadReceipts: body.ReadReceipts,
|
|
Version: body.Version,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
tenant, ok := resolveIMHTTPTenant(wrt, req, "default")
|
|
if !ok {
|
|
return
|
|
}
|
|
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
|
|
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel")))
|
|
if channel == "" {
|
|
channel = "stable"
|
|
}
|
|
if platform != "ios" && platform != "android" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
|
|
return
|
|
}
|
|
release, err := store.AppReleases.Latest(tenant.ID, platform, channel)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, appReleasePayload(release))
|
|
}
|
|
|
|
func serveIMYouthMode(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
settings, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, youthModePayload(settings))
|
|
}
|
|
|
|
func serveIMYouthModeEnable(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imYouthModeEnableRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.PolicyVersion <= 0 || strings.TrimSpace(body.ActionToken) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, true, body.PolicyVersion, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, youthModePayload(settings))
|
|
}
|
|
|
|
func serveIMYouthModeDisable(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
actionToken := strings.TrimSpace(req.Header.Get("X-Action-Token"))
|
|
if actionToken == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "X-Action-Token"})
|
|
return
|
|
}
|
|
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, "close_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
current, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, false, current.PolicyVersion, time.Now().UTC().Round(time.Millisecond))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, youthModePayload(settings))
|
|
}
|
|
|
|
func serveIMDevicePushToken(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
deviceID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/v1/im/devices/"))
|
|
deviceID = strings.TrimSuffix(deviceID, "/push-token")
|
|
if deviceID == "" || strings.Contains(deviceID, "/") {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"})
|
|
return
|
|
}
|
|
var body imDevicePushTokenRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
token, err := store.DevicePushTokens.Upsert(principal.TenantID, &types.DevicePushToken{
|
|
UserID: principal.Uid,
|
|
DeviceID: deviceID,
|
|
Provider: body.Provider,
|
|
Token: body.Token,
|
|
AppVersion: body.AppVersion,
|
|
Platform: body.Platform,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, devicePushTokenPayload(token))
|
|
}
|
|
|
|
func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
|
|
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
start := 0
|
|
if cursor != "" {
|
|
parsed, err := strconv.Atoi(cursor)
|
|
if err != nil || parsed < 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
start = parsed
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
nextIndex := start
|
|
for i := start; i < len(subs); i++ {
|
|
if query != "" && !conversationMatches(subs[i], query) {
|
|
continue
|
|
}
|
|
items = append(items, conversationPayload(subs[i]))
|
|
nextIndex = i + 1
|
|
if len(items) == limit {
|
|
break
|
|
}
|
|
}
|
|
nextCursor := ""
|
|
if nextIndex < len(subs) {
|
|
nextCursor = strconv.Itoa(nextIndex)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
|
items, nextCursor, err := store.Security.ListSystemNotifications(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
eventID := systemNotificationIDFromPath(req.URL.Path)
|
|
if eventID == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "id"})
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Header.Get("Idempotency-Key")) == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
var body imSystemNotificationReadRequest
|
|
if req.Body != nil && req.ContentLength != 0 {
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
}
|
|
readAt := time.Now().UTC().Round(time.Millisecond)
|
|
if body.ReadAt != "" {
|
|
parsed, err := time.Parse(time.RFC3339Nano, body.ReadAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "read_at"})
|
|
return
|
|
}
|
|
readAt = parsed.UTC().Round(time.Millisecond)
|
|
}
|
|
if err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt); err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)})
|
|
}
|
|
|
|
func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
serveIMFavoriteList(wrt, req)
|
|
case http.MethodPost:
|
|
serveIMFavoriteBatchCreate(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Favorites.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
payload = append(payload, favoritePayload(item))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesBatchCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.Messages) == 0 || len(body.Messages) > 100 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "messages"})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
created, existing := 0, 0
|
|
failed := make([]map[string]any, 0)
|
|
for _, src := range body.Messages {
|
|
if src.Topic == "" || src.Seq <= 0 {
|
|
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest})
|
|
continue
|
|
}
|
|
msg, err := store.Messages.GetOne(principal.TenantID, src.Topic, src.Seq)
|
|
if err != nil {
|
|
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
fav := &types.FavoriteItem{
|
|
TenantID: principal.TenantID,
|
|
FavoriteID: "fav_" + store.Store.GetUidString(),
|
|
UserID: principal.Uid,
|
|
ItemType: imMessageType(msg),
|
|
SourceTopic: src.Topic,
|
|
SourceSeq: src.Seq,
|
|
Snapshot: favoriteSnapshot(msg),
|
|
AttachmentRefs: favoriteAttachmentRefs(msg),
|
|
Availability: "available",
|
|
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
|
|
CollectedAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
item, err := store.Favorites.Create(principal.TenantID, fav)
|
|
if err != nil {
|
|
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
if item.FavoriteID == fav.FavoriteID {
|
|
created++
|
|
} else {
|
|
existing++
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"created_count": created, "existing_count": existing, "failed": failed})
|
|
}
|
|
|
|
func serveIMFavoriteResource(wrt http.ResponseWriter, req *http.Request) {
|
|
const prefix = "/v1/im/favorites/"
|
|
idx := strings.Index(req.URL.Path, prefix)
|
|
if idx < 0 {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "favorite route not found", nil)
|
|
return
|
|
}
|
|
tail := strings.Trim(strings.TrimPrefix(req.URL.Path[idx:], prefix), "/")
|
|
switch {
|
|
case tail == "batch-delete":
|
|
serveIMFavoriteBatchDelete(wrt, req)
|
|
case tail == "forward":
|
|
serveIMFavoriteForward(wrt, req)
|
|
default:
|
|
serveIMFavoriteDetail(wrt, req, tail)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteID string) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, favoritePayload(*item))
|
|
case http.MethodDelete:
|
|
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"favorite_id": favoriteID, "deleted": true})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMFavoriteBatchDelete(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesBatchDeleteRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.FavoriteIDs))
|
|
for _, favoriteID := range body.FavoriteIDs {
|
|
favoriteID = strings.TrimSpace(favoriteID)
|
|
item := map[string]any{"favorite_id": favoriteID}
|
|
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
|
|
item["ok"] = false
|
|
item["reason"] = relationErrorReason(err)
|
|
} else {
|
|
item["ok"] = true
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": results})
|
|
}
|
|
|
|
func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imFavoritesForwardRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.FavoriteIDs) == 0 || len(body.FavoriteIDs) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs))
|
|
comment := strings.TrimSpace(body.Comment)
|
|
for _, target := range body.TargetTopics {
|
|
target = strings.TrimSpace(target)
|
|
if target == "" {
|
|
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": imReasonMalformedRequest})
|
|
continue
|
|
}
|
|
if err := requireTopicWriter(principal, target); err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
for _, favoriteID := range body.FavoriteIDs {
|
|
favoriteID = strings.TrimSpace(favoriteID)
|
|
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
msg := &types.Message{
|
|
TenantID: principal.TenantID,
|
|
Topic: target,
|
|
From: principal.Uid.String(),
|
|
Head: types.KVMap{
|
|
"type": item.ItemType,
|
|
"x-im-type": item.ItemType,
|
|
"x-im-forwarded": true,
|
|
},
|
|
Content: favoriteForwardContent(item, comment),
|
|
}
|
|
saved, err := store.Messages.Save(principal.TenantID, msg, nil, true)
|
|
if err != nil {
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
|
|
continue
|
|
}
|
|
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId})
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": results})
|
|
}
|
|
|
|
func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(req.URL.Path, "/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 serveIMCalls(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, "", strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMTopicCalls(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromCallsPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessagesSearchPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, messageSearchPayload(msg, strings.TrimSpace(req.URL.Query().Get("q"))))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
topic, seq, ok := messageContextPath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
|
|
return
|
|
}
|
|
principal, _, ok := authenticateTopicReader(wrt, req, topic)
|
|
if !ok {
|
|
return
|
|
}
|
|
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
|
|
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
|
|
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - before, Before: seq + after + 1, Limit: before + after + 1})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, messageContextPayload(msg))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "anchor_seq": seq, "items": items})
|
|
}
|
|
|
|
func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMediaPath(req.URL.Path))
|
|
if !ok {
|
|
return
|
|
}
|
|
mediaType := strings.TrimSpace(req.URL.Query().Get("type"))
|
|
if mediaType == "" {
|
|
mediaType = "image"
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), mediaType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
items = append(items, mediaMessagePayload(msg))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageReceiptsPath(req.URL.Path)
|
|
if !ok {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message receipt route not found", nil)
|
|
return
|
|
}
|
|
if seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "seq"})
|
|
return
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 100, 200)
|
|
cursor, _ := strconv.Atoi(req.URL.Query().Get("cursor"))
|
|
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: limit + 1})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
readItems := make([]map[string]any, 0, limit)
|
|
deliveredItems := make([]map[string]any, 0, limit)
|
|
nextCursor := 0
|
|
for i, member := range members {
|
|
if i < cursor {
|
|
continue
|
|
}
|
|
if len(readItems) >= limit && len(deliveredItems) >= limit {
|
|
nextCursor = i
|
|
break
|
|
}
|
|
item := receiptMemberPayload(member)
|
|
if member.ReadSeqId >= seq && len(readItems) < limit {
|
|
readItems = append(readItems, item)
|
|
}
|
|
if member.RecvSeqId >= seq && len(deliveredItems) < limit {
|
|
deliveredItems = append(deliveredItems, item)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"seq": seq,
|
|
"read": readItems,
|
|
"delivered": deliveredItems,
|
|
"next_cursor": nextCursor,
|
|
})
|
|
}
|
|
|
|
func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageActionPath(req.URL.Path, "recall")
|
|
if !ok || seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
var body imMessageRecallRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 {
|
|
if body.Version <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
|
|
}
|
|
return
|
|
}
|
|
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, true)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !authorized {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required", nil)
|
|
return
|
|
}
|
|
if time.Since(msg.CreatedAt) > 120*time.Second {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired", nil)
|
|
return
|
|
}
|
|
recalledAt := time.Now().UTC().Round(time.Millisecond)
|
|
updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, body.Version, recalledAt)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, messageMutationPayload(updated, "recalled"))
|
|
}
|
|
|
|
func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
topic, seq, ok := messageActionPath(req.URL.Path, "revisions")
|
|
if !ok || seq <= 0 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
var body imMessageRevisionRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if body.Version <= 0 || body.Content == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version/content"})
|
|
return
|
|
}
|
|
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, false)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
if !authorized {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit permission required", nil)
|
|
return
|
|
}
|
|
if time.Since(msg.CreatedAt) > 300*time.Second {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit window expired", nil)
|
|
return
|
|
}
|
|
if !isIMEditableTextMessage(msg) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "only text messages can be edited", nil)
|
|
return
|
|
}
|
|
head := body.Head
|
|
if head == nil {
|
|
head = types.KVMap{}
|
|
}
|
|
head["type"] = "text"
|
|
if body.ClientMid != "" {
|
|
head["x-im-client-mid"] = body.ClientMid
|
|
}
|
|
editedAt := time.Now().UTC().Round(time.Millisecond)
|
|
updated, err := store.Messages.Edit(principal.TenantID, topic, seq, principal.Uid, body.Version, head, body.Content, body.Reason, editedAt)
|
|
if err != nil {
|
|
writeIMMessageMutationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited"))
|
|
}
|
|
|
|
func serveIMConversationContext(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 := conversationContextTopicFromPath(req.URL.Path)
|
|
peer := peerFromP2PTopic(topic, principal.Uid)
|
|
if topic == "" || peer.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
|
}
|
|
|
|
func 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
|
|
}
|
|
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if idempotencyKey == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
requestID := "frq_" + store.Store.GetUidString()
|
|
created, err := store.Relations.CreateFriendRequest(principal.TenantID, &types.FriendRequest{
|
|
TenantID: principal.TenantID,
|
|
RequestID: requestID,
|
|
ApplicantID: principal.Uid,
|
|
TargetID: target,
|
|
Message: message,
|
|
Source: strings.TrimSpace(body.Source),
|
|
SourceTopic: strings.TrimSpace(body.SourceTopic),
|
|
Status: types.FriendRequestPending,
|
|
IdempotencyKey: idempotencyKey,
|
|
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, friendRequestPayload(created))
|
|
}
|
|
|
|
func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid,
|
|
strings.TrimSpace(req.URL.Query().Get("query")),
|
|
strings.TrimSpace(req.URL.Query().Get("initial")),
|
|
strings.TrimSpace(req.URL.Query().Get("cursor")),
|
|
limit)
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
payload = append(payload, friendListItemPayload(item))
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
|
|
}
|
|
|
|
func serveIMGroups(wrt http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
serveIMGroupList(wrt, req)
|
|
case http.MethodPost:
|
|
serveIMGroupCreate(wrt, req)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
category := strings.TrimSpace(req.URL.Query().Get("category"))
|
|
switch category {
|
|
case "", "owned", "managed", "joined":
|
|
default:
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "category"})
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
|
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
query := strings.TrimSpace(req.URL.Query().Get("query"))
|
|
subs, err := store.Users.GetSubs(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
seen := 0
|
|
nextCursor := ""
|
|
for _, sub := range subs {
|
|
if types.GetTopicCat(sub.Topic) != types.TopicCatGrp {
|
|
continue
|
|
}
|
|
item := groupListItemPayload(sub)
|
|
if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category {
|
|
continue
|
|
}
|
|
if query != "" && !groupListItemMatches(item, query) {
|
|
continue
|
|
}
|
|
if seen < cursor {
|
|
seen++
|
|
continue
|
|
}
|
|
if len(items) == limit {
|
|
nextCursor = strconv.Itoa(seen)
|
|
break
|
|
}
|
|
items = append(items, item)
|
|
seen++
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
|
|
}
|
|
|
|
func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imGroupCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if len([]rune(name)) < 1 || len([]rune(name)) > 32 || len(body.MemberUserIDs) > 99 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
topicName := genTopicName()
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
public := map[string]any{"name": name, "version": 1}
|
|
if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" {
|
|
public["avatar_file_id"] = avatar
|
|
}
|
|
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)
|
|
if err := store.Topics.Create(principal.TenantID, topic, principal.Uid, groupMemberPrivate("", false, false, 1, now)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
var members []*types.Subscription
|
|
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
|
|
members = append(members, &types.Subscription{
|
|
TenantID: principal.TenantID,
|
|
User: uid.String(),
|
|
Topic: topicName,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
Private: groupMemberPrivate("", false, false, 1, now),
|
|
})
|
|
}
|
|
if len(members) > 0 {
|
|
if err := store.Subs.Create(principal.TenantID, members...); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
_ = store.Topics.UpdateSubCnt(principal.TenantID, topicName)
|
|
}
|
|
created, err := store.Topics.Get(principal.TenantID, topicName)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
selfSub, _ := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false)
|
|
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 == "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 == "" || !strings.Contains(tail, "/") {
|
|
serveIMGroupDetail(wrt, req, topic)
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
|
}
|
|
|
|
func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return
|
|
}
|
|
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
selfAccess := types.ModeNone
|
|
if selfSub != nil {
|
|
selfAccess = selfSub.ModeGiven & selfSub.ModeWant
|
|
}
|
|
if selfSub == nil || !selfAccess.IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
|
|
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
|
|
return
|
|
}
|
|
query := strings.TrimSpace(req.URL.Query().Get("query"))
|
|
mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable"))
|
|
roleFilter := strings.TrimSpace(req.URL.Query().Get("role"))
|
|
eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role"))
|
|
muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible"))
|
|
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, limit)
|
|
seen := 0
|
|
nextCursor := ""
|
|
for _, member := range members {
|
|
access := member.ModeGiven & member.ModeWant
|
|
mentionable := access.IsJoiner() && access.IsReader()
|
|
role := groupMemberRole(access)
|
|
if roleFilter != "" && role != roleFilter {
|
|
continue
|
|
}
|
|
if mentionableOnly && !mentionable {
|
|
continue
|
|
}
|
|
item := groupMemberPayload(member, mentionable)
|
|
if eligibleRole != "" {
|
|
eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole)
|
|
item["eligible"] = eligible
|
|
if reason != "" {
|
|
item["ineligible_reason"] = reason
|
|
}
|
|
}
|
|
if muteEligible {
|
|
eligible, reason := groupMuteEligibility(selfAccess, access)
|
|
item["eligible"] = eligible
|
|
if reason != "" {
|
|
item["ineligible_reason"] = reason
|
|
}
|
|
}
|
|
if query != "" && !groupMemberMatches(item, query) {
|
|
continue
|
|
}
|
|
if seen < cursor {
|
|
seen++
|
|
continue
|
|
}
|
|
if len(items) == limit {
|
|
nextCursor = strconv.Itoa(seen)
|
|
break
|
|
}
|
|
items = append(items, item)
|
|
seen++
|
|
}
|
|
permissions := map[string]any{
|
|
"can_mention_all": selfAccess.IsAdmin(),
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"items": items,
|
|
"next_cursor": nextCursor,
|
|
"permissions": permissions,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
writeIMSuccess(wrt, req, groupDetailPayload(group, selfSub))
|
|
case http.MethodPatch:
|
|
access := selfSub.ModeGiven & selfSub.ModeWant
|
|
if !access.IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupPatchRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if group == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
public := groupPublicMap(group.Public)
|
|
currentVersion := intFromAny(public["version"])
|
|
if currentVersion <= 0 {
|
|
currentVersion = 1
|
|
}
|
|
if body.Version > 0 && body.Version != currentVersion {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
|
|
return
|
|
}
|
|
if body.Name != nil {
|
|
name := strings.TrimSpace(*body.Name)
|
|
if len([]rune(name)) < 1 || len([]rune(name)) > 32 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "name"})
|
|
return
|
|
}
|
|
public["name"] = name
|
|
}
|
|
if body.AvatarFileID != nil {
|
|
public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID)
|
|
}
|
|
if body.Description != nil {
|
|
description := strings.TrimSpace(*body.Description)
|
|
if len([]rune(description)) > 200 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
|
|
return
|
|
}
|
|
public["description"] = description
|
|
}
|
|
public["version"] = currentVersion + 1
|
|
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": public}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
updated, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, groupDetailPayload(updated, selfSub))
|
|
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
|
|
}
|
|
writeIMSuccess(wrt, req, groupSettingsPayload(group, selfSub))
|
|
}
|
|
|
|
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
|
|
}
|
|
selfSub.Private = private
|
|
writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub))
|
|
}
|
|
|
|
func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPut {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imGroupMemberProfileUpdateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
nickname := strings.TrimSpace(body.Nickname)
|
|
if len([]rune(nickname)) > 32 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"})
|
|
return
|
|
}
|
|
private := groupPrivateMap(selfSub.Private)
|
|
currentVersion := intFromAny(private["profile_version"])
|
|
if currentVersion <= 0 {
|
|
currentVersion = 1
|
|
}
|
|
if body.Version > 0 && body.Version != currentVersion {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
|
|
return
|
|
}
|
|
private["nickname"] = nickname
|
|
private["profile_version"] = currentVersion + 1
|
|
updatedAt := time.Now().UTC().Round(time.Millisecond)
|
|
private["profile_updated_at"] = updatedAt.Format(time.RFC3339Nano)
|
|
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
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.Subs.Delete(principal.TenantID, topic, principal.Uid); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
_ = store.Topics.UpdateSubCnt(principal.TenantID, topic)
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"})
|
|
}
|
|
|
|
func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsSharer() && !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupQRCodeCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
expiresIn := body.ExpiresInSeconds
|
|
if expiresIn <= 0 {
|
|
expiresIn = imQRCodeDefaultExpiresInSeconds
|
|
}
|
|
if expiresIn > imQRCodeMaxExpiresInSeconds {
|
|
expiresIn = imQRCodeMaxExpiresInSeconds
|
|
}
|
|
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
|
|
token, err := generateIMQRCodeTokenForTopic(principal.TenantID, principal.Uid, topic, "join_group", expiresAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"qr_token": token,
|
|
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group",
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
"group_snapshot": groupDetailPayload(group, selfSub),
|
|
})
|
|
}
|
|
|
|
func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
access := selfSub.ModeGiven & selfSub.ModeWant
|
|
if !access.IsSharer() && !access.IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
var body imGroupInvitationCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(body.UserIDs))
|
|
for _, rawID := range body.UserIDs {
|
|
uid := types.ParseUserId(strings.TrimSpace(rawID))
|
|
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
|
|
if uid.IsZero() || uid == principal.Uid {
|
|
item["status"] = "failed"
|
|
item["reason"] = imReasonMalformedRequest
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
item["user_id"] = uid.UserId()
|
|
if _, err := store.Subs.Get(principal.TenantID, topic, uid, false); err == nil {
|
|
item["status"] = "already_member"
|
|
results = append(results, item)
|
|
continue
|
|
}
|
|
if err := store.Subs.Create(principal.TenantID, &types.Subscription{
|
|
TenantID: principal.TenantID,
|
|
User: uid.String(),
|
|
Topic: topic,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)),
|
|
}); err != nil {
|
|
item["status"] = "failed"
|
|
item["reason"] = relationErrorReason(err)
|
|
} else {
|
|
item["status"] = "joined"
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
_ = store.Topics.UpdateSubCnt(principal.TenantID, topic)
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results})
|
|
}
|
|
|
|
func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, req.Method == http.MethodGet)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
status := strings.TrimSpace(req.URL.Query().Get("status"))
|
|
if status == "" {
|
|
status = "pending"
|
|
}
|
|
items := filterGroupJoinRequests(groupJoinRequests(group), status)
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": ""})
|
|
case http.MethodPost:
|
|
var body imGroupJoinRequestCreateRequest
|
|
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
|
|
}
|
|
aux := groupAuxMap(group.Aux)
|
|
requests := groupJoinRequests(group)
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
requestID := "gjr_" + store.Store.GetUidString()
|
|
item := map[string]any{
|
|
"request_id": requestID,
|
|
"user_id": principal.Uid.UserId(),
|
|
"inviter_id": "",
|
|
"source": defaultString(strings.TrimSpace(body.Source), "manual"),
|
|
"message": strings.TrimSpace(body.Message),
|
|
"status": "pending",
|
|
"version": 1,
|
|
"created_at": now.Format(time.RFC3339Nano),
|
|
"updated_at": now.Format(time.RFC3339Nano),
|
|
"qr_token": strings.TrimSpace(body.QRToken),
|
|
}
|
|
requests = append(requests, item)
|
|
aux["join_requests"] = requests
|
|
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, item)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
aux := groupAuxMap(group.Aux)
|
|
requests := groupJoinRequests(group)
|
|
updated, found := resolveGroupJoinRequest(requests, requestID, action, principal.Uid, body.Version)
|
|
if !found {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request not found", nil)
|
|
return
|
|
}
|
|
aux["join_requests"] = requests
|
|
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if action == "accept" {
|
|
uid := types.ParseUserId(stringFromAny(updated["user_id"]))
|
|
if !uid.IsZero() {
|
|
_ = store.Subs.Create(principal.TenantID, &types.Subscription{
|
|
TenantID: principal.TenantID,
|
|
User: uid.String(),
|
|
Topic: topic,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)),
|
|
})
|
|
_ = store.Topics.UpdateSubCnt(principal.TenantID, topic)
|
|
}
|
|
}
|
|
writeIMSuccess(wrt, req, 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
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
announcement := latestGroupAnnouncement(group)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"topic": topic,
|
|
"pending_join_request_count": len(filterGroupJoinRequests(groupJoinRequests(group), "pending")),
|
|
"announcement": 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
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "announcements/latest" && req.Method == http.MethodGet:
|
|
writeIMSuccess(wrt, req, latestGroupAnnouncement(group))
|
|
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
|
|
}
|
|
aux := groupAuxMap(group.Aux)
|
|
current := latestGroupAnnouncement(group)
|
|
version := intFromAny(current["version"])
|
|
if version <= 0 {
|
|
version = 1
|
|
}
|
|
if body.Version > 0 && body.Version != version {
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": version})
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
announcement := map[string]any{
|
|
"announcement_id": defaultString(stringFromAny(current["announcement_id"]), "ann_"+store.Store.GetUidString()),
|
|
"content": content,
|
|
"author": principal.Uid.UserId(),
|
|
"version": version + 1,
|
|
"published_at": now.Format(time.RFC3339Nano),
|
|
}
|
|
aux["announcement"] = announcement
|
|
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, announcement)
|
|
case strings.HasPrefix(tail, "announcements/") && strings.HasSuffix(tail, "/read") && req.Method == http.MethodPost:
|
|
announcementID := strings.TrimSuffix(strings.TrimPrefix(tail, "announcements/"), "/read")
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)})
|
|
case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet:
|
|
writeIMSuccess(wrt, req, latestGroupAnnouncement(group))
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic string) {
|
|
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
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
|
|
}
|
|
aux := groupAuxMap(group.Aux)
|
|
governance := groupGovernanceMap(aux["governance"])
|
|
currentVersion := intFromAny(governance["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
|
|
}
|
|
applyBoolPatch(governance, "join_approval", body.JoinApproval)
|
|
applyBoolPatch(governance, "mute_all", body.MuteAll)
|
|
applyBoolPatch(governance, "member_upload_files", body.MemberUploadFiles)
|
|
applyBoolPatch(governance, "member_private_chat", body.MemberPrivateChat)
|
|
applyBoolPatch(governance, "member_invite", body.MemberInvite)
|
|
governance["version"] = currentVersion + 1
|
|
aux["governance"] = governance
|
|
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, governance)
|
|
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.Subs.Update(principal.TenantID, topic, uid, map[string]any{"ModeGiven": types.ModeCAdmin | types.ModeCPublic}); err != nil {
|
|
item["ok"] = false
|
|
item["reason"] = relationErrorReason(err)
|
|
} else {
|
|
item["ok"] = true
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
members, _ = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
|
|
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.Subs.Update(principal.TenantID, topic, uid, map[string]any{"ModeGiven": types.ModeCPublic}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
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.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"ModeGiven": types.ModeCAdmin | types.ModeCPublic}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
if err = store.Subs.Update(principal.TenantID, topic, target, map[string]any{"ModeGiven": types.ModeCFull}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
_ = store.Topics.OwnerChange(principal.TenantID, topic, target)
|
|
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
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
aux := groupAuxMap(group.Aux)
|
|
switch {
|
|
case tail == "mutes" && req.Method == http.MethodGet:
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMuteItems(aux), "next_cursor": ""})
|
|
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
|
|
}
|
|
mutes := groupMuteItems(aux)
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
for _, member := range body.Members {
|
|
uid := types.ParseUserId(strings.TrimSpace(member.UserID))
|
|
if uid.IsZero() {
|
|
continue
|
|
}
|
|
mutes = upsertGroupMute(mutes, map[string]any{"user_id": uid.UserId(), "muted_by": principal.Uid.UserId(), "muted_at": now.Format(time.RFC3339Nano), "expires_at": strings.TrimSpace(member.ExpiresAt), "reason": strings.TrimSpace(body.Reason)})
|
|
}
|
|
aux["mutes"] = mutes
|
|
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": 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
|
|
}
|
|
aux["mutes"] = removeGroupMute(groupMuteItems(aux), uid.UserId())
|
|
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "muted": false})
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
}
|
|
|
|
func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) {
|
|
if req.Method != http.MethodGet {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp || memberID.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return
|
|
}
|
|
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return
|
|
}
|
|
memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, memberID)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx))
|
|
}
|
|
|
|
func serveIMMeQRCodes(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imQRCodeCreateRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(body.Purpose)
|
|
if purpose == "" {
|
|
purpose = "add_friend"
|
|
}
|
|
if purpose != "add_friend" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
|
|
return
|
|
}
|
|
expiresIn := body.ExpiresInSeconds
|
|
if expiresIn <= 0 {
|
|
expiresIn = imQRCodeDefaultExpiresInSeconds
|
|
}
|
|
if expiresIn > imQRCodeMaxExpiresInSeconds {
|
|
expiresIn = imQRCodeMaxExpiresInSeconds
|
|
}
|
|
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
|
|
token, err := generateIMQRCodeToken(principal.TenantID, principal.Uid, purpose, expiresAt)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, principal.Uid)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
data := map[string]any{
|
|
"qr_token": token,
|
|
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose),
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
}
|
|
if user != nil {
|
|
data["display_profile"] = userSummaryPayload(user, "")
|
|
}
|
|
writeIMSuccess(wrt, req, data)
|
|
}
|
|
|
|
func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imQRCodeResolveRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
claims, err := parseIMQRCodeToken(strings.TrimSpace(body.Token), strings.TrimSpace(body.Purpose), time.Now().UTC())
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "qr code invalid", nil)
|
|
return
|
|
}
|
|
if claims.TenantID != principal.TenantID {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
|
return
|
|
}
|
|
if claims.Purpose == "join_group" {
|
|
if types.GetTopicCat(claims.Topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"})
|
|
return
|
|
}
|
|
group, err := store.Topics.Get(principal.TenantID, claims.Topic)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if group == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
|
|
return
|
|
}
|
|
selfSub, _ := store.Subs.Get(principal.TenantID, claims.Topic, principal.Uid, false)
|
|
status := "can_request"
|
|
if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() {
|
|
status = "joined"
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"purpose": claims.Purpose,
|
|
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
|
|
"group": groupDetailPayload(group, selfSub),
|
|
"status": status,
|
|
"source": "qr",
|
|
})
|
|
return
|
|
}
|
|
peer := types.ParseUserId(claims.UserID)
|
|
if peer.IsZero() || peer == principal.Uid {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"})
|
|
return
|
|
}
|
|
user, err := store.Users.Get(principal.TenantID, peer)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
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: ctx.Relationship == types.RelationshipNone, Action: imRelationshipAction(ctx.Relationship)}),
|
|
"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) {
|
|
userID, tail, ok := userResourcePath(req.URL.Path)
|
|
if !ok || tail != "profile" {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
|
|
return
|
|
}
|
|
serveIMUserProfile(wrt, req, userID)
|
|
}
|
|
|
|
func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID 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 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
|
|
}
|
|
writeIMSuccess(wrt, req, userProfilePayload(user, ctx))
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
case http.MethodDelete:
|
|
if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
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
|
|
}
|
|
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
|
|
}
|
|
default:
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
|
|
if err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
|
}
|
|
|
|
func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imIndependentPasswordRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(body.Purpose)
|
|
if purpose == "" {
|
|
purpose = "youth_mode"
|
|
}
|
|
if !validIMIndependentPassword(body.Password) || purpose != "youth_mode" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
|
|
return
|
|
}
|
|
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", purpose, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if err := store.Security.UpsertIndependentPassword(principal.TenantID, &types.IndependentPassword{
|
|
TenantID: principal.TenantID,
|
|
UserID: principal.Uid,
|
|
Purpose: purpose,
|
|
PasswordHash: hash,
|
|
}); err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "purpose": purpose})
|
|
}
|
|
|
|
func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body imIndependentPasswordVerifyRequest
|
|
if !decodeIMJSONBody(wrt, req, &body) {
|
|
return
|
|
}
|
|
purpose := strings.TrimSpace(body.Purpose)
|
|
if purpose == "" {
|
|
purpose = "youth_mode"
|
|
}
|
|
action := strings.TrimSpace(body.Action)
|
|
if action == "" {
|
|
action = "close_youth_mode"
|
|
}
|
|
pass, err := store.Security.GetIndependentPassword(principal.TenantID, principal.Uid, purpose)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
|
|
return
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if pass.LockedUntil != nil && now.Before(*pass.LockedUntil) {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
|
|
"retry_after_seconds": int(time.Until(*pass.LockedUntil).Seconds()) + 1,
|
|
})
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword(pass.PasswordHash, []byte(body.Password)) != nil {
|
|
attempts := pass.FailedAttempts + 1
|
|
var lockedUntil *time.Time
|
|
if attempts >= imIndependentPasswordMaxAttempts {
|
|
until := now.Add(imIndependentPasswordLockSeconds * time.Second)
|
|
lockedUntil = &until
|
|
}
|
|
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, attempts, lockedUntil, now)
|
|
if lockedUntil != nil {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
|
|
"retry_after_seconds": imIndependentPasswordLockSeconds,
|
|
})
|
|
return
|
|
}
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
|
|
return
|
|
}
|
|
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, 0, nil, now)
|
|
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, action, purpose)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"action_token": token,
|
|
"action": action,
|
|
"expires_at": expiresAt.Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, challengeID, code, scene string) (*types.VerificationCode, string, bool) {
|
|
if len(code) != imVerificationCodeLength {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
|
|
return nil, "", false
|
|
}
|
|
vc, err := store.Verifications.Get(tenantID, challengeID)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return nil, "", false
|
|
}
|
|
logs.Warn.Println("im verification verify: lookup failed", err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, "", false
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if vc.Scene != scene || vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
|
|
return nil, "", false
|
|
}
|
|
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
|
|
_ = store.Verifications.Fail(tenantID, challengeID, types.VerificationStateExpired)
|
|
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
|
return nil, "", false
|
|
}
|
|
if vc.CodeHash != hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
|
|
nextState := types.VerificationStatePending
|
|
if vc.Attempts+1 >= vc.MaxAttempts {
|
|
nextState = types.VerificationStateLocked
|
|
}
|
|
_ = store.Verifications.Fail(tenantID, challengeID, nextState)
|
|
if nextState == types.VerificationStateLocked {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
|
|
return nil, "", false
|
|
}
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
|
|
return nil, "", false
|
|
}
|
|
token, err := randomIMToken(32)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, "", false
|
|
}
|
|
if err := store.Verifications.MarkVerified(tenantID, challengeID, hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
|
|
logs.Warn.Println("im verification verify: mark verified failed", err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, "", false
|
|
}
|
|
return vc, token, true
|
|
}
|
|
|
|
func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPrincipal, bool) {
|
|
authz := strings.TrimSpace(req.Header.Get("Authorization"))
|
|
token, ok := strings.CutPrefix(authz, "Bearer ")
|
|
if !ok || strings.TrimSpace(token) == "" || store.Store == nil {
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
|
|
return nil, false
|
|
}
|
|
handler := store.Store.GetLogicalAuthHandler("token")
|
|
if handler == nil || !handler.IsInitialized() {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, false
|
|
}
|
|
rec, _, err := handler.Authenticate(auth.AuthContext{}, []byte(strings.TrimSpace(token)))
|
|
if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
|
|
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
|
|
return nil, false
|
|
}
|
|
return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, true
|
|
}
|
|
|
|
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
|
|
creds, err := store.Users.GetAllCreds(tenantID, uid, "tel", true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(creds) == 0 || strings.TrimSpace(creds[0].Value) == "" {
|
|
return "", types.ErrNotFound
|
|
}
|
|
return strings.TrimSpace(creds[0].Value), 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")
|
|
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
|
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
if active != nil && now.Before(active.ResendAfter) {
|
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
"challenge_id": active.ChallengeID,
|
|
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
|
|
})
|
|
return
|
|
}
|
|
challengeID, err := randomIMToken(18)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
code, err := randomIMNumericCode(imVerificationCodeLength)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
logs.Info.Println("im security verification code generated:", action, vc.PhoneMask)
|
|
writeIMSuccess(wrt, req, map[string]any{
|
|
"challenge_id": vc.ChallengeID,
|
|
"phone_mask": vc.PhoneMask,
|
|
"expires_in_seconds": imVerificationExpiresInSeconds,
|
|
"resend_after_seconds": imVerificationResendAfterSeconds,
|
|
})
|
|
}
|
|
|
|
func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target string) (string, time.Time, error) {
|
|
if action == "" {
|
|
return "", time.Time{}, types.ErrMalformed
|
|
}
|
|
tokenID, err := randomIMToken(12)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
secret, err := randomIMToken(32)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(imActionTokenExpiresInSeconds * time.Second)
|
|
if err := store.Security.CreateActionToken(tenantID, &types.ActionToken{
|
|
TenantID: tenantID,
|
|
UserID: uid,
|
|
TokenID: tokenID,
|
|
Action: action,
|
|
Target: target,
|
|
TokenHash: hashIMActionToken(tenantID, uid, action, target, tokenID, secret),
|
|
State: 0,
|
|
ExpiresAt: expiresAt,
|
|
}); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
return tokenID + "." + secret, expiresAt, nil
|
|
}
|
|
|
|
func defaultIMActionTarget(action, fallback string) string {
|
|
switch action {
|
|
case "set_independent_password", "close_youth_mode", "reset_independent_password":
|
|
return "youth_mode"
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string {
|
|
h := sha256.New()
|
|
h.Write([]byte(fmt.Sprintf("%d", tenantID)))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(fmt.Sprintf("%d", store.DecodeUid(uid))))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(action))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(target))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(tokenID))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(secret))
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
func realNamePayload(rv *types.RealNameVerification) map[string]any {
|
|
canSubmit := rv.Status == "unverified" || rv.Status == "rejected"
|
|
data := map[string]any{
|
|
"status": rv.Status,
|
|
"submission_id": rv.SubmissionID,
|
|
"masked_name": rv.MaskedName,
|
|
"masked_id_number": rv.MaskedIDNumber,
|
|
"can_submit": canSubmit,
|
|
}
|
|
if rv.VerifiedAt != nil {
|
|
data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano)
|
|
}
|
|
if rv.ReviewReason != nil {
|
|
data["review_reason"] = *rv.ReviewReason
|
|
}
|
|
return data
|
|
}
|
|
|
|
func parseIMLimit(raw string, fallback, max int) int {
|
|
limit := fallback
|
|
if raw != "" {
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
if limit > max {
|
|
limit = max
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func parseIMTime(raw string) (time.Time, error) {
|
|
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return parsed.UTC().Round(time.Millisecond), nil
|
|
}
|
|
|
|
func parseIMDate(raw string) (time.Time, error) {
|
|
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.UTC)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func validIMMonth(raw string) bool {
|
|
if _, err := time.ParseInLocation("2006-01", strings.TrimSpace(raw), time.UTC); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) {
|
|
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
|
if key == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
|
return "", false
|
|
}
|
|
return key, true
|
|
}
|
|
|
|
func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []string, field string) ([]types.Uid, bool) {
|
|
uids := make([]types.Uid, 0, len(values))
|
|
seen := make(map[types.Uid]bool, len(values))
|
|
for _, value := range values {
|
|
uid := types.ParseUserId(strings.TrimSpace(value))
|
|
if uid.IsZero() {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field})
|
|
return nil, false
|
|
}
|
|
if seen[uid] {
|
|
continue
|
|
}
|
|
seen[uid] = true
|
|
uids = append(uids, uid)
|
|
}
|
|
return uids, true
|
|
}
|
|
|
|
func parseIMNonNegativeCursor(raw string) (int, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0, nil
|
|
}
|
|
cursor, err := strconv.Atoi(raw)
|
|
if err != nil || cursor < 0 {
|
|
return 0, errors.New("invalid cursor")
|
|
}
|
|
return cursor, nil
|
|
}
|
|
|
|
func parseIMBool(raw string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "1", "true", "yes", "y":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func conversationMatches(sub types.Subscription, query string) bool {
|
|
if strings.Contains(strings.ToLower(sub.Topic), query) {
|
|
return true
|
|
}
|
|
encoded, _ := json.Marshal(sub.GetPublic())
|
|
return strings.Contains(strings.ToLower(string(encoded)), query)
|
|
}
|
|
|
|
func conversationPayload(sub types.Subscription) map[string]any {
|
|
unread := sub.GetSeqId() - sub.ReadSeqId
|
|
if unread < 0 {
|
|
unread = 0
|
|
}
|
|
return map[string]any{
|
|
"topic": sub.Topic,
|
|
"public": sub.GetPublic(),
|
|
"private": sub.Private,
|
|
"updated_at": sub.LastModified().UTC().Format(time.RFC3339Nano),
|
|
"seq": sub.GetSeqId(),
|
|
"read": sub.ReadSeqId,
|
|
"recv": sub.RecvSeqId,
|
|
"unread": unread,
|
|
"with": sub.GetWith(),
|
|
}
|
|
}
|
|
|
|
func receiptMemberPayload(sub types.Subscription) map[string]any {
|
|
return map[string]any{
|
|
"user_id": sub.User,
|
|
"public": sub.GetPublic(),
|
|
"read": sub.ReadSeqId,
|
|
"recv": sub.RecvSeqId,
|
|
}
|
|
}
|
|
|
|
func groupListItemPayload(sub types.Subscription) map[string]any {
|
|
public := groupPublicMap(sub.GetPublic())
|
|
access := sub.ModeGiven & sub.ModeWant
|
|
return map[string]any{
|
|
"topic": sub.Topic,
|
|
"name": groupName(public, sub.Topic),
|
|
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
|
|
"member_count": sub.GetSubCnt(),
|
|
"my_role": groupMemberRole(access),
|
|
"updated_at": sub.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
public := map[string]any{}
|
|
topic := ""
|
|
memberCount := 0
|
|
ownerID := ""
|
|
if group != nil {
|
|
public = groupPublicMap(group.Public)
|
|
topic = group.Id
|
|
memberCount = group.SubCnt
|
|
if owner := types.ParseUid(group.Owner); !owner.IsZero() {
|
|
ownerID = owner.UserId()
|
|
}
|
|
}
|
|
version := intFromAny(public["version"])
|
|
if version <= 0 {
|
|
version = 1
|
|
}
|
|
access := types.ModeNone
|
|
if selfSub != nil {
|
|
access = selfSub.ModeGiven & selfSub.ModeWant
|
|
if topic == "" {
|
|
topic = selfSub.Topic
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"topic": topic,
|
|
"name": groupName(public, topic),
|
|
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
|
|
"description": stringFromAny(public["description"]),
|
|
"owner": ownerID,
|
|
"member_count": memberCount,
|
|
"version": version,
|
|
"my_role": groupMemberRole(access),
|
|
"permissions": groupPermissions(access),
|
|
}
|
|
}
|
|
|
|
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
data := groupDetailPayload(group, selfSub)
|
|
private := map[string]any{}
|
|
if selfSub != nil {
|
|
private = groupPrivateMap(selfSub.Private)
|
|
}
|
|
data["pinned"] = boolFromAny(private["pinned"])
|
|
data["mute_notifications"] = boolFromAny(private["mute_notifications"])
|
|
data["message_retention"] = stringFromAny(private["message_retention"])
|
|
if data["message_retention"] == "" {
|
|
data["message_retention"] = "forever"
|
|
}
|
|
data["governance"] = defaultGroupGovernance()
|
|
return data
|
|
}
|
|
|
|
func groupMemberSettingsPayload(sub *types.Subscription) map[string]any {
|
|
private := map[string]any{}
|
|
if sub != nil {
|
|
private = groupPrivateMap(sub.Private)
|
|
}
|
|
return map[string]any{
|
|
"pinned": boolFromAny(private["pinned"]),
|
|
"mute_notifications": boolFromAny(private["mute_notifications"]),
|
|
"version": intFromAny(private["version"]),
|
|
"updated_at": stringFromAny(private["updated_at"]),
|
|
}
|
|
}
|
|
|
|
func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any {
|
|
access := sub.ModeGiven & sub.ModeWant
|
|
userID := types.ParseUserId(sub.User)
|
|
user := &types.User{ObjHeader: types.ObjHeader{Id: userID.String()}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()}
|
|
data := userSummaryPayload(user, "")
|
|
if private := groupPrivateMap(sub.Private); stringFromAny(private["nickname"]) != "" {
|
|
data["group_nickname"] = stringFromAny(private["nickname"])
|
|
}
|
|
data["role"] = groupMemberRole(access)
|
|
data["mentionable"] = mentionable
|
|
data["acs"] = map[string]any{
|
|
"mode": access.String(),
|
|
"want": sub.ModeWant.String(),
|
|
"given": sub.ModeGiven.String(),
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
|
|
data := groupMemberPayload(*sub, true)
|
|
data["topic"] = topic
|
|
data["relationship"] = ctx.Relationship
|
|
data["capabilities"] = map[string]any{
|
|
"can_send": ctx.CanSend,
|
|
"can_call": ctx.CanSend,
|
|
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
|
}
|
|
if ctx.RequestID != "" {
|
|
data["request_id"] = ctx.RequestID
|
|
}
|
|
if user != nil {
|
|
for key, value := range userSummaryPayload(user, "") {
|
|
if key == "avatar_file_id" || key == "display_name" {
|
|
data[key] = value
|
|
}
|
|
}
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyOptionalProfileFields(data, public)
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
copyOptionalProfileFields(data, map[string]any(public))
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func groupMemberRole(access types.AccessMode) string {
|
|
switch {
|
|
case access.IsOwner():
|
|
return "owner"
|
|
case access.IsAdmin():
|
|
return "admin"
|
|
default:
|
|
return "member"
|
|
}
|
|
}
|
|
|
|
func groupCategory(role string) string {
|
|
switch role {
|
|
case "owner":
|
|
return "owned"
|
|
case "admin":
|
|
return "managed"
|
|
default:
|
|
return "joined"
|
|
}
|
|
}
|
|
|
|
func groupPermissions(access types.AccessMode) map[string]any {
|
|
return map[string]any{
|
|
"can_edit_profile": access.IsAdmin(),
|
|
"can_invite_members": access.IsSharer() || access.IsAdmin(),
|
|
"can_manage_members": access.IsAdmin(),
|
|
"can_manage_settings": access.IsAdmin(),
|
|
"can_leave": !access.IsOwner() && access.IsJoiner(),
|
|
}
|
|
}
|
|
|
|
func defaultGroupGovernance() map[string]any {
|
|
return map[string]any{
|
|
"join_approval": false,
|
|
"mute_all": false,
|
|
"member_upload_files": true,
|
|
"member_private_chat": true,
|
|
"member_invite": true,
|
|
"version": 1,
|
|
}
|
|
}
|
|
|
|
func groupGovernanceMap(value any) map[string]any {
|
|
governance := defaultGroupGovernance()
|
|
for key, value := range groupPrivateMap(value) {
|
|
governance[key] = value
|
|
}
|
|
return governance
|
|
}
|
|
|
|
func groupGovernancePayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
|
|
aux := map[string]any{}
|
|
if group != nil {
|
|
aux = groupAuxMap(group.Aux)
|
|
}
|
|
governance := groupGovernanceMap(aux["governance"])
|
|
editable := false
|
|
if selfSub != nil {
|
|
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
|
|
}
|
|
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
|
|
governance[key+"_editable"] = editable
|
|
}
|
|
return governance
|
|
}
|
|
|
|
func 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 authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) {
|
|
if principal == nil {
|
|
return nil, false, types.ErrPermissionDenied
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
return nil, false, types.ErrPermissionDenied
|
|
}
|
|
msg, err := store.Messages.GetOne(principal.TenantID, topic, seq)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if msg == nil {
|
|
return nil, false, types.ErrNotFound
|
|
}
|
|
if msg.RecalledAt != nil || msg.DeletedAt != nil {
|
|
return msg, false, types.ErrDuplicate
|
|
}
|
|
from := types.ParseUid(msg.From)
|
|
access := sub.ModeGiven & sub.ModeWant
|
|
authorized := from == principal.Uid || (allowAdmin && access.IsAdmin())
|
|
return msg, authorized, nil
|
|
}
|
|
|
|
func isIMEditableTextMessage(msg *types.Message) bool {
|
|
if msg == nil {
|
|
return false
|
|
}
|
|
if msg.Head == nil {
|
|
return true
|
|
}
|
|
msgType, _ := msg.Head["type"].(string)
|
|
return msgType == "" || msgType == "text"
|
|
}
|
|
|
|
func writeIMMessageMutationError(wrt http.ResponseWriter, req *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found", nil)
|
|
case errors.Is(err, types.ErrDuplicate):
|
|
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict", nil)
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
case errors.Is(err, types.ErrMalformed):
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
}
|
|
}
|
|
|
|
func messageMutationPayload(msg *types.Message, status string) map[string]any {
|
|
data := map[string]any{
|
|
"status": status,
|
|
"topic": msg.Topic,
|
|
"seq": msg.SeqId,
|
|
"version": msg.Version,
|
|
"updated_at": msg.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if msg.RecalledAt != nil {
|
|
data["recalled_at"] = msg.RecalledAt.UTC().Format(time.RFC3339Nano)
|
|
data["recalled_by"] = msg.RecalledBy
|
|
}
|
|
if status == "edited" {
|
|
data["head"] = msg.Head
|
|
data["content"] = msg.Content
|
|
}
|
|
return data
|
|
}
|
|
|
|
func favoritePayload(item types.FavoriteItem) map[string]any {
|
|
return map[string]any{
|
|
"favorite_id": item.FavoriteID,
|
|
"type": item.ItemType,
|
|
"snapshot": item.Snapshot,
|
|
"source": map[string]any{"topic": item.SourceTopic, "seq": item.SourceSeq},
|
|
"collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano),
|
|
"availability": item.Availability,
|
|
}
|
|
}
|
|
|
|
func messageSearchPayload(msg types.Message, query string) map[string]any {
|
|
text := messageText(msg)
|
|
return map[string]any{
|
|
"seq": msg.SeqId,
|
|
"from": msg.From,
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"type": imMessageType(&msg),
|
|
"snippet": textSnippet(text, query),
|
|
"matches": textMatches(text, query),
|
|
"snapshot": favoriteSnapshot(&msg),
|
|
"availability": messageAvailability(msg),
|
|
}
|
|
}
|
|
|
|
func messageContextPayload(msg types.Message) map[string]any {
|
|
return map[string]any{
|
|
"seq": msg.SeqId,
|
|
"from": msg.From,
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"type": imMessageType(&msg),
|
|
"head": msg.Head,
|
|
"content": msg.Content,
|
|
"availability": messageAvailability(msg),
|
|
}
|
|
}
|
|
|
|
func mediaMessagePayload(msg types.Message) map[string]any {
|
|
snapshot := favoriteSnapshot(&msg)
|
|
data := map[string]any{
|
|
"seq": msg.SeqId,
|
|
"from": msg.From,
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"availability": messageAvailability(msg),
|
|
}
|
|
if content, ok := snapshot["content"].(map[string]any); ok {
|
|
for key, value := range content {
|
|
data[key] = value
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func callRecordPayloads(records []types.CallRecord, viewer types.Uid) []map[string]any {
|
|
items := make([]map[string]any, 0, len(records))
|
|
for _, record := range records {
|
|
items = append(items, callRecordPayload(record, viewer))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any {
|
|
peer := record.CalleeID
|
|
direction := "outgoing"
|
|
if record.CalleeID == viewer {
|
|
peer = record.CallerID
|
|
direction = "incoming"
|
|
}
|
|
data := map[string]any{
|
|
"call_id": record.CallID,
|
|
"topic": record.Topic,
|
|
"seq": record.SeqID,
|
|
"caller_id": record.CallerID.UserId(),
|
|
"callee_id": record.CalleeID.UserId(),
|
|
"peer_user_id": peer.UserId(),
|
|
"direction": direction,
|
|
"call_type": record.CallType,
|
|
"status": record.Status,
|
|
"result": record.Status,
|
|
"duration_ms": record.DurationMS,
|
|
"started_at": record.StartedAt.UTC().Format(time.RFC3339Nano),
|
|
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if record.EndReason != "" {
|
|
data["end_reason"] = record.EndReason
|
|
}
|
|
if record.AcceptedAt != nil {
|
|
data["accepted_at"] = record.AcceptedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
if record.FinishedAt != nil {
|
|
data["finished_at"] = record.FinishedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any {
|
|
items := make([]map[string]any, 0, len(modules))
|
|
for _, module := range modules {
|
|
if !isAllowedIMWorkbenchRoute(module.Route) {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{
|
|
"code": module.Code,
|
|
"name": module.Name,
|
|
"icon_file_id": module.IconFileID,
|
|
"route": module.Route,
|
|
"unread_count": 0,
|
|
"enabled": module.Enabled,
|
|
"permission": module.Permission,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func organizationDepartmentPayloads(departments []types.OrganizationDepartment) []map[string]any {
|
|
items := make([]map[string]any, 0, len(departments))
|
|
for _, department := range departments {
|
|
items = append(items, organizationDepartmentPayload(department))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func organizationDepartmentPayload(department types.OrganizationDepartment) map[string]any {
|
|
return map[string]any{
|
|
"department_id": department.DepartmentID,
|
|
"name": department.Name,
|
|
"parent_id": department.ParentID,
|
|
"child_count": department.ChildCount,
|
|
"member_count": department.MemberCount,
|
|
"version": department.Version,
|
|
}
|
|
}
|
|
|
|
func organizationMemberPayloads(members []types.OrganizationMember) []map[string]any {
|
|
items := make([]map[string]any, 0, len(members))
|
|
for _, member := range members {
|
|
items = append(items, organizationMemberPayload(member))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func organizationMemberPayload(member types.OrganizationMember) map[string]any {
|
|
payload := map[string]any{
|
|
"user_id": member.UserID.UserId(),
|
|
"department_id": member.DepartmentID,
|
|
"display_name": member.DisplayName,
|
|
"position": member.Position,
|
|
"avatar_file_id": member.AvatarFileID,
|
|
"status": member.Status,
|
|
"contactable": member.Contactable,
|
|
"version": member.Version,
|
|
}
|
|
if member.Mobile != "" {
|
|
payload["mobile"] = member.Mobile
|
|
}
|
|
if member.Email != "" {
|
|
payload["email"] = member.Email
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func organizationSearchPayloads(items []types.OrganizationSearchItem) []map[string]any {
|
|
payload := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
switch item.Type {
|
|
case "department":
|
|
if item.Department != nil {
|
|
payload = append(payload, map[string]any{
|
|
"type": "department",
|
|
"department": organizationDepartmentPayload(*item.Department),
|
|
})
|
|
}
|
|
case "user":
|
|
if item.Member != nil {
|
|
payload = append(payload, map[string]any{
|
|
"type": "user",
|
|
"user": organizationMemberPayload(*item.Member),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
|
|
participants := meetingParticipantPayloads(meeting.Participants)
|
|
data := map[string]any{
|
|
"meeting_id": meeting.MeetingID,
|
|
"title": meeting.Title,
|
|
"organizer_id": meeting.OrganizerID.UserId(),
|
|
"start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano),
|
|
"end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
|
"location": meeting.Location,
|
|
"state": meeting.State,
|
|
"reminder_minutes": meeting.ReminderMinutes,
|
|
"version": meeting.Version,
|
|
"participants": participants,
|
|
"permissions": meetingPermissionsPayload(meeting, viewer),
|
|
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if meeting.CanceledAt != nil {
|
|
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
for _, participant := range meeting.Participants {
|
|
if participant.UserID == viewer {
|
|
data["my_response"] = participant.Response
|
|
break
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func meetingParticipantPayloads(participants []types.MeetingParticipant) []map[string]any {
|
|
items := make([]map[string]any, 0, len(participants))
|
|
for _, participant := range participants {
|
|
item := map[string]any{
|
|
"user_id": participant.UserID.UserId(),
|
|
"response": participant.Response,
|
|
}
|
|
if participant.ResponseAt != nil {
|
|
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items
|
|
}
|
|
|
|
func meetingPermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
|
|
isOrganizer := meeting.OrganizerID == viewer
|
|
isScheduled := meeting.State == types.MeetingStateScheduled
|
|
canRespond := false
|
|
for _, participant := range meeting.Participants {
|
|
if participant.UserID == viewer && !isOrganizer {
|
|
canRespond = true
|
|
break
|
|
}
|
|
}
|
|
if time.Now().UTC().After(meeting.EndAt) {
|
|
canRespond = false
|
|
}
|
|
return map[string]any{
|
|
"can_update": isOrganizer && isScheduled,
|
|
"can_cancel": isOrganizer && isScheduled,
|
|
"can_remind": isOrganizer && isScheduled,
|
|
"can_respond": canRespond && isScheduled,
|
|
}
|
|
}
|
|
|
|
func dailyReportListPayloads(reports []types.DailyReport) []map[string]any {
|
|
items := make([]map[string]any, 0, len(reports))
|
|
for _, report := range reports {
|
|
items = append(items, dailyReportListPayload(report))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func dailyReportListPayload(report types.DailyReport) map[string]any {
|
|
data := map[string]any{
|
|
"report_id": report.ReportID,
|
|
"author_id": report.AuthorID.UserId(),
|
|
"report_date": report.ReportDate.Format("2006-01-02"),
|
|
"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,
|
|
"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 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 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,
|
|
"public_account": profile.PublicAccount,
|
|
"version": profile.Version,
|
|
"updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"field_rules": map[string]any{
|
|
"nickname": map[string]any{"min_length": 1, "max_length": 32},
|
|
"signature": map[string]any{"max_length": 120},
|
|
"birth_date": map[string]any{"format": "YYYY-MM-DD"},
|
|
"gender": []string{"male", "female", "other", ""},
|
|
},
|
|
}
|
|
return data
|
|
}
|
|
|
|
func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any {
|
|
return map[string]any{
|
|
"add_me_methods": settings.AddMeMethods,
|
|
"profile_visibility": settings.ProfileVisibility,
|
|
"last_seen_visibility": settings.LastSeenVisibility,
|
|
"read_receipts": settings.ReadReceipts,
|
|
"version": settings.Version,
|
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"editable": map[string]any{
|
|
"add_me_methods": true,
|
|
"profile_visibility": true,
|
|
"last_seen_visibility": true,
|
|
"read_receipts": true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func appReleasePayload(release *types.AppRelease) map[string]any {
|
|
return map[string]any{
|
|
"platform": release.Platform,
|
|
"channel": release.Channel,
|
|
"latest_version": release.LatestVersion,
|
|
"min_supported_version": release.MinSupportedVersion,
|
|
"update_type": release.UpdateType,
|
|
"release_notes": release.ReleaseNotes,
|
|
"store_url": release.StoreURL,
|
|
"package_url": release.PackageURL,
|
|
"checksum": release.Checksum,
|
|
"remind_after_seconds": release.RemindAfterSeconds,
|
|
"published_at": release.PublishedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
|
|
data := map[string]any{
|
|
"enabled": settings.Enabled,
|
|
"policy_version": settings.PolicyVersion,
|
|
"restrictions": settings.Restrictions,
|
|
"password_set": settings.PasswordSet,
|
|
"can_enable": settings.CanEnable,
|
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if settings.EnabledAt != nil {
|
|
data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
|
|
return map[string]any{
|
|
"device_id": token.DeviceID,
|
|
"provider": token.Provider,
|
|
"app_version": token.AppVersion,
|
|
"platform": token.Platform,
|
|
"updated_at": token.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
}
|
|
|
|
func isAllowedIMWorkbenchRoute(route string) bool {
|
|
switch route {
|
|
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any {
|
|
data := map[string]any{
|
|
"topic": ctx.Topic,
|
|
"peer_user_id": ctx.PeerID.UserId(),
|
|
"relationship": ctx.Relationship,
|
|
"can_send": ctx.CanSend,
|
|
"capabilities": map[string]any{
|
|
"can_send_text": ctx.CanSend,
|
|
"can_send_media": ctx.CanSend,
|
|
"can_send_card": ctx.CanSend,
|
|
"can_call": ctx.CanSend,
|
|
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
|
"can_handle_request": ctx.Relationship == types.RelationshipPendingInbound,
|
|
},
|
|
}
|
|
if ctx.RequestID != "" {
|
|
data["request_id"] = ctx.RequestID
|
|
}
|
|
if ctx.Peer != nil {
|
|
data["peer"] = map[string]any{
|
|
"user_id": ctx.PeerID.UserId(),
|
|
"public": ctx.Peer.Public,
|
|
"trusted": ctx.Peer.Trusted,
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendRequestPayload(req *types.FriendRequest) map[string]any {
|
|
data := map[string]any{
|
|
"request_id": req.RequestID,
|
|
"applicant_id": req.ApplicantID.UserId(),
|
|
"target_user_id": req.TargetID.UserId(),
|
|
"message": req.Message,
|
|
"source": req.Source,
|
|
"status": req.Status,
|
|
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if topic := req.ApplicantID.P2PName(req.TargetID); topic != "" {
|
|
data["topic"] = topic
|
|
}
|
|
if req.SourceTopic != "" {
|
|
data["source_topic"] = req.SourceTopic
|
|
}
|
|
if req.HandledAt != nil {
|
|
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]any {
|
|
data := friendRequestPayload(&item.Request)
|
|
data["direction"] = item.Direction
|
|
if item.Peer != nil {
|
|
data["peer"] = userSummaryPayload(item.Peer, "")
|
|
}
|
|
return data
|
|
}
|
|
|
|
func friendListItemPayload(item types.FriendListItem) map[string]any {
|
|
data := map[string]any{
|
|
"user_id": item.Friendship.PeerID.UserId(),
|
|
"relationship": item.Friendship.Status,
|
|
"remark": item.Friendship.Remark,
|
|
"topic": item.Friendship.Topic,
|
|
"online_visibility": "unknown",
|
|
"created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
"capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": item.Friendship.Status == types.RelationshipFriend},
|
|
"display_name_source": "profile",
|
|
}
|
|
if item.Friendship.Remark != "" {
|
|
data["display_name"] = item.Friendship.Remark
|
|
data["display_name_source"] = "remark"
|
|
}
|
|
if item.Peer != nil {
|
|
for key, value := range userSummaryPayload(item.Peer, item.Friendship.Remark) {
|
|
_, exists := data[key]
|
|
if !exists || key == "avatar_file_id" || (key == "display_name" && item.Friendship.Remark == "") {
|
|
data[key] = value
|
|
}
|
|
}
|
|
}
|
|
data["initial"] = displayInitial(data["display_name"])
|
|
return data
|
|
}
|
|
|
|
func userSearchItemPayload(item types.UserSearchItem) map[string]any {
|
|
data := userSummaryPayload(item.User, "")
|
|
data["relationship"] = item.Relationship
|
|
data["can_add"] = item.CanAdd
|
|
data["action"] = item.Action
|
|
if item.RequestID != "" {
|
|
data["request_id"] = item.RequestID
|
|
}
|
|
return data
|
|
}
|
|
|
|
func imRelationshipAction(relationship string) string {
|
|
switch relationship {
|
|
case types.RelationshipFriend:
|
|
return "chat"
|
|
case types.RelationshipPendingInbound, types.RelationshipPendingOutbound:
|
|
return "pending"
|
|
case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer:
|
|
return "none"
|
|
default:
|
|
return "add"
|
|
}
|
|
}
|
|
|
|
func blockListItemPayload(item types.BlockListItem) map[string]any {
|
|
data := map[string]any{
|
|
"user_id": item.Block.BlockedID.UserId(),
|
|
"blocked_at": item.Block.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"updated_at": item.Block.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if item.User != nil {
|
|
data["user"] = userSummaryPayload(item.User, "")
|
|
}
|
|
return data
|
|
}
|
|
|
|
func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
|
|
data := userSummaryPayload(user, "")
|
|
data["relationship"] = ctx.Relationship
|
|
data["topic"] = ctx.Topic
|
|
data["capabilities"] = map[string]any{
|
|
"can_send": ctx.CanSend,
|
|
"can_call": ctx.CanSend,
|
|
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
|
}
|
|
if ctx.RequestID != "" {
|
|
data["request_id"] = ctx.RequestID
|
|
}
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyOptionalProfileFields(data, public)
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
copyOptionalProfileFields(data, map[string]any(public))
|
|
}
|
|
return data
|
|
}
|
|
|
|
func copyOptionalProfileFields(data map[string]any, public map[string]any) {
|
|
for _, key := range []string{"gender", "region", "public_account", "signature"} {
|
|
if value, ok := public[key]; ok {
|
|
data[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
func userSummaryPayload(user *types.User, remark string) map[string]any {
|
|
uid := types.ParseUid(user.Id)
|
|
userID := uid.UserId()
|
|
if uid.IsZero() {
|
|
userID = user.Id
|
|
}
|
|
data := map[string]any{"user_id": userID}
|
|
if remark != "" {
|
|
data["display_name"] = remark
|
|
}
|
|
if public, ok := user.Public.(map[string]any); ok {
|
|
copyUserPublicSummary(data, public)
|
|
} else if public, ok := user.Public.(types.KVMap); ok {
|
|
copyUserPublicSummary(data, map[string]any(public))
|
|
}
|
|
if _, ok := data["display_name"]; !ok {
|
|
data["display_name"] = userID
|
|
}
|
|
return data
|
|
}
|
|
|
|
func copyUserPublicSummary(data map[string]any, public map[string]any) {
|
|
for _, key := range []string{"display_name", "fn", "name", "nickname"} {
|
|
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
|
data["display_name"] = value
|
|
break
|
|
}
|
|
}
|
|
for _, key := range []string{"avatar_file_id", "photo", "avatar"} {
|
|
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
|
data["avatar_file_id"] = value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func displayInitial(value any) string {
|
|
name := strings.TrimSpace(fmt.Sprint(value))
|
|
if name == "" {
|
|
return "#"
|
|
}
|
|
for _, r := range name {
|
|
return strings.ToUpper(string(r))
|
|
}
|
|
return "#"
|
|
}
|
|
|
|
func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonMalformedRequest, "resource not found", nil)
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
writeIMError(wrt, req, http.StatusForbidden, 20005, imReasonPermissionDenied, "permission denied", nil)
|
|
case errors.Is(err, types.ErrInvalidResponse):
|
|
writeIMError(wrt, req, http.StatusConflict, 20009, "VERSION_CONFLICT", "version conflict", nil)
|
|
case errors.Is(err, types.ErrMalformed):
|
|
writeIMError(wrt, req, http.StatusBadRequest, 20000, imReasonMalformedRequest, "malformed request", nil)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
}
|
|
}
|
|
|
|
func relationErrorReason(err error) string {
|
|
switch {
|
|
case errors.Is(err, types.ErrNotFound):
|
|
return "NOT_FOUND"
|
|
case errors.Is(err, types.ErrPermissionDenied):
|
|
return imReasonPermissionDenied
|
|
case errors.Is(err, types.ErrMalformed):
|
|
return imReasonMalformedRequest
|
|
default:
|
|
return imReasonInternal
|
|
}
|
|
}
|
|
|
|
func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, needRead bool) (*imPrincipal, *types.Subscription, bool) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
if types.GetTopicCat(topic) != types.TopicCatGrp {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return nil, nil, false
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, nil, false
|
|
}
|
|
access := types.ModeNone
|
|
if sub != nil {
|
|
access = sub.ModeGiven & sub.ModeWant
|
|
}
|
|
if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
|
|
return nil, nil, false
|
|
}
|
|
return principal, sub, true
|
|
}
|
|
|
|
func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) {
|
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
|
if !ok {
|
|
return nil, "", false
|
|
}
|
|
if topic == "" {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
|
return nil, "", false
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, "", false
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
|
return nil, "", false
|
|
}
|
|
return principal, topic, true
|
|
}
|
|
|
|
func requireTopicWriter(principal *imPrincipal, topic string) error {
|
|
if principal == nil || topic == "" {
|
|
return types.ErrMalformed
|
|
}
|
|
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsWriter() {
|
|
return types.ErrPermissionDenied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyStringAnyMap(in map[string]any) map[string]any {
|
|
out := make(map[string]any, len(in))
|
|
for key, value := range in {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func stringFromAny(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
|
|
func defaultString(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func boolFromAny(value any) bool {
|
|
if got, ok := value.(bool); ok {
|
|
return got
|
|
}
|
|
if got, ok := value.(string); ok {
|
|
return got == "true" || got == "1"
|
|
}
|
|
return false
|
|
}
|
|
|
|
func intFromAny(value any) int {
|
|
switch got := value.(type) {
|
|
case int:
|
|
return got
|
|
case int64:
|
|
return int(got)
|
|
case float64:
|
|
return int(got)
|
|
case json.Number:
|
|
parsed, _ := got.Int64()
|
|
return int(parsed)
|
|
case string:
|
|
parsed, _ := strconv.Atoi(got)
|
|
return parsed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func imMessageType(msg *types.Message) string {
|
|
if msg == nil {
|
|
return "text"
|
|
}
|
|
for _, key := range []string{"type", "x-im-type"} {
|
|
if value := strings.TrimSpace(fmt.Sprint(msg.Head[key])); value != "" && value != "<nil>" {
|
|
return value
|
|
}
|
|
}
|
|
if content, ok := msg.Content.(map[string]any); ok {
|
|
if fileID := stringFromAny(content["file_id"]); fileID != "" {
|
|
mime := stringFromAny(content["mime"])
|
|
switch {
|
|
case strings.HasPrefix(mime, "image/"):
|
|
return "image"
|
|
case strings.HasPrefix(mime, "video/"):
|
|
return "video"
|
|
case strings.HasPrefix(mime, "audio/"):
|
|
return "voice"
|
|
default:
|
|
return "file"
|
|
}
|
|
}
|
|
if stringFromAny(content["url"]) != "" {
|
|
return "link"
|
|
}
|
|
}
|
|
return "text"
|
|
}
|
|
|
|
func favoriteSnapshot(msg *types.Message) map[string]any {
|
|
if msg == nil {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{
|
|
"topic": msg.Topic,
|
|
"seq": msg.SeqId,
|
|
"from": msg.From,
|
|
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"type": imMessageType(msg),
|
|
"head": msg.Head,
|
|
"content": msg.Content,
|
|
"text": messageText(*msg),
|
|
}
|
|
}
|
|
|
|
func favoriteAttachmentRefs(msg *types.Message) any {
|
|
if msg == nil {
|
|
return []string{}
|
|
}
|
|
if content, ok := msg.Content.(map[string]any); ok {
|
|
if fileID := stringFromAny(content["file_id"]); fileID != "" {
|
|
return []string{fileID}
|
|
}
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string]any {
|
|
payload := map[string]any{
|
|
"favorite_id": item.FavoriteID,
|
|
"source": map[string]any{
|
|
"topic": item.SourceTopic,
|
|
"seq": item.SourceSeq,
|
|
},
|
|
"snapshot": item.Snapshot,
|
|
"forwarded": true,
|
|
}
|
|
if refs, ok := item.AttachmentRefs.([]string); ok && len(refs) > 0 {
|
|
payload["attachment_refs"] = refs
|
|
} else if item.AttachmentRefs != nil {
|
|
payload["attachment_refs"] = item.AttachmentRefs
|
|
}
|
|
if comment != "" {
|
|
payload["comment"] = comment
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func classifyIMURLRisk(rawURL string) (decision, riskLevel, reason string) {
|
|
if rawURL == "" || len(rawURL) > 2048 {
|
|
return "block", "high", "invalid_url"
|
|
}
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return "block", "high", "invalid_url"
|
|
}
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return "block", "high", "unsupported_scheme"
|
|
}
|
|
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
|
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
|
|
return "block", "high", "private_host"
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
|
return "block", "high", "private_host"
|
|
}
|
|
}
|
|
normalized := strings.ToLower(rawURL)
|
|
for _, marker := range []string{"javascript:", "data:", "file:", "password=", "passwd=", "token=", "access_token=", "xn--"} {
|
|
if strings.Contains(normalized, marker) {
|
|
return "warn", "medium", "suspicious_url"
|
|
}
|
|
}
|
|
return "allow", "low", "ok"
|
|
}
|
|
|
|
func shortTokenHash(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])[:16]
|
|
}
|
|
|
|
func messageText(msg types.Message) string {
|
|
switch content := msg.Content.(type) {
|
|
case string:
|
|
return content
|
|
case map[string]any:
|
|
for _, key := range []string{"text", "content", "title", "summary", "name", "url"} {
|
|
if value := stringFromAny(content[key]); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
encoded, _ := json.Marshal(msg.Content)
|
|
return string(encoded)
|
|
}
|
|
|
|
func messageAvailability(msg types.Message) string {
|
|
if msg.DeletedAt != nil || msg.RecalledAt != nil {
|
|
return "removed"
|
|
}
|
|
return "available"
|
|
}
|
|
|
|
func textSnippet(text, query string) string {
|
|
text = strings.TrimSpace(text)
|
|
if len([]rune(text)) <= 120 || strings.TrimSpace(query) == "" {
|
|
return text
|
|
}
|
|
lower := strings.ToLower(text)
|
|
idx := strings.Index(lower, strings.ToLower(strings.TrimSpace(query)))
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
start := idx - 40
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := start + 120
|
|
runes := []rune(text)
|
|
if end > len(runes) {
|
|
end = len(runes)
|
|
}
|
|
return string(runes[start:end])
|
|
}
|
|
|
|
func textMatches(text, query string) []map[string]any {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return []map[string]any{}
|
|
}
|
|
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
|
|
if idx < 0 {
|
|
return []map[string]any{}
|
|
}
|
|
return []map[string]any{{"start": idx, "end": idx + len(query)}}
|
|
}
|
|
|
|
func validIMRealName(name, idType, idNumber string) bool {
|
|
if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" {
|
|
return false
|
|
}
|
|
return regexp.MustCompile(`^[0-9]{17}[0-9X]$`).MatchString(idNumber)
|
|
}
|
|
|
|
func protectIMSensitive(tenantID types.TenantID, value string) string {
|
|
return base64.RawStdEncoding.EncodeToString([]byte(hashIMSecret(tenantID, value, "sensitive")))
|
|
}
|
|
|
|
func maskIMName(name string) string {
|
|
runes := []rune(name)
|
|
if len(runes) <= 1 {
|
|
return name
|
|
}
|
|
return string(runes[0]) + "*"
|
|
}
|
|
|
|
func maskIMIDNumber(id string) string {
|
|
if len(id) <= 8 {
|
|
return id
|
|
}
|
|
return id[:3] + strings.Repeat("*", len(id)-7) + id[len(id)-4:]
|
|
}
|
|
|
|
func validIMIndependentPassword(password string) bool {
|
|
if !regexp.MustCompile(`^[0-9]{4}$`).MatchString(password) {
|
|
return false
|
|
}
|
|
if password[0] == password[1] && password[1] == password[2] && password[2] == password[3] {
|
|
return false
|
|
}
|
|
ascending, descending := true, true
|
|
for i := 1; i < len(password); i++ {
|
|
ascending = ascending && password[i] == password[i-1]+1
|
|
descending = descending && password[i]+1 == password[i-1]
|
|
}
|
|
return !ascending && !descending
|
|
}
|
|
|
|
func decodeIMJSONBody(wrt http.ResponseWriter, req *http.Request, dst any) bool {
|
|
if req.Body == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
}
|
|
dec := json.NewDecoder(req.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(dst); err != nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
} else if err == nil {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCode string) (*types.Tenant, bool) {
|
|
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
|
|
if tenantCode == "" {
|
|
tenantCode = strings.TrimSpace(fallbackCode)
|
|
}
|
|
if tenantCode == "" || len(tenantCode) > 64 {
|
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
|
|
return nil, false
|
|
}
|
|
if store.Tenants == nil {
|
|
logs.Err.Println("im http: tenant store is not initialized")
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, false
|
|
}
|
|
tenant, err := store.Tenants.GetByCode(tenantCode)
|
|
if err != nil {
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return nil, false
|
|
}
|
|
logs.Warn.Println("im http: failed to resolve tenant", tenantCode, err)
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
return nil, false
|
|
}
|
|
if !tenant.IsActive() {
|
|
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
|
|
return nil, false
|
|
}
|
|
return tenant, true
|
|
}
|
|
|
|
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
|
|
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
|
|
Code: 0,
|
|
Data: data,
|
|
RequestID: req.Header.Get("X-Request-ID"),
|
|
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func writeIMError(wrt http.ResponseWriter, req *http.Request, status, code int, reason, message string, details any) {
|
|
writeIMJSON(wrt, status, imHTTPResponse{
|
|
Code: code,
|
|
Reason: reason,
|
|
Message: message,
|
|
Details: details,
|
|
RequestID: req.Header.Get("X-Request-ID"),
|
|
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func writeIMJSON(wrt http.ResponseWriter, status int, resp imHTTPResponse) {
|
|
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
wrt.WriteHeader(status)
|
|
if err := json.NewEncoder(wrt).Encode(resp); err != nil {
|
|
logs.Warn.Println("im http: failed to write response", err)
|
|
}
|
|
}
|
|
|
|
func isAllowedVerificationScene(scene string) bool {
|
|
switch scene {
|
|
case "register", "login", "reset_password", "sensitive_action":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeIMPhone(phone, country string) (string, error) {
|
|
if strings.TrimSpace(country) == "" {
|
|
country = "CN"
|
|
}
|
|
number, err := phonenumbers.Parse(strings.TrimSpace(phone), country)
|
|
if err != nil || !phonenumbers.IsValidNumber(number) {
|
|
return "", types.ErrMalformed
|
|
}
|
|
return phonenumbers.Format(number, phonenumbers.E164), nil
|
|
}
|
|
|
|
func maskIMPhone(phone string) string {
|
|
if len(phone) <= 7 {
|
|
return phone
|
|
}
|
|
return phone[:4] + "****" + phone[len(phone)-3:]
|
|
}
|
|
|
|
func randomIMNumericCode(length int) (string, error) {
|
|
max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
|
|
n, err := rand.Int(rand.Reader, max)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
code := fmt.Sprintf("%0*d", length, n.Int64())
|
|
return code, nil
|
|
}
|
|
|
|
func randomIMToken(size int) (string, error) {
|
|
buf := make([]byte, size)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
func hashIMSecret(tenantID types.TenantID, parts ...string) string {
|
|
h := sha256.New()
|
|
h.Write(globals.apiKeySalt)
|
|
h.Write([]byte(fmt.Sprintf(":%d:", tenantID)))
|
|
for _, part := range parts {
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(part))
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
func generateIMQRCodeToken(tenantID types.TenantID, uid types.Uid, purpose string, expiresAt time.Time) (string, error) {
|
|
return generateIMQRCodeTokenForTopic(tenantID, uid, "", purpose, expiresAt)
|
|
}
|
|
|
|
func generateIMQRCodeTokenForTopic(tenantID types.TenantID, uid types.Uid, topic, purpose string, expiresAt time.Time) (string, error) {
|
|
nonce, err := randomIMToken(12)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
claims := imQRCodeClaims{
|
|
Version: 1,
|
|
TenantID: tenantID,
|
|
UserID: uid.UserId(),
|
|
Topic: topic,
|
|
Purpose: purpose,
|
|
Expires: expiresAt.Unix(),
|
|
Nonce: nonce,
|
|
}
|
|
payload, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
|
mac := signIMQRCodePayload(encodedPayload)
|
|
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac), nil
|
|
}
|
|
|
|
func parseIMQRCodeToken(token, expectedPurpose string, now time.Time) (*imQRCodeClaims, error) {
|
|
payload, signature, ok := strings.Cut(token, ".")
|
|
if !ok || payload == "" || signature == "" {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
got, err := base64.RawURLEncoding.DecodeString(signature)
|
|
if err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
want := signIMQRCodePayload(payload)
|
|
if !hmac.Equal(got, want) {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
var claims imQRCodeClaims
|
|
if err = json.Unmarshal(raw, &claims); err != nil {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
if claims.Version != 1 || claims.TenantID == 0 || claims.UserID == "" || claims.Purpose == "" || claims.Expires <= now.Unix() {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
if expectedPurpose != "" && claims.Purpose != expectedPurpose {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
return &claims, nil
|
|
}
|
|
|
|
func signIMQRCodePayload(payload string) []byte {
|
|
mac := hmac.New(sha256.New, globals.apiKeySalt)
|
|
mac.Write([]byte("im:qrcode:v1"))
|
|
mac.Write([]byte{0})
|
|
mac.Write([]byte(payload))
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
func verificationChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/auth/verification-codes/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func passwordResetChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/auth/password-reset/challenges/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func securityChallengeIDFromPath(path string) string {
|
|
const marker = "/v1/im/security/challenges/"
|
|
idx := strings.Index(path, marker)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], marker)
|
|
challengeID, suffix, ok := strings.Cut(rest, "/")
|
|
if !ok || suffix != "verify" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(challengeID)
|
|
}
|
|
|
|
func 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 messageReceiptsPath(path string) (string, int, 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.Atoi(seqValue)
|
|
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 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, int, 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.Atoi(parts[2])
|
|
if err != nil || seq <= 0 {
|
|
return "", 0, false
|
|
}
|
|
return parts[0], seq, true
|
|
}
|
|
|
|
func messageActionPath(path, action string) (string, int, 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.Atoi(seqValue)
|
|
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 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) (types.Uid, string, bool) {
|
|
const prefix = "/v1/im/users/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return types.ZeroUid, "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
userID, tail, ok := strings.Cut(rest, "/")
|
|
if !ok || userID == "" || tail == "" {
|
|
return types.ZeroUid, "", false
|
|
}
|
|
return types.ParseUserId(strings.TrimSpace(userID)), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func organizationDepartmentResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/organization/departments/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
departmentID, tail, ok := strings.Cut(rest, "/")
|
|
if !ok || departmentID == "" || tail == "" {
|
|
return "", "", false
|
|
}
|
|
return strings.TrimSpace(departmentID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func meetingResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/meetings/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
meetingID, tail, ok := strings.Cut(rest, "/")
|
|
if meetingID == "" {
|
|
return "", "", false
|
|
}
|
|
if !ok {
|
|
return strings.TrimSpace(meetingID), "", true
|
|
}
|
|
return strings.TrimSpace(meetingID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func dailyReportResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/reports/daily/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
reportID, tail, ok := strings.Cut(rest, "/")
|
|
if reportID == "" {
|
|
return "", "", false
|
|
}
|
|
if !ok {
|
|
return strings.TrimSpace(reportID), "", true
|
|
}
|
|
return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func monthlyReportResourcePath(path string) (string, string, bool) {
|
|
const prefix = "/v1/im/reports/monthly/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
reportID, tail, ok := strings.Cut(rest, "/")
|
|
if reportID == "" {
|
|
return "", "", false
|
|
}
|
|
if !ok {
|
|
return strings.TrimSpace(reportID), "", true
|
|
}
|
|
return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true
|
|
}
|
|
|
|
func groupMemberProfilePath(tail string) (types.Uid, bool) {
|
|
parts := strings.Split(strings.Trim(tail, "/"), "/")
|
|
if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" {
|
|
return types.ZeroUid, false
|
|
}
|
|
uid := types.ParseUserId(strings.TrimSpace(parts[1]))
|
|
return uid, !uid.IsZero()
|
|
}
|
|
|
|
func groupJoinRequestActionPath(tail string) (string, string, bool) {
|
|
parts := strings.Split(strings.Trim(tail, "/"), "/")
|
|
if len(parts) != 3 || parts[0] != "join-requests" || parts[1] == "" {
|
|
return "", "", false
|
|
}
|
|
switch parts[2] {
|
|
case "accept", "reject":
|
|
return strings.TrimSpace(parts[1]), parts[2], true
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func friendRequestActionFromPath(path string) (string, string) {
|
|
const prefix = "/v1/im/friend-requests/"
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return "", ""
|
|
}
|
|
rest := strings.TrimPrefix(path[idx:], prefix)
|
|
requestID, action, ok := strings.Cut(rest, "/")
|
|
if !ok {
|
|
return "", ""
|
|
}
|
|
return strings.TrimSpace(requestID), strings.TrimSpace(action)
|
|
}
|
|
|
|
func userIDFromTail(path, prefix string) types.Uid {
|
|
idx := strings.Index(path, prefix)
|
|
if idx < 0 {
|
|
return types.ZeroUid
|
|
}
|
|
return types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(path[idx:], prefix)))
|
|
}
|
|
|
|
func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
|
|
if types.GetTopicCat(topic) != types.TopicCatP2P || uid.IsZero() {
|
|
return types.ZeroUid
|
|
}
|
|
prefix := "p2p"
|
|
body := strings.TrimPrefix(topic, prefix)
|
|
uidLen := len(uid.String())
|
|
if len(body) < 2*uidLen {
|
|
return types.ZeroUid
|
|
}
|
|
left := types.ParseUid(body[:uidLen])
|
|
right := types.ParseUid(body[uidLen:])
|
|
if left == uid {
|
|
return right
|
|
}
|
|
if right == uid {
|
|
return left
|
|
}
|
|
return types.ZeroUid
|
|
}
|