feat(api): tiered cumulative quota + self-service registration
Anonymous callers get a free cumulative quota (1000 calls per IP); once exhausted they get 403 quota_exhausted and must register. Public users can self-register (email+password) to obtain a higher-quota API key, view usage, and regenerate the key. Quota counters live in Redis; the public API stays read-only except for the registration writes. - migration 0011: app_user table + api_key.quota_total + 'registered' tier - ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters - middleware: enforce cumulative quota + X-Quota-* headers - store: RegisterUser/Authenticate/RegenerateKey (bcrypt) - handlers: POST /api/v1/register, /account, /account/regenerate - admin: quota_total column + registered tier - public: 'API 密钥' account page + API docs quota section Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -37,20 +37,43 @@ const (
|
||||
// defaultAnonLimit is the per-minute request budget for unauthenticated
|
||||
// callers (identified by client IP) when none is configured.
|
||||
defaultAnonLimit = 60
|
||||
|
||||
// defaultAnonTotalQuota is the lifetime number of calls an anonymous caller
|
||||
// (by IP) may make before being asked to register for a higher quota.
|
||||
defaultAnonTotalQuota = 1000
|
||||
|
||||
// defaultRegRatePerMin / defaultRegQuotaTotal are the per-minute budget and
|
||||
// cumulative quota granted to a self-registered API key.
|
||||
defaultRegRatePerMin = 300
|
||||
defaultRegQuotaTotal = 100000
|
||||
|
||||
// registerRatePerMin caps account registration/login attempts per IP to
|
||||
// curb abuse; these endpoints sit outside the metered quota group.
|
||||
registerRatePerMin = 10
|
||||
)
|
||||
|
||||
// Handler holds dependencies shared by the HTTP routes.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
spa fs.FS
|
||||
limiter *ratelimit.Limiter
|
||||
anonLimit int
|
||||
store *store.Store
|
||||
spa fs.FS
|
||||
limiter *ratelimit.Limiter
|
||||
anonLimit int
|
||||
anonTotalQuota int64
|
||||
regRatePerMin int
|
||||
regQuotaTotal int64
|
||||
}
|
||||
|
||||
// New constructs a Handler backed by the given store. spa may be nil (JSON-only).
|
||||
// Rate limiting is disabled until WithRateLimit is called.
|
||||
func New(s *store.Store, spa fs.FS) *Handler {
|
||||
return &Handler{store: s, spa: spa, anonLimit: defaultAnonLimit}
|
||||
return &Handler{
|
||||
store: s,
|
||||
spa: spa,
|
||||
anonLimit: defaultAnonLimit,
|
||||
anonTotalQuota: defaultAnonTotalQuota,
|
||||
regRatePerMin: defaultRegRatePerMin,
|
||||
regQuotaTotal: defaultRegQuotaTotal,
|
||||
}
|
||||
}
|
||||
|
||||
// WithRateLimit attaches a Redis-backed limiter and the anonymous per-minute
|
||||
@@ -64,6 +87,22 @@ func (h *Handler) WithRateLimit(l *ratelimit.Limiter, anonPerMin int) *Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithQuotas configures the cumulative free quota for anonymous callers and the
|
||||
// per-minute rate + cumulative quota self-registered keys receive. Non-positive
|
||||
// values keep the defaults.
|
||||
func (h *Handler) WithQuotas(anonTotal, regPerMin, regTotal int) *Handler {
|
||||
if anonTotal > 0 {
|
||||
h.anonTotalQuota = int64(anonTotal)
|
||||
}
|
||||
if regPerMin > 0 {
|
||||
h.regRatePerMin = regPerMin
|
||||
}
|
||||
if regTotal > 0 {
|
||||
h.regQuotaTotal = int64(regTotal)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
||||
func (h *Handler) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
@@ -91,6 +130,16 @@ func (h *Handler) Router() http.Handler {
|
||||
r.Get("/sources/{id}", h.SourceByID)
|
||||
r.Get("/stats", h.Stats)
|
||||
})
|
||||
|
||||
// Self-service account routes. Lightly IP-throttled to curb abuse but
|
||||
// outside the metered quota group so a user can always register or
|
||||
// check their key even after exhausting the free anonymous quota.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.registerLimit)
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/account", h.AccountInfo)
|
||||
r.Post("/account/regenerate", h.RegenerateKey)
|
||||
})
|
||||
})
|
||||
|
||||
// Public SPA (homepage + search + contribute). API routes above take
|
||||
|
||||
Reference in New Issue
Block a user