* 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>
115 lines
2.7 KiB
Go
115 lines
2.7 KiB
Go
package http
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestAPIRateLimiter_AllowsUnderLimit(t *testing.T) {
|
|
l := newAPIRateLimiter()
|
|
|
|
for i := 0; i < apiRateLimitPerIP; i++ {
|
|
if !l.limiterFor("1.2.3.4").Allow() {
|
|
t.Fatalf("request %d should be allowed", i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIRateLimiter_BlocksOverLimit(t *testing.T) {
|
|
l := newAPIRateLimiter()
|
|
|
|
for i := 0; i < apiRateLimitPerIP; i++ {
|
|
l.limiterFor("1.2.3.4").Allow()
|
|
}
|
|
|
|
if l.limiterFor("1.2.3.4").Allow() {
|
|
t.Error("request over limit should be blocked")
|
|
}
|
|
}
|
|
|
|
func TestAPIRateLimiter_IndependentPerIP(t *testing.T) {
|
|
l := newAPIRateLimiter()
|
|
|
|
// Exhaust limit for one IP
|
|
for i := 0; i < apiRateLimitPerIP; i++ {
|
|
l.limiterFor("1.2.3.4").Allow()
|
|
}
|
|
|
|
// Different IP should still be allowed
|
|
if !l.limiterFor("5.6.7.8").Allow() {
|
|
t.Error("different IP should not be affected")
|
|
}
|
|
}
|
|
|
|
func TestAPIRateLimiter_EmptyIPAllowed(t *testing.T) {
|
|
l := newAPIRateLimiter()
|
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
handler := l.RateLimit(inner)
|
|
|
|
req := httptest.NewRequest("GET", "/api/stats", nil)
|
|
req.RemoteAddr = ""
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("empty IP should always be allowed, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAPIRateLimiter_Middleware(t *testing.T) {
|
|
l := newAPIRateLimiter()
|
|
called := 0
|
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called++
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
handler := l.RateLimit(inner)
|
|
|
|
// Use varying source ports to verify the middleware normalizes to IP only.
|
|
for i := 0; i < apiRateLimitPerIP+2; i++ {
|
|
req := httptest.NewRequest("GET", "/api/stats", nil)
|
|
req.RemoteAddr = fmt.Sprintf("10.0.0.1:%d", 10000+i)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if i < apiRateLimitPerIP {
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("request %d: got %d, want 200", i+1, w.Code)
|
|
}
|
|
} else {
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("request %d: got %d, want 429", i+1, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
if called != apiRateLimitPerIP {
|
|
t.Errorf("inner handler called %d times, want %d", called, apiRateLimitPerIP)
|
|
}
|
|
}
|
|
|
|
func TestAPIRateLimiter_Sweep(t *testing.T) {
|
|
l := newAPIRateLimiter()
|
|
|
|
l.limiterFor("1.2.3.4")
|
|
|
|
// Manually set lastSeen far in the past to trigger sweep
|
|
l.mu.Lock()
|
|
l.limiters["1.2.3.4"].lastSeen = l.limiters["1.2.3.4"].lastSeen.Add(-(apiRateLimiterSweepEvery + apiRateLimitWindow*2 + 1))
|
|
l.lastCleanup = l.limiters["1.2.3.4"].lastSeen
|
|
l.mu.Unlock()
|
|
|
|
l.limiterFor("5.6.7.8") // triggers sweep
|
|
|
|
l.mu.Lock()
|
|
_, exists := l.limiters["1.2.3.4"]
|
|
l.mu.Unlock()
|
|
|
|
if exists {
|
|
t.Error("stale entry should have been swept")
|
|
}
|
|
}
|