1972 lines
67 KiB
Go
1972 lines
67 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"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
|
|
)
|
|
|
|
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 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 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 imFriendRequestCreateRequest struct {
|
|
TargetUserID string `json:"target_user_id"`
|
|
Message string `json:"message,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
SourceTopic string `json:"source_topic,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 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 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 serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
|
|
switch {
|
|
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 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) {
|
|
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 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 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) {
|
|
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 serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodDelete {
|
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
if err := store.Relations.DeleteFriend(principal.TenantID, principal.Uid, peer, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
writeIMRelationError(wrt, req, err)
|
|
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 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 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 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),
|
|
"topic": req.ApplicantID.P2PName(req.TargetID),
|
|
}
|
|
if req.SourceTopic != "" {
|
|
data["source_topic"] = req.SourceTopic
|
|
}
|
|
if req.HandledAt != nil {
|
|
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return data
|
|
}
|
|
|
|
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.ErrMalformed):
|
|
writeIMError(wrt, req, http.StatusBadRequest, 20000, imReasonMalformedRequest, "malformed request", nil)
|
|
default:
|
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
}
|
|
}
|
|
|
|
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 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 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 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
|
|
}
|