packages.wenpai.net/internal/http/api_rate_limit.go
Ben Word db40af2ea8
Add GET /api/stats endpoint for total downloads badge (#53)
* Add GET /api/stats endpoint for total downloads badge

Public JSON endpoint that reads the existing package_stats singleton
row (same data source as the admin dashboard). Includes per-IP rate
limiting (10 req/min) and Cache-Control: public, max-age=300 so
CDN/shields.io caching reduces actual hits further.

Response shape:
  { total_installs, installs_30d, active_plugins, active_themes, total_packages }

https://claude.ai/code/session_019NzdEnnohsqdqyuw5VXUgy

* Use golang.org/x/time/rate for API rate limiting and add installs badge

Replace custom token-counting rate limiter with per-IP rate.Limiter
instances from golang.org/x/time/rate. Add total composer installs
badge to README.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Strip port from RemoteAddr before rate limiting

Use clientIP to normalize host:port to just the host, so varying
source ports from the same IP share a single rate limiter. Update
middleware test to use realistic host:port addresses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Show exact install counts with per-type breakdown on admin dashboard

- Display exact numbers with commas instead of rounded "24K"
- Show plugin and theme install counts separately
- Show timestamp in CST instead of relative time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-23 15:08:31 -05:00

76 lines
1.7 KiB
Go

package http
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
const (
apiRateLimitPerIP = 10 // burst size per IP
apiRateLimitWindow = 1 * time.Minute
apiRateLimiterSweepEvery = 5 * time.Minute
)
type apiRateLimiter struct {
mu sync.Mutex
limiters map[string]*apiLimiterEntry
lastCleanup time.Time
}
type apiLimiterEntry struct {
limiter *rate.Limiter
lastSeen time.Time
}
func newAPIRateLimiter() *apiRateLimiter {
return &apiRateLimiter{
limiters: make(map[string]*apiLimiterEntry),
}
}
func (l *apiRateLimiter) limiterFor(ip string) *rate.Limiter {
l.mu.Lock()
defer l.mu.Unlock()
l.sweepLocked(time.Now())
entry, ok := l.limiters[ip]
if !ok {
// Refill rate: apiRateLimitPerIP tokens over apiRateLimitWindow.
limiter := rate.NewLimiter(rate.Every(apiRateLimitWindow/apiRateLimitPerIP), apiRateLimitPerIP)
l.limiters[ip] = &apiLimiterEntry{limiter: limiter, lastSeen: time.Now()}
return limiter
}
entry.lastSeen = time.Now()
return entry.limiter
}
func (l *apiRateLimiter) sweepLocked(now time.Time) {
if !l.lastCleanup.IsZero() && now.Sub(l.lastCleanup) < apiRateLimiterSweepEvery {
return
}
for ip, entry := range l.limiters {
if now.Sub(entry.lastSeen) > apiRateLimitWindow*2 {
delete(l.limiters, ip)
}
}
l.lastCleanup = now
}
// RateLimit wraps a handler and rejects requests that exceed the per-IP limit.
func (l *apiRateLimiter) RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if ip != "" && !l.limiterFor(ip).Allow() {
w.Header().Set("Retry-After", "60")
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}