packages.wenpai.net/internal/http/api_stats.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

35 lines
1 KiB
Go

package http
import (
"encoding/json"
"net/http"
"github.com/roots/wp-packages/internal/app"
)
type statsResponse struct {
TotalInstalls int64 `json:"total_installs"`
Installs30d int64 `json:"installs_30d"`
ActivePlugins int64 `json:"active_plugins"`
ActiveThemes int64 `json:"active_themes"`
TotalPackages int64 `json:"total_packages"`
}
func handleAPIStats(a *app.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var s statsResponse
err := a.DB.QueryRowContext(r.Context(),
`SELECT active_plugins, active_themes, active_plugins + active_themes,
plugin_installs + theme_installs, installs_30d
FROM package_stats WHERE id = 1`,
).Scan(&s.ActivePlugins, &s.ActiveThemes, &s.TotalPackages, &s.TotalInstalls, &s.Installs30d)
if err != nil {
http.Error(w, "stats unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
_ = json.NewEncoder(w).Encode(s)
}
}