feat: add redis cache and fanout support
This commit is contained in:
+90
-15
@@ -60,6 +60,7 @@ const (
|
||||
imVerificationExpiresInSeconds = 300
|
||||
imVerificationResendAfterSeconds = 60
|
||||
imVerificationMaxAttempts = 5
|
||||
imVerificationCreateRateWindow = time.Hour
|
||||
imActionTokenExpiresInSeconds = 300
|
||||
imIndependentPasswordMaxAttempts = 5
|
||||
imIndependentPasswordLockSeconds = 300
|
||||
@@ -380,7 +381,7 @@ type imMessageRevisionRequest struct {
|
||||
type imFavoritesBatchCreateRequest struct {
|
||||
Messages []struct {
|
||||
Topic string `json:"topic"`
|
||||
Seq int `json:"seq,string"`
|
||||
Seq int64 `json:"seq,string"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
|
||||
@@ -479,7 +480,7 @@ type imGroupFileCreateRequest struct {
|
||||
FileType string `json:"file_type,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
SourceTopic string `json:"source_topic,omitempty"`
|
||||
SourceSeq int `json:"source_seq,omitempty"`
|
||||
SourceSeq int64 `json:"source_seq,omitempty"`
|
||||
}
|
||||
|
||||
type imGroupAnnouncementPutRequest struct {
|
||||
@@ -874,6 +875,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
|
||||
if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
|
||||
logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err)
|
||||
} else if !allowed {
|
||||
retryAfter := int(ttl.Seconds())
|
||||
if retryAfter < 1 {
|
||||
retryAfter = int(imVerificationCreateRateWindow.Seconds())
|
||||
}
|
||||
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
||||
"retry_after_seconds": retryAfter,
|
||||
})
|
||||
return
|
||||
}
|
||||
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
|
||||
@@ -3342,7 +3355,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
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})
|
||||
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
@@ -3599,7 +3612,7 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
peer := peerFromP2PTopic(topic, principal.Uid)
|
||||
topic, peer := normalizeP2PConversationTopic(topic, principal.Uid)
|
||||
if topic == "" || peer.IsZero() {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
||||
return
|
||||
@@ -3617,6 +3630,7 @@ func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, pri
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
||||
return
|
||||
}
|
||||
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
|
||||
@@ -4043,6 +4057,11 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
|
||||
roleFilter := strings.TrimSpace(req.URL.Query().Get("role"))
|
||||
eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role"))
|
||||
muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible"))
|
||||
cacheKey := groupMemberPageCacheKey(topic, principal.Uid, selfAccess, query, roleFilter, eligibleRole, limit, cursor, mentionableOnly, muteEligible)
|
||||
if cached, ok := store.GetCachedGroupMemberPage(principal.TenantID, cacheKey); ok {
|
||||
writeIMSuccess(wrt, req, cached)
|
||||
return
|
||||
}
|
||||
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
@@ -4093,12 +4112,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
|
||||
permissions := map[string]any{
|
||||
"can_mention_all": selfAccess.IsAdmin(),
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
payload := map[string]any{
|
||||
"topic": topic,
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"permissions": permissions,
|
||||
})
|
||||
}
|
||||
store.CacheGroupMemberPage(principal.TenantID, cacheKey, payload)
|
||||
writeIMSuccess(wrt, req, payload)
|
||||
}
|
||||
|
||||
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
|
||||
@@ -5767,6 +5788,18 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
|
||||
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
|
||||
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
|
||||
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
|
||||
} else if !allowed {
|
||||
retryAfter := int(ttl.Seconds())
|
||||
if retryAfter < 1 {
|
||||
retryAfter = int(imVerificationCreateRateWindow.Seconds())
|
||||
}
|
||||
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
||||
"retry_after_seconds": retryAfter,
|
||||
})
|
||||
return
|
||||
}
|
||||
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
@@ -6712,7 +6745,23 @@ func groupMemberMatches(item map[string]any, query string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) {
|
||||
func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string,
|
||||
limit, cursor int, mentionableOnly, muteEligible bool) string {
|
||||
return strings.Join([]string{
|
||||
topic,
|
||||
uid.UserId(),
|
||||
access.String(),
|
||||
strings.ToLower(strings.TrimSpace(query)),
|
||||
strings.TrimSpace(roleFilter),
|
||||
strings.TrimSpace(eligibleRole),
|
||||
strconv.Itoa(limit),
|
||||
strconv.Itoa(cursor),
|
||||
strconv.FormatBool(mentionableOnly),
|
||||
strconv.FormatBool(muteEligible),
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, allowAdmin bool) (*types.Message, bool, error) {
|
||||
if principal == nil {
|
||||
return nil, false, types.ErrPermissionDenied
|
||||
}
|
||||
@@ -8385,7 +8434,7 @@ func cleanTenantCodeHint(code string) string {
|
||||
return code
|
||||
}
|
||||
|
||||
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) {
|
||||
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) {
|
||||
targetType := strings.ToLower(strings.TrimSpace(target.Type))
|
||||
snapshot := map[string]any{"type": targetType}
|
||||
switch targetType {
|
||||
@@ -8413,7 +8462,7 @@ func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target
|
||||
return targetType, 0, topic, 0, snapshot, nil
|
||||
case "message":
|
||||
topic := strings.TrimSpace(target.Topic)
|
||||
seq, err := strconv.Atoi(strings.TrimSpace(target.Seq))
|
||||
seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64)
|
||||
if topic == "" || seq <= 0 || err != nil {
|
||||
return "", 0, "", 0, nil, types.ErrMalformed
|
||||
}
|
||||
@@ -8476,7 +8525,7 @@ func groupInvitationResourcePath(path string) (string, string, bool) {
|
||||
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
|
||||
}
|
||||
|
||||
func messageReceiptsPath(path string) (string, int, bool) {
|
||||
func messageReceiptsPath(path string) (string, int64, bool) {
|
||||
const marker = "/v1/im/topics/"
|
||||
idx := strings.Index(path, marker)
|
||||
if idx < 0 {
|
||||
@@ -8491,7 +8540,7 @@ func messageReceiptsPath(path string) (string, int, bool) {
|
||||
if !ok || suffix != "receipts" {
|
||||
return "", 0, false
|
||||
}
|
||||
seq, err := strconv.Atoi(seqValue)
|
||||
seq, err := strconv.ParseInt(seqValue, 10, 64)
|
||||
if err != nil {
|
||||
return topic, 0, true
|
||||
}
|
||||
@@ -8531,7 +8580,7 @@ func topicFromPathWithSuffix(path, prefix, suffix string) string {
|
||||
return strings.Trim(rest, "/")
|
||||
}
|
||||
|
||||
func messageContextPath(path string) (string, int, bool) {
|
||||
func messageContextPath(path string) (string, int64, bool) {
|
||||
const prefix = "/v1/im/topics/"
|
||||
idx := strings.Index(path, prefix)
|
||||
if idx < 0 {
|
||||
@@ -8542,14 +8591,14 @@ func messageContextPath(path string) (string, int, bool) {
|
||||
if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" {
|
||||
return "", 0, false
|
||||
}
|
||||
seq, err := strconv.Atoi(parts[2])
|
||||
seq, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil || seq <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return parts[0], seq, true
|
||||
}
|
||||
|
||||
func messageActionPath(path, action string) (string, int, bool) {
|
||||
func messageActionPath(path, action string) (string, int64, bool) {
|
||||
const marker = "/v1/im/topics/"
|
||||
idx := strings.Index(path, marker)
|
||||
if idx < 0 {
|
||||
@@ -8564,7 +8613,7 @@ func messageActionPath(path, action string) (string, int, bool) {
|
||||
if !ok || suffix != action {
|
||||
return "", 0, false
|
||||
}
|
||||
seq, err := strconv.Atoi(seqValue)
|
||||
seq, err := strconv.ParseInt(seqValue, 10, 64)
|
||||
if err != nil {
|
||||
return topic, 0, true
|
||||
}
|
||||
@@ -8756,3 +8805,29 @@ func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
|
||||
}
|
||||
return types.ZeroUid
|
||||
}
|
||||
|
||||
func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) {
|
||||
topic = strings.TrimSpace(topic)
|
||||
if len(topic) < 3 || uid.IsZero() {
|
||||
return "", types.ZeroUid
|
||||
}
|
||||
if types.GetTopicCat(topic) == types.TopicCatMe {
|
||||
peer := types.ParseUserId(topic)
|
||||
if peer.IsZero() || peer == uid {
|
||||
return "", types.ZeroUid
|
||||
}
|
||||
return uid.P2PName(peer), peer
|
||||
}
|
||||
peer := peerFromP2PTopic(topic, uid)
|
||||
if peer.IsZero() {
|
||||
return "", types.ZeroUid
|
||||
}
|
||||
return topic, peer
|
||||
}
|
||||
|
||||
func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
|
||||
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
|
||||
return normalized
|
||||
}
|
||||
return strings.TrimSpace(topic)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user