Filtering was split across two independent client-side states and applied to whatever
page 1 happened to hold, by caption SUBSTRING. So a tag chip selected in the list view
was silently still applied in the grid without being shown; a filter matched photos
whose caption merely contained the text; and anything past the first page was invisible
to it. Verified against the seeded data: `hashtag=tanz` returned 6 photos by substring,
1 by tag.
`FeedQuery` now carries `hashtag` (single, list view), `hashtags` (CSV, OR'd, grid
chips) and `uploader` (exact, AND'd), normalised through one function that trims,
strips `#`, lowercases and dedupes, and yields None when empty — so an empty filter
means "no filter", never "match nothing". The two SQL branches collapse into one with
`h.tag = ANY($4)`. Tag-OR plus tag+user-AND is a specified feature, not an accident:
`e2e/specs/03-feed/filter-search.spec.ts` and USER_JOURNEYS §8 pin it, which is why the
semantics moved to the server rather than being simplified away.
Tags travel as CSV safely because the backend restricts them to ASCII alphanumerics and
`_`; `uploader` stays a single exact parameter because a display name can contain a
comma.
New `GET /api/v1/uploaders` reads `v_feed`, so banned and hidden uploaders are excluded
for free.
Migration 021 gives `v_hashtag_counts` the same treatment. It counted every upload
regardless of the uploader's ban state, so banning a guest left their tags in the chip
list as ghost filters that lead to an empty feed. Verified: after banning the guest who
owned all six `tanz*` photos, the chips went 6 -> 0.
`?limit=-5` returned a 500 — only the upper bound was clamped, so Postgres was asked
for `LIMIT -4`. Clamped at both ends.
`is_banned` is added to `/me/context` so the client can show a read-only notice instead
of letting a banned guest discover the ban one 403 toast at a time. `add_comment` sorts
and dedupes hashtags on the normalised key, matching the upload path — the two disagreed,
which is a lock-ordering deadlock between concurrent upserts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-user quota widget was shown to everyone and the /me/quota payload returned
free_disk_bytes (raw server free space) and active_uploaders to any authenticated
guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero
the server-wide telemetry fields for non-staff in the handler. Guests still get their
own used/limit so enforcement stays transparent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files)
so no future functional diff is buried under formatting churn, then gating `cargo fmt
--check` in checks.yml so it stays clean.
Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat:
cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean.
This is the deferred cleanup noted when CI's Format step was first left out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Performance:
- Cache the runtime `config` table in-memory (ConfigCache) with synchronous
invalidation on every write (admin PATCH + test reseed). Was re-reading each
key from Postgres on every request (~8 round-trips per upload).
- Stream uploads chunk-by-chunk to a temp file instead of buffering the whole
body in RAM (peak was up to the per-class cap, e.g. 500 MB/video); only 512
sniff-bytes are kept for magic-byte detection, then atomic rename into place.
- Cache the media-filesystem disk snapshot (DiskCache, 15s TTL) shared by the
quota check and admin stats; drop the discarded System::refresh_all().
- HTML export streams video (and small-image) originals straight into the ZIP
via a manifest instead of copying them to a temp dir first (removed the
transient 2x disk usage) and drops the double directory scan.
- Auth extractor resolves session -> live user in one JOIN (was two queries),
touching last_seen_at by token hash.
Stability:
- SSE: on broadcast lag, emit a `resync` event so the client runs a delta
fetch instead of silently losing events; frontend reconciles adds, deletions,
and (via an in-place refresh) like/comment counts on visible cards.
- Storage quota fails OPEN when the disk can't be read (was a 0-byte limit that
locked out all uploads).
- Graceful shutdown drains in-flight requests on SIGTERM/SIGINT, bounded by a
10s backstop so open SSE streams can't stall a deploy.
- Upload removes the persisted file if the DB transaction fails (no orphaned
bytes with no row to reclaim them).
Tests:
- New pure select_disk() with 5 unit tests (longest-prefix, fallbacks, fail-open).
- New e2e export-video spec covering the HTML export's video-streaming branch.
Co-Authored-By: Claude Opus 4.8 <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>