diff --git a/README.md b/README.md index 9ff8a87..0bc0451 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ photographed Pages, replacing the `scp`-a-JPG workflow. One instance per Site; ## Status -Built: Site config, the Menu Set store, the image pipeline, and the +Built: Site config, the Menu Set store, the image pipeline, the customer-facing renderer (landing page, Section pages, archived sets, media -route) with the approved design. Not yet built: the admin, the container and -the Ansible role. +route) with the approved design, and the admin (sign in, create a set, upload +and reorder Pages, preview, publish, restore, delete). Not yet built: the +container, the Ansible playbook and the deploy. ## Layout @@ -19,9 +20,10 @@ src/storage/manifest.ts Manifest type, validation, atomic JSON writes src/storage/sets.ts The Menu Set store: create, add Pages, publish, restore, delete src/storage/validate.ts Boot validation: fatal problems and warnings src/images/pipeline.ts sharp variants: Original, Viewing image, Thumbnail -src/auth.ts scrypt password hash, signed session cookie, login throttle -src/render/ HTML builder, document shell, the customer pages +src/auth.ts scrypt password hash, session cookie, login throttle, CSRF +src/render/ HTML builder, document shell, the customer pages, the admin src/routes/public.ts Landing, Section, archive and media routes +src/routes/admin.ts Sign in, dashboard, editor, upload, publish, restore, delete src/server.ts buildApp (what tests drive) and main (what the container runs) public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo config/ Per-Site config, one file per Site @@ -42,6 +44,20 @@ npm run seed # placeholder Pages in .dev/data, for design review npm run dev # http://localhost:3000 against .dev ``` +To sign in locally, put a password hash in `.dev/site.env`: + +```sh +printf 'ADMIN_PASSWORD_HASH=%s\nSESSION_SECRET=%s\n' \ + "$(node -e "import('./src/auth.ts').then(async m => process.stdout.write(await m.hashPassword('your password')))")" \ + "$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")" \ + > .dev/site.env +``` + +## Who can do what + +`/` and `/
/` are public. `/admin` needs the password; a Draft's Pages +are served only to a signed-in session, and Originals have no route at all. + ## Storage model ``` diff --git a/package-lock.json b/package-lock.json index 4d6d567..3c1e393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@fastify/cookie": "^11.1.2", + "@fastify/formbody": "^9.0.0", "@fastify/multipart": "^10.1.2", "@fastify/static": "^10.1.4", "fastify": "^5.12.5", @@ -146,6 +147,26 @@ "fast-json-stringify": "^7.0.0" } }, + "node_modules/@fastify/formbody": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-9.0.0.tgz", + "integrity": "sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^6.0.0" + } + }, "node_modules/@fastify/forwarded": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", diff --git a/package.json b/package.json index 372732b..ea2c597 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@fastify/cookie": "^11.1.2", + "@fastify/formbody": "^9.0.0", "@fastify/multipart": "^10.1.2", "@fastify/static": "^10.1.4", "fastify": "^5.12.5", diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..6a38477 --- /dev/null +++ b/public/admin.css @@ -0,0 +1,322 @@ +/* The admin shares the customer tokens and stays out of the way: forms, + lists, and thumbnails good enough to judge a page by. */ + +.admin { + max-width: 62rem; + margin: 0 auto; + padding: 1.25rem 1rem 4rem; +} + +.admin__bar { + display: flex; + align-items: baseline; + gap: 0.85rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--hairline); +} + +.admin__title { + margin: 0; + font-size: var(--step-2); + font-weight: 600; + font-stretch: 116%; +} + +.admin__section { + flex: 1 1 auto; + color: var(--ink-dim); + font-size: 0.9rem; +} + +.block { + margin-top: 1.75rem; +} + +.block__title { + margin: 0 0 0.5rem; + color: var(--ink-dim); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.02em; +} + +.setlist { + margin: 0; + padding: 0; + list-style: none; +} + +.setlist li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0; + border-bottom: 1px solid var(--hairline-soft); +} + +.setlist__none { + color: var(--ink-dim); + font-size: 0.9rem; +} + +.setlist__main { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; +} + +.setlist__label { + font-weight: 500; +} + +.setlist__meta { + color: var(--ink-dim); + font-size: 0.85rem; +} + +.setlist__actions { + display: flex; + gap: 0.35rem; + white-space: nowrap; +} + +.pill { + padding: 0.1rem 0.4rem; + border: 1px solid var(--hairline); + color: var(--ink-dim); + font-size: 0.72rem; + text-transform: none; +} + +.pill--current { + border-color: transparent; + background: var(--accent); + color: var(--steel); + font-weight: 600; +} + +.pill--draft { + border-color: transparent; + background: var(--copper); + color: var(--steel); + font-weight: 600; +} + +.button { + display: inline-block; + padding: 0.4rem 0.7rem; + border: 1px solid var(--hairline); + background: var(--rail); + color: var(--ink); + font: inherit; + font-size: 0.88rem; + cursor: pointer; +} + +.button:hover { + border-color: var(--accent); +} + +.button--primary { + border-color: transparent; + background: var(--accent); + color: var(--steel); + font-weight: 600; +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.button--danger { + border-color: color-mix(in srgb, #d06a5a 55%, transparent); + color: #eeb8ae; +} + +.button--icon { + padding: 0.25rem 0.5rem; + font-size: 0.9rem; +} + +.newset, +.inline-form { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.9rem; +} + +.newset { + padding-top: 0.9rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 12rem; +} + +.field label { + display: flex; + flex-direction: column; + gap: 0.3rem; + color: var(--ink-dim); + font-size: 0.82rem; +} + +.field input { + padding: 0.45rem 0.55rem; + border: 1px solid var(--hairline); + background: var(--steel); + color: var(--ink); + font: inherit; + font-size: 0.95rem; +} + +.field__hint, +.muted { + color: var(--ink-dim); + font-size: 0.85rem; +} + +.actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.drop { + padding: 1.1rem; + border: 1px dashed var(--hairline); + color: var(--ink-dim); + text-align: center; +} + +.drop.is-over { + border-color: var(--accent); + color: var(--ink); +} + +.drop__text { + margin: 0; +} + +.drop__hint { + margin: 0.4rem 0 0; + font-size: 0.82rem; +} + +.drop__status { + margin: 0.6rem 0 0; + min-height: 1.2em; + color: var(--accent); + font-size: 0.88rem; +} + +.no-js-upload { + display: none; +} + +.pages-admin { + display: grid; + gap: 0.5rem; + margin: 1rem 0 0; + padding: 0; + list-style: none; +} + +.page-admin { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + background: var(--rail); +} + +.page-admin.is-dragging { + opacity: 0.4; +} + +.page-admin img { + display: block; + width: 64px; + height: auto; + background: var(--paper); +} + +.page-admin__handle { + color: var(--ink-dim); + cursor: grab; + user-select: none; +} + +.page-admin__meta { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + font-size: 0.82rem; + color: var(--ink-dim); +} + +.page-admin__number { + color: var(--ink); + font-weight: 600; +} + +.page-admin__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-admin__actions { + display: flex; + gap: 0.25rem; +} + +.notice--error { + border-left-color: #d06a5a; + background: color-mix(in srgb, #d06a5a 18%, var(--rail)); +} + +.fold summary { + color: var(--ink-dim); + font-size: 0.85rem; + cursor: pointer; +} + +/* Login */ + +.login { + max-width: 21rem; + margin: 14vh auto 0; + padding: 0 1rem; +} + +.login__sub { + margin: 0.2rem 0 1.5rem; + color: var(--ink-dim); +} + +.login form { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-start; +} + +.login .field { + width: 100%; +} + +.login input { + width: 100%; +} diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..8c1cfab --- /dev/null +++ b/public/admin.js @@ -0,0 +1,202 @@ +/* Admin enhancements. Every action here also has a form-post equivalent, so + losing this file costs convenience, not function. */ +(function () { + 'use strict'; + + var root = document.querySelector('[data-set-id]'); + if (!root) return; + + var setId = root.getAttribute('data-set-id'); + var csrf = root.getAttribute('data-csrf'); + var MAX_EDGE = 3000; + var JPEG_QUALITY = 0.92; + + var drop = document.querySelector('[data-drop]'); + var fileInput = document.querySelector('[data-file-input]'); + var status = document.querySelector('[data-upload-status]'); + var choose = document.querySelector('[data-choose]'); + var list = document.querySelector('[data-pages-admin]'); + var noJsForm = document.querySelector('.no-js-upload'); + + if (noJsForm) noJsForm.style.display = 'none'; + + Array.prototype.forEach.call(document.querySelectorAll('[data-confirm]'), function (form) { + form.addEventListener('submit', function (event) { + if (!window.confirm(form.getAttribute('data-confirm'))) event.preventDefault(); + }); + }); + + function say(message) { + if (status) status.textContent = message; + } + + /* Shrink in the browser so a 20-page menu is quick to upload over restaurant + wifi. Photos the browser cannot decode (HEIC, say) go up untouched and are + rejected later with a message naming the file. */ + function shrink(file) { + return new Promise(function (resolve) { + if (!/^image\/(jpeg|png|webp)$/.test(file.type) || typeof createImageBitmap !== 'function') { + return resolve(file); + } + createImageBitmap(file) + .then(function (bitmap) { + var scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height)); + if (scale === 1 && file.type === 'image/jpeg' && file.size < 4 * 1024 * 1024) { + bitmap.close(); + return resolve(file); + } + var canvas = document.createElement('canvas'); + canvas.width = Math.round(bitmap.width * scale); + canvas.height = Math.round(bitmap.height * scale); + canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close(); + canvas.toBlob( + function (blob) { + if (!blob || blob.size >= file.size) return resolve(file); + var name = file.name.replace(/\.[^.]+$/, '') + '.jpg'; + resolve(new File([blob], name, { type: 'image/jpeg' })); + }, + 'image/jpeg', + JPEG_QUALITY, + ); + }) + .catch(function () { + resolve(file); + }); + }); + } + + function uploadOne(file) { + var body = new FormData(); + body.append('pages', file, file.name); + return fetch('/admin/sets/' + setId + '/pages', { + method: 'POST', + headers: { 'x-csrf': csrf, accept: 'application/json' }, + body: body, + }).then(function (response) { + return response.json().then(function (payload) { + if (!response.ok) throw new Error(payload.error || 'upload failed'); + if (payload.failed && payload.failed.length > 0) { + throw new Error(payload.failed.map(function (item) { return item.error; }).join('; ')); + } + return payload; + }); + }); + } + + function upload(files) { + var queue = Array.prototype.slice.call(files); + if (queue.length === 0) return; + var failures = []; + + function next(index) { + if (index >= queue.length) { + if (failures.length === 0) { + say('Uploaded ' + queue.length + '. Reloading…'); + window.location.reload(); + } else { + say('Failed: ' + failures.join('; ')); + } + return; + } + say('Shrinking and uploading ' + (index + 1) + ' of ' + queue.length + '…'); + shrink(queue[index]) + .then(uploadOne) + .then(function () { + next(index + 1); + }) + .catch(function (error) { + failures.push(error.message); + next(index + 1); + }); + } + + next(0); + } + + if (choose && fileInput) { + choose.addEventListener('click', function () { + fileInput.click(); + }); + } + + if (fileInput) { + fileInput.addEventListener('change', function () { + upload(fileInput.files); + }); + } + + if (drop) { + ['dragenter', 'dragover'].forEach(function (type) { + drop.addEventListener(type, function (event) { + event.preventDefault(); + drop.classList.add('is-over'); + }); + }); + ['dragleave', 'drop'].forEach(function (type) { + drop.addEventListener(type, function (event) { + event.preventDefault(); + drop.classList.remove('is-over'); + }); + }); + drop.addEventListener('drop', function (event) { + if (event.dataTransfer && event.dataTransfer.files) upload(event.dataTransfer.files); + }); + } + + /* Drag to reorder. The up/down buttons remain for touch and no-JS. */ + function items() { + return Array.prototype.slice.call(list.querySelectorAll('[data-page-id]')); + } + + function renumber() { + items().forEach(function (item, index) { + var number = item.querySelector('.page-admin__number'); + if (number) number.textContent = String(index + 1); + }); + } + + function saveOrder() { + var ids = items().map(function (item) { + return item.getAttribute('data-page-id'); + }); + say('Saving order…'); + return fetch('/admin/sets/' + setId + '/reorder', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-csrf': csrf, accept: 'application/json' }, + body: JSON.stringify({ pageIds: ids }), + }) + .then(function (response) { + if (!response.ok) throw new Error('could not save the order'); + say('Order saved.'); + }) + .catch(function (error) { + say(error.message); + }); + } + + if (list) { + var dragged = null; + + items().forEach(function (item) { + item.addEventListener('dragstart', function (event) { + dragged = item; + item.classList.add('is-dragging'); + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; + }); + item.addEventListener('dragend', function () { + item.classList.remove('is-dragging'); + dragged = null; + renumber(); + saveOrder(); + }); + item.addEventListener('dragover', function (event) { + if (!dragged || dragged === item) return; + event.preventDefault(); + var rect = item.getBoundingClientRect(); + var after = event.clientY > rect.top + rect.height / 2; + list.insertBefore(dragged, after ? item.nextSibling : item); + }); + }); + } +})(); diff --git a/public/styles.css b/public/styles.css index f6f530f..ec2786a 100644 --- a/public/styles.css +++ b/public/styles.css @@ -236,6 +236,12 @@ a { text-decoration: underline; } +.notice--admin { + max-width: none; + border-left-color: var(--accent); + background: color-mix(in srgb, var(--accent) 22%, var(--rail)); +} + /* ---------- The Pages ---------- */ .pages { diff --git a/src/auth.ts b/src/auth.ts index 82c37b1..c37d3df 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -94,8 +94,7 @@ export function verifySessionToken(token: string | undefined, secret: string, no return Number.isSafeInteger(expiry) && expiry > now.getTime(); } -export interface ThrottleVerdict { - allowed: boolean; +export interface ThrottleVerdict { allowed: boolean; retryAfterSeconds: number; } @@ -147,3 +146,24 @@ export class LoginThrottle { this.failures.delete(key); } } + +/** + * A stateless CSRF token derived from the session cookie: a cross-site request + * cannot produce it, because it cannot read the httpOnly cookie. + */ +export function csrfToken(sessionToken: string | undefined, secret: string): string { + return createHmac('sha256', secret).update(`csrf:${sessionToken ?? 'anonymous'}`).digest('base64url'); +} + +export function verifyCsrf( + sessionToken: string | undefined, + secret: string, + provided: unknown, +): boolean { + if (typeof provided !== 'string' || provided === '') return false; + const providedBuffer = Buffer.from(provided); + const expectedBuffer = Buffer.from(csrfToken(sessionToken, secret)); + return ( + providedBuffer.length === expectedBuffer.length && timingSafeEqual(providedBuffer, expectedBuffer) + ); +} diff --git a/src/render/admin.ts b/src/render/admin.ts new file mode 100644 index 0000000..d23e67c --- /dev/null +++ b/src/render/admin.ts @@ -0,0 +1,278 @@ +/** + * The admin's server-rendered pages: a login form, a dashboard of Sections + * and their Menu Sets, and the editor for one set. Forms post and redirect, so + * the whole thing works without JavaScript; the script only adds drag-to-reorder + * and browser-side pre-shrinking of uploads. + */ + +import { html, Html, join } from './html.ts'; +import { renderDocument } from './layout.ts'; +import { pageCountLabel, setDateLabel } from './pages.ts'; +import type { SectionConfig, SiteConfig } from '../config.ts'; +import { variantFileNames } from '../storage/manifest.ts'; +import type { StoredSet } from '../storage/sets.ts'; + +export interface AdminShellInput { + config: SiteConfig; + title: string; + bodyClass: string; + body: Html; + scripts: readonly string[]; +} + +function shell(input: AdminShellInput): Html { + return renderDocument({ + config: input.config, + title: input.title, + bodyClass: `admin-page ${input.bodyClass}`, + description: null, + noindex: true, + styles: ['/assets/admin.css'], + scripts: input.scripts, + body: input.body, + }); +} + +function labelled( + label: string, + control: Html, + hint: string | null = null, +): Html { + return html` + + ${hint === null ? '' : html`${hint}`} + `; +} + +export function renderLogin(config: SiteConfig, error: string | null): Html { + return shell({ + config, + title: 'Sign in', + bodyClass: 'admin-login', + scripts: [], + body: html`
+

${config.brand.name}

+ + ${error === null ? '' : html`

${error}

`} +
+ ${labelled('Password', html``)} + +
+
`, + }); +} + +function setRow(section: SectionConfig, set: StoredSet, kind: 'current' | 'draft' | 'archive'): Html { + const pill = + kind === 'current' + ? html`Current` + : kind === 'draft' + ? html`Draft` + : html`Archive`; + const actions = + kind === 'draft' + ? html`Edit + Preview` + : html`Open + View`; + + return html`
  • + + ${pill} + ${set.manifest.label} + ${setDateLabel(set.manifest)} · ${pageCountLabel(set.manifest.pages.length)} + + ${actions} +
  • `; +} + +export interface DashboardInput { + config: SiteConfig; + sets: readonly StoredSet[]; + current: ReadonlyMap; + notice: string | null; +} + +export function renderDashboard(input: DashboardInput): Html { + const { config } = input; + const bySection = (slug: string) => input.sets.filter((set) => set.section === slug); + + const blocks = config.sections.map((section) => { + const all = bySection(section.slug); + const current = input.current.get(section.slug); + const drafts = all.filter((set) => set.state === 'draft'); + const archives = all + .filter((set) => set.state === 'published' && set.manifest.id !== current?.manifest.id) + .sort((a, b) => (b.manifest.publishedAt ?? '').localeCompare(a.manifest.publishedAt ?? '')); + + return html`
    +

    ${section.label}

    +
      + ${current === undefined ? html`
    • No menu published yet.
    • ` : setRow(section, current, 'current')} + ${join(drafts.map((set) => setRow(section, set, 'draft')))} +
    + ${archives.length === 0 + ? '' + : html`
    + ${archives.length === 1 ? '1 older menu' : `${archives.length} older menus`} +
      + ${join(archives.map((set) => setRow(section, set, 'archive')))} +
    +
    `} +
    + + ${labelled( + 'New Menu Set', + html``, + )} + ${labelled('Valid until (optional)', html``)} + +
    +
    `; + }); + + return shell({ + config, + title: 'Menus', + bodyClass: 'admin-dashboard', + scripts: [], + body: html`
    +
    +

    Menus

    +
    + +
    +
    + ${input.notice === null ? '' : html`

    ${input.notice}

    `} + ${blocks} +
    `, + }); +} + +export interface EditorInput { + config: SiteConfig; + section: SectionConfig; + set: StoredSet; + csrf: string; + notice: string | null; + error: string | null; +} + +export function renderEditor(input: EditorInput): Html { + const { config, section, set } = input; + const draft = set.state === 'draft'; + + const pageItems = set.manifest.pages.map((page, index) => { + const names = variantFileNames(page); + return html`
  • + + + + ${index + 1} + ${page.originalName} + ${page.width} × ${page.height} + + ${draft + ? html` +
    + + + +
    +
    + + + +
    +
    + +
    +
    ` + : ''} +
  • `; + }); + + return shell({ + config, + title: `${set.manifest.label} · ${section.label}`, + bodyClass: 'admin-editor', + scripts: ['/assets/admin.js'], + body: html`
    +
    +

    ${set.manifest.label}

    + ${section.label} + All menus +
    + ${input.notice === null ? '' : html`

    ${input.notice}

    `} + ${input.error === null ? '' : html`

    ${input.error}

    `} + + ${set.state === 'published' + ? html`

    + This menu is published at /${section.slug}/${set.slug}/. + A published menu never changes; to change it, create a new Menu Set. +

    ` + : ''} + +
    +

    Details

    + ${draft + ? html`
    + ${labelled('Label', html``)} + ${labelled( + 'Valid until (optional)', + html``, + )} + +
    ` + : html`

    ${setDateLabel(set.manifest)}

    `} +
    + + ${draft + ? html`
    +

    Pages

    +
    + +

    Drop photos here, or

    +

    + JPEG, PNG or WebP. Photos are shrunk in the browser to ${3000}px before they are uploaded. +

    +

    +
    +
    + + +
    +
      + ${pageItems.length === 0 ? html`
    • No pages yet.
    • ` : join(pageItems)} +
    +
    ` + : html`
    +

    Pages

    +
      ${join(pageItems)}
    +
    `} + +
    +

    ${draft ? 'Publish' : 'Manage'}

    +
    + ${draft + ? html`
    + +
    + Preview` + : html`View live`} + ${set.state === 'published' + ? html`
    + +
    ` + : ''} +
    + + +
    +
    +
    +
    `, + }); +} diff --git a/src/render/layout.ts b/src/render/layout.ts index f31419d..7b4e087 100644 --- a/src/render/layout.ts +++ b/src/render/layout.ts @@ -9,7 +9,9 @@ export interface DocumentInput { body: Html; description: string | null; noindex: boolean; - script: boolean; + /** Extra stylesheets, on top of the customer stylesheet every page gets. */ + styles: readonly string[]; + scripts: readonly string[]; } export function renderDocument(input: DocumentInput): Html { @@ -38,6 +40,7 @@ export function renderDocument(input: DocumentInput): Html { crossorigin /> + ${input.styles.map((href) => html``)}