A file past the size limit makes the multipart stream itself throw, which
escaped the per-file catch and reached the error handler: the phone that just
sent a 35 MB photo got 'Something went wrong'. Catch the limit errors around the
part loop, say which limit was hit, and report it once rather than twice. Also
asserts that derivatives carry no EXIF while the Original keeps the uploaded
bytes.
The data root belongs to the app's uid, so the SSH user usually cannot write
into it, and a site.env they did write would be unreadable by the app — the
admin would stay disabled after provisioning. The script now writes the file
through a container running as the app user, so ownership is right the first
time and no playbook re-run is needed. Also checks the data root exists and
tells you to run the playbook if it does not.
Every mutating form now carries the CSRF token as a hidden field, and the
upload route accepts it as a multipart field as well as a header. Driving the
real UI in Firefox found what the header-only tests could not: Create Draft,
Save, Publish, Delete and the up/down buttons all posted without a token and
were rejected. Adds npm run check-site, which verifies a running Site from the
outside and exits non-zero so it can gate a cutover.
The playbook pulls the tag from the registry, creates the data root owned by the
app user, runs the container with its config baked into the image, waits for
health, then verifies the data directory as the app user so a permissions
mistake surfaces at deploy time. Caddy is pointed at the app with a stable
marker, so a temporary hostname can be checked beside the live site and the
cutover is one invocation with the retire flags set.
verify reports integrity problems and repair rebuilds derivatives from the
Originals, which is what makes the file layout trustworthy without a database.
The image runs TypeScript on Node 24 with no build step, writes only under
MENU_ROOT, and the provision script writes the untracked site.env using the
image's own hasher so the server needs no Node.
The admin signs in with the shared password, creates a Menu Set, drops photos
into it (shrunk in the browser first), reorders Pages by dragging or with
up/down buttons, previews the Draft exactly as customers would see it, and
publishes it over the previous set. Restore and delete close the loop. Mutations
need a session, a same-origin request and a CSRF token derived from the session;
a Draft's media is served only to a session holder.
Landing page, Section pages and archived sets rendered per request from the
store, with the approved design: hand-written CSS, self-hosted Archivo, a
copper pour line in the Section rail, an admin-only Thumbnail and a text page
index. Media is keyed by Page id so URLs survive publishing, and a Draft's
media needs a session. Includes the scrypt admin auth and the login throttle
the media rule needs.
The store keeps a Menu Set's state in the directory layout (drafts/ versus
published/) and its metadata in a manifest, with publishing as a single
directory rename. Editing is allowed on Drafts only, so a published menu can
never change under a customer. Stages 2 and 3 of docs/plan.md, with tests.