a20b96d893ea258cffac77b0680aa8960048ee4b
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a4a4e46c53 |
docs: add a restore procedure, fix the backup cadence, and correct quota_tolerance
Four things, all found by the same question: what does an operator standing at the
venue actually need?
A RESTORE PROCEDURE. There was none anywhere, and a backup you have never restored
isn't a backup. Two hazards worth writing down: media must be extracted preserving
ownership (the app runs as uid 100 / gid 101, and a root-owned restore makes every
upload fail with EACCES surfacing as a generic 500), and the app must be STOPPED
first, because migrations run on boot and a live pool will fight the restore.
Both the backup and the restore commands were run against the real stack before being
written down, which caught two that would have failed:
- The plain `pg_dump` did not restore: `psql` aborted on `ERROR: schema
"_sqlx_test" already exists`. pg_dump emits no DROPs without --clean --if-exists,
so the documented dump could only ever be restored into an empty database. Fixed
at the source (the dump is now self-cleaning) and verified end to end: 16 tables
back, exit 0.
- `--same-owner` does not exist in BusyBox tar, which is what `alpine` ships, so
the extract aborted before unpacking anything. `--numeric-owner` plus the
explicit chown, verified to land 100:101.
BACKUP CADENCE. "Weekly offsite" is the wrong shape when every irreplaceable byte is
created in one eight-hour window and nobody can retake a wedding. The backup that
matters runs that night, and again after the release so the keepsake is captured.
Also: take the DB dump and the media tarball back to back, or you get rows pointing
at files the dump doesn't know about.
quota_tolerance WAS DOCUMENTED AS SOMETHING IT ISN'T. .env.example called it "fraction
of disk that triggers the low-storage warning". It is the multiplier in
`floor(free_disk * tolerance / active_uploaders)` -- so an operator who wants "warn me
later" and sets 0.95 is actually authorising guests to fill 95% of the disk, moving
the fixed point from 43% to ~49% and eating the export headroom. The admin UI labelled
it "Toleranz (0-1)" with no explanation at all, which invites exactly that reading;
it is now "Speicher-Anteil für Gäste" with the formula in the hint. Wrong docs on a
tuning knob are worse than no docs.
SIZING. New section with the arithmetic: three volumes on one filesystem, the quota
fixed point at tolerance/(1+tolerance), and the fact the 80 GB baseline does not cover
the keepsake -- both archives are built concurrently and each is roughly a second copy
of every original. Provision ~3x expected media, or give exports its own volume.
Also ticks the low-disk alert off the roadmap, since it now exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
faf2e62a29 |
fix(deploy): route /health in production, and actually apply Caddyfile changes
Two defects in the update procedure I wrote last round, both of which make a
successful-looking deploy a lie.
1. The documented health check could never pass.
`curl -fsS https://DOMAIN/health` 404s against a perfectly healthy production
stack. The backend registers /health on its ROOT router, not under /api/v1, and
the production Caddyfile proxies only /api/* and /media/* — so /health fell
through to the SvelteKit catch-all, which has no such route and returns its 404
page. With -f, curl exits 22 and the `&& echo` never runs. My own gloss
("Anything other than ok means check the logs") then sent the operator chasing a
phantom outage.
e2e/Caddyfile.test has carried `reverse_proxy /health app:3000` since it was
written — precisely because the catch-all would otherwise swallow it. Production
never did. Per the fix-the-gap-not-the-doc call, production gets the same line,
and /health joins the no-store matcher so a cached response can't report the last
known state instead of the current one. Verified by running the production
Caddyfile against the real backend: /health -> 200 "ok", Cache-Control: no-store,
with /api/v1/event and / unaffected.
2. The sequence never reloaded Caddy, so a Caddyfile-only change was dropped.
`--build` only rebuilds services with a `build:` section, and caddy is a pinned
upstream image. Compose decides whether to recreate a container from its config
hash, which covers the mount SPECIFICATION but not the mounted file's CONTENTS —
so a git pull that changes ./Caddyfile produces no delta, Compose reports
`Running`, and Caddy serves its old config indefinitely. Exit code 0 throughout.
Round 1's iOS download fix (
|
||
|
|
3d94bbd6fb |
docs(deploy): document the update path — up -d alone ships nothing
The README only ever described a fresh install. There was no update section anywhere, and `--build` appeared nowhere in the docs. That matters because `app` and `frontend` are `build:` services with no published image tag, and Compose has no source-change detection: if an image by that name exists it is reused. So the natural `git pull && docker compose up -d` reports "Container app-1 Running", rebuilds nothing, and exits 0. A deploy that shipped none of the new code is indistinguishable from a successful one — which is how eleven merged fixes can sit in the repo and never reach the box. Verified both halves against a real stack rather than asserting them: with a source change staged, `up -d` left the image ID untouched; `up -d --build` produced a new image ID and a healthy /health. Adds an "Updating an existing deployment" section covering backup-before-migrate, pull, rebuild, health check, and an image-ID comparison to prove a build actually happened. Also spells out the rollback trap: migrations run on boot and are not undone by checking out an older commit, so rolling back code without restoring the snapshot leaves the schema ahead of the binary and the app refusing to start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
27e4004cc8 |
docs(backup): make the backup commands work; fix the e2e/prod divergences
Backup. Both documented commands failed on the shipped stack, and the sentence
explaining them was wrong too:
- `pg_dump $DATABASE_URL` — `DATABASE_URL` is only ever in the compose
environment, never an operator's shell, and it points at `db:5432`, which is
compose-internal DNS. The app image has no postgres client either.
- `> /media/backups/…` — `/media` is a named volume mounted inside the app
container, not a host path, and nothing ever creates a `backups` subdirectory.
- `rsync /opt/eventsnap/media/` — that path does not exist anywhere.
- "a single path to back up" — false, and dangerously so: exports were moved to
their own `exports_data` volume precisely so a keepsake (which contains every
photo in the event) can't be served off the media tree. Backing up only
`media_data` silently loses every generated keepsake.
Rewritten as three commands — db via `docker compose exec -T db pg_dump`, and one
`docker run … tar` per volume — all verified against the running stack. The
volume mounts use `/src`, not `/media`: I hit the footgun while testing this.
Docker pre-populates an EMPTY volume from the image's own directory and chowns it
to match, so `-v media_data:/media alpine` tars alpine's cdrom/floppy/usb, writes
them into the volume, and leaves it root-owned so the non-root app can no longer
write. Mounting where the image has nothing avoids all of it. Documented inline
so the next person doesn't rediscover it.
Also correct the architecture notes: `/media/*` no longer routes to the backend
(that static tree was removed as a gating bypass), and `exports_data` was missing
from the volume list — the one volume an operator most needs to know about.
e2e stack: add the `EXPORT_PATH` + `/exports` volume it was missing. The file
says "mirrors production layout"; without these, exports landed on the container's
writable layer at the default path, so export-leak and export-video wrote real
archives into ephemeral storage and the "exports live outside media" invariant
was never actually exercised.
Pre-existing red test, unrelated to the audit: all four 02-upload/quota tests
have been failing since
|
||
|
|
3f74c65787 |
ci: run the checks that only ran on a laptop; mobile in CI; retries 0
CI ran Playwright (chromium-desktop) + the dependency audit and nothing else. cargo test,
clippy, the frontend vitest suite, svelte-check and the e2e typecheck were all green on a
developer's machine and gated NOTHING.
checks.yml (new): cargo test (with a Postgres service; --test-threads bounded so the
sqlx per-test DBs can't exhaust connections) + clippy; vitest + svelte-check; e2e tsc.
e2e.yml: also run the chromium-mobile project — 22 tests (focus traps, touch targets,
safe-area, viewport reflow) that never ran in CI on a phone-first app.
playwright.config: widen webkit-iphone beyond @smoke (2 specs) to the core guest journeys
(auth/upload/feed) — iOS Safari is the PRIMARY browser here; and retries 2 → 0.
retries:0 is deliberate and against the usual advice: this repo's real bugs are races, a
race is indistinguishable from a flake from outside, and a retry resolves that ambiguity
toward "flake" every time — it took a real 3%-flaky product bug from 1-in-33 to 1-in-37000,
green. A flake here is a bug report.
Not gated: cargo fmt (the tree has never been rustfmt'd — a 112-file reformat would bury
every real diff; do it separately). WebKit widening is unverified locally (needs libavif16
via sudo), so its first CI run may surface real iOS bugs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
41ac742af8 |
test+docs: tighten H1 assertions, cover secret/XFF edges, document boot & SSE-ban
Re-review follow-ups (non-blocking quality items): Tests: - moderation H1 specs now assert exact `→ 403` instead of bare .rejects.toThrow(), so a spurious 500 can no longer masquerade as "revocation worked". - config: added case-insensitivity (upper/mixed-case placeholder) and the len==32/31 boundary cases for validate_secrets. - rate_limiter: added the trailing-comma empty-entry case for client_ip (must fall back, not return ""). (Backend unit tests: 35 pass.) Docs: - README: note that with APP_ENV=production a placeholder .env makes the app refuse to boot and Caddy wait unhealthy — reason is in `docker compose logs app`. - SECURITY-BACKLOG + sse.rs comment: document that a mid-session ban does not tear down an already-open SSE stream (new tickets are blocked; low blast radius). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
91ff961850 |
fix(security): medium/low — event-lock, atomic config, a11y, diashow, hygiene
- social: likes/comments rejected on a closed event (e2e proven). - admin: patch_config validates-then-writes atomically; char-based length. - hashtag/comment models: atomic tag writes in edit + comment paths. - Modal: inert the background (modal-inert action) for screen readers. - sse.ts: idempotent visibilitychange listener (no duplicate reconnects). - diashow: videos advance on `ended` (transitions + page wiring). - docs/hygiene: README clone-case fix; removed stale committed .env.test and gitignored it; docker-compose.dev.yml tidy. Left documented as accepted-risk per plan: PIN-persist-after-logout, UUID-reachable hidden uploads, DECISION-media-auth.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
edcef0258c |
fix(security): batch-2 hardening — XSS allowlist, event-scoping, deploy
Address the seven findings from the security & deployment review. High: - upload: make infer authoritative — reject files it can't identify (SVG/HTML/JS) and require the detected MIME to be on an ALLOWED_MEDIA allowlist; derive the stored MIME and on-disk extension from the detected type, ignoring client filename/Content-Type. Closes the stored-XSS vector via media served on-origin. - deploy: rename docker-compose.override.yml -> docker-compose.dev.yml so the default `docker compose up -d` no longer publishes Postgres 5432 to the host; the port map is now opt-in via -f. README updated. Medium: - upload: DefaultBodyLimit::disable() -> max(576 MiB) as an HTTP-level OOM backstop; handler still enforces precise per-class size limits. - docker: run backend and frontend as non-root users. Low: - social/upload: event-scope toggle_like, list_comments, add_comment, delete_comment, edit_upload, delete_upload via find_by_id_and_event / soft_delete_in_event — cross-event IDs now resolve to 404. - Caddy: site-wide HSTS / nosniff / X-Frame-Options / Referrer-Policy, plus Content-Disposition: attachment on /media/originals/*. - .env.example: replace default Postgres password with a CHANGE_ME hint. Out of scope: localStorage JWT (root cause fixed; httpOnly cookies are a larger change tracked separately). Verified: cargo build (no new warnings), cargo test (3 passed), caddy validate, docker compose config (no 5432 published by default). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e42d8a92a1 |
feat(e2e): Playwright suite — 134 tests across 9 spec areas + UA matrix
Adds an end-to-end Playwright test suite under e2e/ that spins up an
isolated docker-compose stack (Postgres :55432, Caddy :3101, backend with
EVENTSNAP_TEST_MODE=1, SvelteKit adapter-node frontend) and exercises the
SvelteKit app against the real Rust backend.
Phase 1 — happy paths covering every documented USER_JOURNEYS.md flow:
01-auth/ join, recover, admin login, leave event, PIN lockout
02-upload/ gallery picker (API path), rate-limit + admin toggle
03-feed/ like/comment SSE, filters, SSE reconnect on visibility
04-host/ event lock API, ban/unban, promote
05-admin/ config validation, foundational authz guards, stats
06-export/ /export status + download stub
__smoke/ cross-UA happy-path (runs on every UA project)
Phase 2 — adversarial + browser chaos:
07-adversarial/ XSS payloads (6 × display name path), SQLi shapes,
length / encoding / RTL override / NUL byte;
file-upload boundaries (ELF body claimed as JPEG,
oversize vs max_image_size_mb, zero-byte, NUL
filename, path-traversal, SVG-with-script);
JWT alg:none, signature/payload tamper, expired
session, PIN brute-force (serial + parallel),
admin password brute-force; deep authz (cross-user
delete, banned user across like/comment/feed-read,
host→admin escalation); small-scale DDoS (20× /join,
10MB comment body, 10 concurrent SSE).
08-browser-chaos/ localStorage / sessionStorage / cookie purge,
IndexedDB drop mid-session, offline → reconnect,
slow-3G, 503 flakes, 429 with no retry storm,
multi-tab same/different user, no-JS, hostile CSS,
clock skew ±1h / -2d, localStorage quota exhausted.
Phase 3 — mobile gestures (runs only on chromium-mobile / Pixel 7):
09-mobile/ touch-target ≥44px audit, env(safe-area-inset-bottom)
structural check, long-press (FeedListCard → ContextSheet,
quick-tap negation, click-suppression), double-tap
(feed card like + lightbox heart-burst, via synthetic
pointer events to bypass the first-tap-fires-click trap),
viewport reflow (portrait/landscape/narrow/phablet),
plus fixme stubs documenting planned gestures (swipe
lightbox L/R, swipe-down dismiss, pull-to-refresh,
long-press-comment).
Cross-UA matrix (chromium-engine projects run @smoke only):
chromium-pixel7, chromium-galaxy-s22, samsung-internet (Samsung UA
emulation on Galaxy viewport), edge-android, plus webkit-iphone,
chrome-ios, firefox-android, firefox-desktop — the latter four need
libavif16 on the host (Playwright dep) but the configs are in place.
Infrastructure:
- fixtures/test.ts central test.extend (api, db, adminToken, guest,
host, signIn). Per-test DB truncate via the dev-only POST
/admin/__truncate route, gated by EVENTSNAP_TEST_MODE=1.
- helpers/sse-listener.ts, helpers/upload-client.ts (Node-side
multipart for adversarial file-upload tests + JPEG/PNG/ELF magic
constants), helpers/touch.ts (longPress / doubleTap / swipe /
inlineStyle / computedStyle).
- 10 page objects covering every route + UploadSheet/Lightbox.
- global-setup waits for /health, logs in admin, disables every
rate-limit and quota toggle.
- .github/workflows/e2e.yml: PR check runs chromium-desktop + the
smoke matrix in parallel, uploads playwright-report/ and traces on
failure.
Findings the suite surfaces as live `[finding]` warnings (not silenced):
1. /admin/login has no rate-limit or lockout (bcrypt cost only).
2. PIN-attempt counter races under parallel /recover requests.
3. Zero-byte uploads pass /api/v1/upload.
4. SVG-with-script can pass the magic-byte check (consider CSP +
X-Content-Type-Options on /media/*).
Stack-internal docs live in e2e/README.md (UA tier table, Samsung
Internet escalation tiers A/B/C, debugging tips, roadmap).
Final tally: 134 passed / 0 failed / 9 skipped (test.fixme stubs for
not-yet-shipped gestures and one UI-upload-flow investigation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9a0ceeced7 |
docs: realign blueprint with shipped state + add feature/journey/ideas docs
- PROJECT.md, README.md, TEST_GUIDE.md: status line refreshed; rate-limiter doc-vs-code drift fixed; HTML export section rewritten for the SvelteKit- static viewer; SSE event names + new events documented; config seed block extended with planned toggles + privacy_note; decision log entries added. - docs/CONCEPT_HTML_VIEWER.md, docs/CONCEPT_MOBILE_UI.md: banner the design intent as shipped; point at the source-of-truth code paths. - docs/CONCEPT_DIASHOW.md: planned-then-shipped design for the live diashow (two-queue policy, pluggable transitions, data-mode aware). - docs/FEATURES.md: capability matrix by role (Guest / Host / Admin) plus prose per area (auth, posting, feed, moderation, admin, export, gestures, data mode, quotas, privacy note, extensibility). - docs/USER_JOURNEYS.md: step-by-step flows for every supported scenario, including PIN reset by host, data mode, privacy note, gestures, and the admin toggles. - docs/IDEAS.md: speculative extensions (global diashow, reactions, multi-tenancy, animation pack, etc.) — explicitly out of v0.16 scope. - backend/migrations/README.md, frontend/src/lib/README.md: codify the "never edit a shipped migration" rule and the lib/ conventions (one store per concern, gestures via actions, sheets via ContextSheet, transitions as drop-in components). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b89b1d6ffa |
chore: scaffold monorepo for EventSnap
- Rust/Axum backend skeleton with all crates, multi-stage Dockerfile - SvelteKit + TypeScript frontend with Tailwind CSS v4, adapter-node, Dockerfile - docker-compose.yml: db (postgres:16) → app → frontend → caddy with healthcheck and named volumes - Caddyfile: TLS via Let's Encrypt, cache headers, API/media routing to backend - .env.example: all environment variables documented with defaults - README.md: project overview, features, stack, deploy guide, roadmap - .gitignore: excludes secrets, build artifacts, node_modules, media uploads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |