H6: migration 006 replaces v_feed's double LEFT JOIN + COUNT(DISTINCT) (which
materialized a likes×comments Cartesian per upload) with correlated scalar
subqueries that each use their own index. Output columns are unchanged, so
feed + hashtag-filtered paths both benefit.
H7: feed_delta now applies the feed rate limit (keyed per user), caps results
at 200 rows, and clamps how far back a client `since` may reach (7 days). When
clamped or capped it returns reload_required=true; the SSE client turns that
into a full feed reload instead of streaming the whole gallery through the view
on every tab refocus.
M9: the SSE reconnect cursor is now advanced only from server timestamps (an
upload's created_at, seeded from the feed and updated on new-upload events and
delta responses), never the client clock — so a skewed phone clock can't drop
events missed while backgrounded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:
- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
commit; soft_delete returns the paths; an hourly reaper reclaims disk for
rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).
Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).
Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- handlers/me.rs (new): GET /api/v1/me/context (profile + role + privacy_note
+ quota toggle state, fetched once on app bootstrap) and GET /api/v1/me/quota
(live used / limit / active uploaders / free disk).
- handlers/upload.rs:
- quota enforcement via the dynamic formula
floor((free_disk * tolerance) / max(active_uploaders, 1)),
gated by quota_enabled + storage_quota_enabled toggles
- new GET /api/v1/upload/{id}/original — unauthed by design
(matches /media/previews/* — URL is the secret) so it works as
<img src> / <video src> / window.open
- rate-limit toggle wiring (rate_limits_enabled + upload_rate_enabled)
- handlers/host.rs:
- POST /api/v1/host/users/{id}/pin-reset — Host may reset guest PINs,
Admin may reset guest + host PINs (never another admin or self).
Returns the freshly-generated plaintext PIN once; emits a global
pin-reset SSE so the affected user's device can clear its localStorage.
- set_role guard expanded so hosts can demote other hosts (not self,
never admins) — backend match for the doc'd permission model.
- handlers/admin.rs: ALLOWED_KEYS split into NUMERIC_KEYS / BOOL_KEYS /
TEXT_KEYS with per-kind validation; saving privacy_note broadcasts an
event-updated SSE so other clients refresh live.
- handlers/feed.rs, handlers/admin.rs (export), auth/handlers.rs:
rate-limit toggle wiring at every limiter call site.
- auth/handlers.rs: when an expired PIN lockout is detected on /recover,
reset failed_pin_attempts to zero before the bcrypt check — without
this every wrong PIN re-locked the user after the cooldown.
- main.rs: wire startup_recovery + spawn_periodic_tasks, register the
new /me/context, /me/quota, /upload/{id}/original, and
/host/users/{id}/pin-reset routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend: rate limiter gains check_with_retry() returning seconds until
the next slot opens. Upload 429 responses include retry_after_secs in
JSON and a Retry-After header.
Frontend: upload queue catches 429 as RateLimitError, resets affected
item to pending, schedules processQueue() for the server-reported delay,
and shows a live countdown banner in the queue UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add sliding-window in-memory RateLimiter service (Arc<Mutex<HashMap>>)
with per-IP and per-user-id limits on all public endpoint classes:
- POST /api/v1/join: 5/min per IP
- GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60)
- POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10)
- GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3)
Limits are hot-reloadable via the config table. All 429 responses use
German error messages. Client IP is read from X-Forwarded-For (Caddy).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Cursor-based feed endpoint using v_feed view with hashtag filtering
- Like toggle (INSERT ON CONFLICT), comments CRUD
- Feed delta endpoint for SSE-driven incremental updates
- SSE client with Page Visibility API (pause/reconnect)
- Responsive photo/video grid with infinite scroll
- Hashtag filter chips, lightbox modal with comments
- Media file serving via tower-http ServeDir
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>