fix(server,web): auth-gate client APK + version endpoints + per-user rate limit (#397)

Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.

### Server

- internal/api/client_assets.go:
  - clientAPKAllowDownload(): in-memory map[userID]time.Time under
    a mutex. Returns 0 (allow) or wait duration (block).
  - handleClientAPK reads user from context, checks the limit,
    returns 429 + Retry-After header if blocked.
  - testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
  the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
  TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
  different user gets a fresh slot). Existing tests updated to use
  authedRequest() helper.

### Web

- MobileAppDownload.svelte: switched from bare fetch (no credentials)
  to api.get<>() which carries the session cookie. 404 / 401 /
  network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
  never show this. Settings → Mobile app section keeps it for
  logged-in users.

Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 20:28:27 -04:00
parent 2435ef7961
commit 154f415f92
5 changed files with 157 additions and 27 deletions
+6 -5
View File
@@ -52,11 +52,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
api.Post("/auth/register", h.handleRegister)
api.Post("/auth/forgot-password", h.handleForgotPassword)
api.Post("/auth/reset-password", h.handleResetPassword)
// Self-hosted in-app update channel (#397). Unauthenticated so
// the install flow doesn't depend on a live session.
api.Get("/client/version", h.handleClientVersion)
api.Get("/client/apk", h.handleClientAPK)
api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool))
authed.Post("/auth/logout", h.handleLogout)
@@ -106,6 +101,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Delete("/quarantine/{track_id}", h.handleUnflag)
authed.Get("/quarantine/mine", h.handleListMyQuarantine)
// Self-hosted in-app update channel (#397). Auth-gated to
// prevent anonymous bandwidth abuse on the APK stream;
// /apk additionally per-user rate-limited.
authed.Get("/client/version", h.handleClientVersion)
authed.Get("/client/apk", h.handleClientAPK)
authed.Route("/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/lidarr/config", h.handleGetLidarrConfig)