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:
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Smartphone } from 'lucide-svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
|
||||
// Polls /api/client/version once on mount. Renders nothing if the
|
||||
// server has no APK bundled (404) — graceful degradation in dev
|
||||
// environments and pre-CI-wiring images.
|
||||
// Polls /api/client/version once on mount. Renders nothing on 404
|
||||
// (no bundled APK — graceful degradation in dev / pre-CI images)
|
||||
// or 401 (defensive — caller must be logged in).
|
||||
//
|
||||
// Mounted on the login page so the download is discoverable without
|
||||
// authentication; the /api/client/* endpoints are themselves unauthed.
|
||||
// Also reusable in Settings for logged-in users who want to grab the
|
||||
// matching APK for sideloading on a different device.
|
||||
// The /api/client/* endpoints are auth-gated to prevent anonymous
|
||||
// bandwidth abuse on the APK stream, so this component only shows
|
||||
// for logged-in users. Mount in Settings, never in pre-auth surfaces.
|
||||
|
||||
type Info = { version: string; apkUrl: string; sizeBytes: number };
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/client/version');
|
||||
if (!res.ok) return; // 404 = no bundled APK; stay hidden
|
||||
const body = (await res.json()) as Partial<Info>;
|
||||
const body = await api.get<Partial<Info>>('/api/client/version');
|
||||
if (!body.version || !body.apkUrl) return;
|
||||
info = {
|
||||
version: body.version,
|
||||
@@ -32,7 +30,7 @@
|
||||
sizeBytes: body.sizeBytes ?? 0
|
||||
};
|
||||
} catch {
|
||||
// Network error / non-JSON response — silent fail, no banner.
|
||||
// 404 (no APK), 401 (somehow unauthed), network error — silent.
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { login } from '$lib/auth/store.svelte';
|
||||
import type { ApiError } from '$lib/api/client';
|
||||
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
@@ -89,9 +88,5 @@
|
||||
Don't have an account?
|
||||
<a href="/register" class="text-accent hover:underline">Register</a>
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<MobileAppDownload />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user