Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
529 lines
17 KiB
Go
529 lines
17 KiB
Go
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)
|
||
})
|
||
}
|