* 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>
76 lines
1.7 KiB
Go
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)
|
|
})
|
|
}
|