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.
139 lines
11 KiB
Markdown
139 lines
11 KiB
Markdown
# Menu app — agreed plan
|
|
|
|
Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-hosted app that uploads, orders, previews and publishes Menu Sets. Verified against the current environment by read-only reconnaissance of `monoco`.
|
|
|
|
## What exists today
|
|
|
|
- Caddy (80/443, TLS) → `reverse_proxy nginx-menu.hopsngrains.com:80` → `nginx:alpine` with `-v /var/lib/menu.hopsngrains.com:/usr/share/nginx/html:ro`.
|
|
- Each Section is a hand-written `index.html` listing one `<img>` per JPG, with a vendored copy of Materialize CSS/JS in every Section folder.
|
|
- The read-only mount is why content can only be changed over SSH. `static-menu/Caddyfile` is dead config from the retired `caddy-menu` playbook and is read by nothing.
|
|
- The same playbook serves three Sites: `menu.hopsngrains.com` (319M, this folder), `menu.hngmohali.com` (208M), `menu.greatbear.in` (405M). Remote disk: 15G free, 82% used.
|
|
- `nginx-menu/` is untracked in the `ansible-playbooks` repo (`git.tanshu.com:tanshu/playbooks`). Sibling projects build an image locally, push to `registry.tanshu.com`, then run `ansible-playbook -e tag=`.
|
|
|
|
## Decisions
|
|
|
|
| Question | Decision |
|
|
| --- | --- |
|
|
| Upload path | Small self-hosted app on the same host, replacing `scp` |
|
|
| Serving | The app serves HTML **and** images; nginx is retired per Site (ADR 0001) |
|
|
| Rendering | Per request, server-rendered HTML (ADR 0003) |
|
|
| Stack | Node + Fastify + `sharp`, one container |
|
|
| Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) |
|
|
| Scope | One app instance per Site (ADR 0004); other two Sites migrate later |
|
|
| Sections | Per-Site config in git; not editable in the admin (ADR 0005) |
|
|
| Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable |
|
|
| Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server |
|
|
| Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served |
|
|
| Logging | JSON to stdout only; no Prometheus, no Promtail |
|
|
| Backups | Owner's existing backups; out of scope |
|
|
|
|
## Domain
|
|
|
|
`CONTEXT.md` is the glossary. Sections for this Site: Food, Bar, Wine, Straight Drinks, Delivery, What's On. One Current Menu Set per Section; a Section with no Current set does not appear on the landing page.
|
|
|
|
## Architecture
|
|
|
|
### Topology
|
|
|
|
```
|
|
caddy (80/443, TLS) ──reverse_proxy──▶ menu-app.hopsngrains.com:3000
|
|
├── renders landing, Section, archive pages
|
|
└── serves viewing images
|
|
(nginx-menu.hopsngrains.com retired after cutover)
|
|
```
|
|
|
|
### Storage
|
|
|
|
```
|
|
/var/lib/menu-app.hopsngrains.com/
|
|
├── site.env # ADMIN_PASSWORD_HASH, SESSION_SECRET (mode 600, untracked)
|
|
└── data/
|
|
├── originals/<section>/<set-id>/<page-id>.<hash>.<ext> # never served
|
|
├── drafts/<section>/<set-id>/ # manifest.json + variants
|
|
└── published/<section>/<slug>/ # manifest.json + variants
|
|
```
|
|
|
|
- `<slug>` is the valid-until date when set, else the publication date (`YYYY-MM-DD`), suffixed `-2` on collision.
|
|
- Directory encodes state: `drafts/` is invisible to customers, `published/` is reachable.
|
|
- `manifest.json` holds `id`, `section`, `label`, `validUntil`, `createdAt`, `publishedAt` and the ordered Page list; each Page carries its own `id`, content hash, source extension, the Viewing image's dimensions and the uploaded filename.
|
|
- **Current** is the published set for a Section with the newest `publishedAt`; everything else published is an Archive. No separate current pointer exists to drift.
|
|
- Writes are temp file + `fsync` + rename, and publishing is a directory rename, so a crash leaves either a Draft or a published set. `publish`/`archive` serialize behind one in-process lock. Boot validation checks that every manifest matches its directory and that Page files exist.
|
|
|
|
### Routes
|
|
|
|
| Route | Purpose |
|
|
| --- | --- |
|
|
| `GET /` | Landing page: Sections with a Current set, text rows |
|
|
| `GET /<section>/` | Current Menu Set |
|
|
| `GET /<section>/<slug>/` | Archived Menu Set (unlisted, still reachable) |
|
|
| `GET /<section>/#page-N` | Deep link to one Page |
|
|
| `GET /media/<page-id>.<hash>.webp\|.jpg` | Viewing image, `immutable` cache; keyed by Page id so the URL survives publish |
|
|
| `GET /admin/*` | Admin, session required, `noindex` |
|
|
| `GET /admin/sets/<id>/preview` | Draft rendered exactly as customers would see it |
|
|
|
|
Unknown Sections and Sections without a Current set 404 rather than redirect, so a stale printed link fails loudly instead of showing the wrong menu. Media for a Draft is served only to a session holder, which is what keeps Drafts unreachable in practice.
|
|
|
|
### Image pipeline
|
|
|
|
- Accept JPEG/PNG/WebP up to 30MB, up to 40 Pages per set; anything else is rejected with a message naming the file.
|
|
- The browser pre-shrinks to 3000px on the long edge before upload (fast over restaurant wifi); if the browser cannot decode the file, the file is uploaded untouched.
|
|
- Server: apply EXIF orientation, strip all other metadata, write the Original, generate a 1600px WebP (q80) + JPEG (q82) Viewing image and a 480px WebP Thumbnail.
|
|
- Variant filenames carry a short content hash and are keyed by Page id, so a replaced Page cannot be served from a stale cache and a published URL keeps working across a re-publish.
|
|
|
|
### Admin
|
|
|
|
Log in; see each Section with its Current set and state; create a Menu Set (choose Section, add Pages, drag to reorder, set label and optional valid-until, thumbnail grid); preview; publish (archives the previous set); restore an Archive as Current; delete a Draft or Archive. No user management, no scheduling, no Section editing, no password-change flow (edit `site.env` and restart).
|
|
|
|
Protection: a `scrypt` hash (from `node:crypto`, so no native build dependency) in `site.env`, httpOnly + Secure + SameSite=Lax session cookie, login throttled per IP, `noindex` on all admin routes. Originals and Drafts have no public route at all.
|
|
|
|
### Customer design
|
|
|
|
Text-led landing page; Sections as tap-list rows with copper hairlines; no hero photograph, no cards, no shadows.
|
|
|
|
```
|
|
┌────────────────────────────┐ ┌────────────────────────────┐
|
|
│ HOPS N GRAINS │ │ HOPS N GRAINS ⌂ │
|
|
│ Food 12 pages │ │ Bar Food Wine Drinks │
|
|
│ ────────────────────────── │ │ ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░ │
|
|
│ Bar 20 pages │ ├────────────────────────────┤
|
|
│ ────────────────────────── │ │ ┌──────────────────────┐ │
|
|
│ Wine │ │ │ menu page photo │ │
|
|
└────────────────────────────┘ │ └──────────────────────┘ │
|
|
│ 3 / 12 │
|
|
└────────────────────────────┘
|
|
```
|
|
|
|
- Colour: `#14181b` steel, `#1f262b` rail, `#f2efe9` paper, `#0088aa` logo teal, `#b2703b` copper.
|
|
- Type: self-hosted variable **Archivo** — expanded 600 for the brand and Section names, normal width for body and UI, tabular numerals for the counter.
|
|
- The one memorable element: a copper "pour" line in the sticky Section rail that fills as you scroll, doubling as position in a 20-page menu.
|
|
- Tapping the page counter opens a text page index for jumping; a quiet "Older menus" line at the foot of a Section page lists Archives.
|
|
- Roughly 2KB of progressive-enhancement JS for the lightbox and counter. Visible focus, `alt` text ("Bar menu, page 3 of 12"), `prefers-reduced-motion` respected, self-hosted fonts so it works on restaurant wifi.
|
|
|
|
## Repos, build and deploy
|
|
|
|
- New repo `menu-admin` on `git.tanshu.com`: app source, `Dockerfile`, `compose.yaml` for local dev, `docs/` (this plan, `CONTEXT.md`, the ADRs), `node --test` tests, and `deploy.sh` (tests, build, push).
|
|
- The container is `node:24-alpine` running TypeScript directly, so there is no build step; sharp's musl binaries come from `npm ci` on the image's own platform. It writes only under `MENU_ROOT` and healthchecks its own `/healthz`.
|
|
- The Site's Sections, brand and theme live in `config/<site>.site.json`, baked into the image and selected with `MENU_SITE_CONFIG`. Changing Sections is therefore a commit, a new image and a redeploy; the playbook only names the Site.
|
|
- Build locally and push `registry.tanshu.com/menu-admin:<tag>`, matching the sibling `deploy.sh` / `make build-production TAG=` convention.
|
|
- New `menu-admin` playbook in `ansible-playbooks` with per-Site vars (`vars/hopsngrains.yml` first, the other two stubbed): roles `network`, `menu-app` (pull image, create `/var/lib/menu-app.<site>`, start container, health check, then `verify` the data directory) and `caddy` (snippet upsert).
|
|
- The existing `roles/caddy` only *adds* a snippet when its exact text is missing, which cannot change a proxy target; the new playbook's role uses a stable marker so `blockinfile` updates in place, and removes the old role's per-hostname marker at cutover so Caddy never holds two blocks for one hostname.
|
|
- One-time human step on the server: a `scripts/provision-site-env.sh` prompts for the admin password, hashes it with the image's own hasher, and writes `/var/lib/menu-app.<site>/site.env` (mode 600, never in git). Until it exists the admin is disabled and the public menu still serves.
|
|
- A small CLI: `verify` reports integrity problems in the data directory, `repair` regenerates missing variants from the Originals.
|
|
|
|
## Sequence
|
|
|
|
1. Commit `nginx-menu/` to git so the current state is reproducible and revertible; create the `menu-admin` repo and move `CONTEXT.md`, `docs/`, and the ADRs into it.
|
|
2. Storage layer: config, manifest read/write, atomic writes, boot validation, tests.
|
|
3. Image pipeline: sharp variants, content-hashed filenames, tests.
|
|
4. Public renderer: landing, Section and archive routes, design tokens and CSS.
|
|
5. Admin: login, upload with browser pre-shrink, ordering, preview, publish/restore/delete.
|
|
6. Container, compose dev fixtures, Ansible role, deploy to `menu-new.hopsngrains.com`, verify from a phone on restaurant wifi.
|
|
7. Cutover: re-upload the current Food, Bar and Delivery menus, create What's On from the posters, switch `menu.hopsngrains.com` to the app, stop the old nginx container.
|
|
8. After a week: remove the old container and `/var/lib/menu.hopsngrains.com`, delete the dated folders, drop the dead `Caddyfile`, and reduce this folder to dev fixtures.
|
|
|
|
Deleting the old 319MB directory is the only irreversible step, so it is last.
|
|
|
|
## Out of scope
|
|
|
|
Backups (owner already runs them), Prometheus metrics, Promtail/Loki integration, multi-user accounts, scheduled publishing, admin-editable Sections, migrating `menu.hngmohali.com` and `menu.greatbear.in`, and git history for the image content.
|