Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HTTP rate limiters in this file are in-process (per API OS process / replica).
|
||||
//
|
||||
// MULTI-REPLICA CUTOVER (see docs/production-readiness.md § edge rate limits):
|
||||
// - There is no Redis (or other shared store) in the Descrybe stack today.
|
||||
// - Without edge caps, effective HTTP budget across N replicas is roughly
|
||||
// N× the per-process base RPM.
|
||||
// - RATE_LIMIT_REPLICAS=N (optional) divides only the HTTP middleware caps in
|
||||
// this file via rateLimitEffectiveCap (ceil) so aggregate under even load
|
||||
// approximates the documented RPM. It is not a shared counter and does not
|
||||
// affect login email lockout, processing.StartLimiter, support.AIRateLimiter,
|
||||
// or email send limiters — those stay per-process until edge/shared infra.
|
||||
// - Cutover for multi-replica hard global RPM: enforce cluster caps at the
|
||||
// edge (CDN/ingress/WAF). RATE_LIMIT_REPLICAS alone is not a substitute.
|
||||
// - RATE_LIMIT_MULTI_REPLICA=true acknowledges multi-replica deploy without a
|
||||
// shared backend; the API logs a boot warning (config.RateLimitWarningMessage).
|
||||
// - RATE_LIMIT_BACKEND=redis|postgres is accepted as documentation only and
|
||||
// forced to memory until a shared backend is implemented — do not assume
|
||||
// distributed counters exist.
|
||||
|
||||
// slidingWindowLimiter is a light in-process rate limiter (per-key).
|
||||
// Suitable for a single API instance; not shared across replicas.
|
||||
type slidingWindowLimiter struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
limit int
|
||||
hits map[string][]time.Time
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
func newSlidingWindowLimiter(limit int, window time.Duration) *slidingWindowLimiter {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
if window <= 0 {
|
||||
window = time.Minute
|
||||
}
|
||||
return &slidingWindowLimiter{
|
||||
window: window,
|
||||
limit: limit,
|
||||
hits: make(map[string][]time.Time),
|
||||
lastGC: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *slidingWindowLimiter) allow(key string) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-l.window)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if now.Sub(l.lastGC) > l.window {
|
||||
for k, ts := range l.hits {
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(l.hits, k)
|
||||
} else {
|
||||
l.hits[k] = kept
|
||||
}
|
||||
}
|
||||
l.lastGC = now
|
||||
}
|
||||
ts := l.hits[key]
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= l.limit {
|
||||
l.hits[key] = kept
|
||||
return false
|
||||
}
|
||||
l.hits[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// writeRateLimited responds 429 with Retry-After plus IETF RateLimit headers
|
||||
// (draft-ietf-httpapi-ratelimit-headers) so clients can back off before retrying.
|
||||
// On deny, remaining is always 0; t/w use the limiter window in seconds.
|
||||
func writeRateLimited(w http.ResponseWriter, limit, windowSec int) {
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if windowSec < 1 {
|
||||
windowSec = 60
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(windowSec))
|
||||
w.Header().Set("RateLimit", fmt.Sprintf(`"http";r=0;t=%d`, windowSec))
|
||||
w.Header().Set("RateLimit-Policy", fmt.Sprintf(`"http";q=%d;w=%d`, limit, windowSec))
|
||||
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
}
|
||||
|
||||
// rateLimitEffectiveCap divides a per-process HTTP base cap across RATE_LIMIT_REPLICAS
|
||||
// (ceil) so aggregate traffic under even load approximates the documented RPM.
|
||||
// replicas<=1 leaves the base unchanged (default single-instance behavior).
|
||||
// Scope: HTTP middleware in this file only — not lockout / StartLimiter / AI / email.
|
||||
func rateLimitEffectiveCap(base, replicas int) int {
|
||||
if base <= 0 {
|
||||
return 1
|
||||
}
|
||||
if replicas <= 1 {
|
||||
return base
|
||||
}
|
||||
n := (base + replicas - 1) / replicas
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Server) rateLimitReplicas() int {
|
||||
if s == nil || s.Config.RateLimitReplicas < 1 {
|
||||
return 1
|
||||
}
|
||||
return s.Config.RateLimitReplicas
|
||||
}
|
||||
|
||||
// heavyMutationRPM is the per-company HTTP budget for sync / process / export mutations.
|
||||
// In-process only (not shared across replicas). Counts requests, not products in a bulk body.
|
||||
const heavyMutationRPM = 30
|
||||
|
||||
func isHeavyFeedOrProcessMutation(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch path {
|
||||
case "/api/v1/process", "/api/v1/products/process", "/api/processing/jobs":
|
||||
return true
|
||||
default:
|
||||
if strings.HasSuffix(path, "/sync-process-sample") || strings.HasSuffix(path, "/extract-schema") {
|
||||
return true
|
||||
}
|
||||
// Export generate / selected-product export — heavy CPU + IO per request.
|
||||
if strings.Contains(path, "/export-feeds/") &&
|
||||
(strings.HasSuffix(path, "/generate") || strings.HasSuffix(path, "/export-products")) {
|
||||
return true
|
||||
}
|
||||
// Process job retries also consume StartLimiter capacity.
|
||||
if strings.HasSuffix(path, "/retry") &&
|
||||
(strings.Contains(path, "/processing/jobs/") || strings.Contains(path, "/process/")) {
|
||||
return true
|
||||
}
|
||||
// Feed sync downloads/parses remote content — throttle both /api and /api/v1.
|
||||
// Store connector syncs (/woocommerce/sync, /shopify/…) are intentionally excluded;
|
||||
// they use connector-specific workers and are not part of this shared bucket.
|
||||
return strings.HasSuffix(path, "/sync") && strings.Contains(path, "/feeds/")
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitV1Process throttles heavy process / feed sync / export mutations per company.
|
||||
// Limit is in-process (per API replica); set RATE_LIMIT_REPLICAS or prefer edge limits when running multiple replicas.
|
||||
func (s *Server) RateLimitV1Process(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(heavyMutationRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isHeavyFeedOrProcessMutation(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && cid != uuid.Nil {
|
||||
key = cid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// publicRPM is the per-IP budget for unauthenticated /api/public routes (plans, logos, …).
|
||||
const publicRPM = 30
|
||||
|
||||
// RateLimitPublic throttles unauthenticated /api/public routes per client IP
|
||||
// (RemoteAddr; rewritten only via TrustedRealIP when TRUSTED_PROXIES is set).
|
||||
func (s *Server) RateLimitPublic(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(publicRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimSpace(r.RemoteAddr)
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Public export token scraping budgets (in-process; see file header for replicas).
|
||||
const (
|
||||
publicExportIPRPM = 30 // well-formed export GETs per IP
|
||||
publicExportProbeRPM = 15 // invalid-shape token probes per IP (enumeration)
|
||||
publicExportTokenRPM = 30 // polls per public_token (known-token scrape)
|
||||
)
|
||||
|
||||
// RateLimitPublicExport throttles tokenized export GETs harder than generic /api/public.
|
||||
// Invalid token shapes are rejected here (no DB) and counted against a probe budget.
|
||||
func (s *Server) RateLimitPublicExport(next http.Handler) http.Handler {
|
||||
ipCap := rateLimitEffectiveCap(publicExportIPRPM, s.rateLimitReplicas())
|
||||
probeCap := rateLimitEffectiveCap(publicExportProbeRPM, s.rateLimitReplicas())
|
||||
tokenCap := rateLimitEffectiveCap(publicExportTokenRPM, s.rateLimitReplicas())
|
||||
ipLimiter := newSlidingWindowLimiter(ipCap, time.Minute)
|
||||
probeLimiter := newSlidingWindowLimiter(probeCap, time.Minute)
|
||||
tokenLimiter := newSlidingWindowLimiter(tokenCap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := strings.TrimSpace(r.RemoteAddr)
|
||||
if ip == "" {
|
||||
ip = "unknown"
|
||||
}
|
||||
token := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "token")))
|
||||
if !feeds.ValidPublicToken(token) {
|
||||
if !probeLimiter.allow(ip) {
|
||||
writeRateLimited(w, probeCap, 60)
|
||||
return
|
||||
}
|
||||
// Same body as writePublicExportError — no oracle for token existence.
|
||||
Error(w, http.StatusNotFound, "export feed not found")
|
||||
return
|
||||
}
|
||||
if !ipLimiter.allow(ip) {
|
||||
writeRateLimited(w, ipCap, 60)
|
||||
return
|
||||
}
|
||||
if !tokenLimiter.allow("t:" + token) {
|
||||
writeRateLimited(w, tokenCap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// API key surface budgets (in-process).
|
||||
const (
|
||||
apiKeyAttemptRPM = 60 // keyed /api/v1 requests per IP (brute-force / spray)
|
||||
apiKeyCompanyRPM = 120 // authenticated /api/v1 requests per company
|
||||
)
|
||||
|
||||
// RateLimitAPIKeyAttempts throttles /api/v1 requests that present an API key, per IP.
|
||||
// Mount before RequireAPIKey so invalid keys still consume budget.
|
||||
func (s *Server) RateLimitAPIKeyAttempts(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(apiKeyAttemptRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if extractAPIKey(r) == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(r.RemoteAddr)
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimitAPIKey throttles authenticated /api/v1 traffic per company (API4 abuse cap).
|
||||
func (s *Server) RateLimitAPIKey(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(apiKeyCompanyRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && cid != uuid.Nil {
|
||||
key = cid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isMarketingGenerateOrSend(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch {
|
||||
case path == "/api/seo/apply":
|
||||
return true
|
||||
case path == "/api/campaigns/generate":
|
||||
return true
|
||||
case strings.HasSuffix(path, "/generate") && strings.Contains(path, "/campaigns/"):
|
||||
return true
|
||||
case path == "/api/campaigns/send" || path == "/api/campaigns/send-test":
|
||||
return true
|
||||
case strings.HasSuffix(path, "/send") || strings.HasSuffix(path, "/send-test") || strings.HasSuffix(path, "/schedule"):
|
||||
return strings.Contains(path, "/campaigns/")
|
||||
case path == "/api/integrations/email/send" || path == "/api/email/send":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitMarketing throttles campaign generate/send and SEO AI apply per company.
|
||||
// In-process only (per API replica); set RATE_LIMIT_REPLICAS or prefer edge limits when running multiple replicas.
|
||||
func (s *Server) RateLimitMarketing(next http.Handler) http.Handler {
|
||||
genCap := rateLimitEffectiveCap(10, s.rateLimitReplicas())
|
||||
sendCap := rateLimitEffectiveCap(30, s.rateLimitReplicas())
|
||||
genLimiter := newSlidingWindowLimiter(genCap, time.Minute)
|
||||
sendLimiter := newSlidingWindowLimiter(sendCap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isMarketingGenerateOrSend(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && cid != uuid.Nil {
|
||||
key = cid.String()
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
limiter := sendLimiter
|
||||
cap := sendCap
|
||||
if strings.Contains(path, "generate") || path == "/api/seo/apply" {
|
||||
limiter = genLimiter
|
||||
cap = genCap
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
authLoginRPM = 10 // login / invite / set-password / sales-contact per IP
|
||||
authRegisterRPM = 5 // registration spam bucket (stricter than login)
|
||||
)
|
||||
|
||||
func isAuthRegister(r *http.Request) bool {
|
||||
return r.Method == http.MethodPost && strings.TrimSuffix(r.URL.Path, "/") == "/api/auth/register"
|
||||
}
|
||||
|
||||
func isAuthMutation(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSuffix(r.URL.Path, "/") {
|
||||
case "/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/invite-preview",
|
||||
"/api/auth/accept-invite",
|
||||
"/api/auth/complete-set-password",
|
||||
"/api/sales/contact":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitAuth throttles unauthenticated auth POSTs per client IP
|
||||
// (RemoteAddr; rewritten only via TrustedRealIP when TRUSTED_PROXIES is set).
|
||||
// Register uses a stricter bucket so login brute-force and signup spam do not share budget.
|
||||
func (s *Server) RateLimitAuth(next http.Handler) http.Handler {
|
||||
loginCap := rateLimitEffectiveCap(authLoginRPM, s.rateLimitReplicas())
|
||||
registerCap := rateLimitEffectiveCap(authRegisterRPM, s.rateLimitReplicas())
|
||||
loginLimiter := newSlidingWindowLimiter(loginCap, time.Minute)
|
||||
registerLimiter := newSlidingWindowLimiter(registerCap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAuthMutation(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(r.RemoteAddr)
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
limiter := loginLimiter
|
||||
cap := loginCap
|
||||
if isAuthRegister(r) {
|
||||
limiter = registerLimiter
|
||||
cap = registerCap
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// adminPlanFeaturesGetRPM caps GET /api/admin/plans/{id}/features per user.
|
||||
// In-process only; stops client refetch storms from saturating the API.
|
||||
const adminPlanFeaturesGetRPM = 60
|
||||
|
||||
func isAdminPlanFeaturesGet(r *http.Request) bool {
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
if !strings.HasPrefix(path, "/api/admin/plans/") {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(path, "/features")
|
||||
}
|
||||
|
||||
// RateLimitAdminPlanFeatures throttles repeated GET plan-feature matrix fetches per user.
|
||||
func (s *Server) RateLimitAdminPlanFeatures(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(adminPlanFeaturesGetRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAdminPlanFeaturesGet(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && uid != uuid.Nil {
|
||||
key = uid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// adminAnalyticsGetRPM caps expensive admin diagnostic/analytics GETs per user.
|
||||
const adminAnalyticsGetRPM = 20
|
||||
|
||||
func isAdminAnalyticsGet(r *http.Request) bool {
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
return path == "/api/admin/analytics" || path == "/api/admin/diagnostics"
|
||||
}
|
||||
|
||||
// RateLimitAdminAnalytics throttles expensive platform analytics/diagnostics reads per user.
|
||||
func (s *Server) RateLimitAdminAnalytics(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(adminAnalyticsGetRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAdminAnalyticsGet(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && uid != uuid.Nil {
|
||||
key = uid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// aiProbeRPM caps LLM/mail credential probes (cost / abuse).
|
||||
const aiProbeRPM = 10
|
||||
|
||||
func isAIOrMailProbePOST(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch {
|
||||
case path == "/api/integrations/ai/test":
|
||||
return true
|
||||
case path == "/api/admin/settings/mail/test":
|
||||
return true
|
||||
case strings.HasPrefix(path, "/api/admin/settings/ai-roles/") && strings.HasSuffix(path, "/test"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitAIProbes throttles AI/mail test probes per user (admin) or company (tenant).
|
||||
func (s *Server) RateLimitAIProbes(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(aiProbeRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAIOrMailProbePOST(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
key := "anon"
|
||||
if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil {
|
||||
key = "u:" + uid.String()
|
||||
} else if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil {
|
||||
key = "c:" + cid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user