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.
137 lines
11 KiB
Markdown
137 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 against a `fixtures/` folder, `docs/` (this plan, `CONTEXT.md`, the ADRs), and `node --test` tests for the storage and image layers.
|
|
- 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), `caddy` (snippet upsert).
|
|
- The existing `roles/caddy` only *adds* a snippet when missing, which cannot change a proxy target; it is fixed to update an existing managed block in place.
|
|
- One-time human step on the server: a `scripts/provision-site-env.sh` prompts for the admin password, hashes it, and writes `/var/lib/menu-app.<site>/site.env` (mode 600, never in git).
|
|
- 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.
|