diff --git a/.gitignore b/.gitignore index b8e394a..338d115 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .npm-cache/ .tmp/ +.dev/ *.log site.env diff --git a/README.md b/README.md index 979c729..9ff8a87 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ photographed Pages, replacing the `scp`-a-JPG workflow. One instance per Site; ## Status -Built so far: Site config parsing, the Menu Set store and the image pipeline, -with tests. Not yet built: the public renderer, the admin, the container and the -Ansible role. +Built: Site config, the Menu Set store, the image pipeline, and 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. ## Layout @@ -18,6 +19,12 @@ 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/routes/public.ts Landing, Section, archive and media routes +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 test/ node:test suites docs/plan.md The agreed plan docs/adr/ Decisions worth remembering @@ -31,6 +38,8 @@ Node 24 or newer runs the TypeScript sources directly. npm install npm test # node:test npm run typecheck # tsc --noEmit +npm run seed # placeholder Pages in .dev/data, for design review +npm run dev # http://localhost:3000 against .dev ``` ## Storage model diff --git a/config/hopsngrains.site.json b/config/hopsngrains.site.json new file mode 100644 index 0000000..4f44f6a --- /dev/null +++ b/config/hopsngrains.site.json @@ -0,0 +1,27 @@ +{ + "site": "menu.hopsngrains.com", + "brand": { + "name": "Hops n Grains", + "tagline": "Handcrafted Microbrewery", + "logo": "/assets/favicon.svg" + }, + "theme": { + "steel": "#14181b", + "rail": "#1f262b", + "paper": "#f2efe9", + "accent": "#0088aa", + "copper": "#b2703b" + }, + "sections": [ + { "slug": "food", "label": "Food", "shortLabel": "Food", "order": 1 }, + { "slug": "bar", "label": "Bar", "shortLabel": "Bar", "order": 2 }, + { "slug": "wine", "label": "Wine", "shortLabel": "Wine", "order": 3 }, + { "slug": "straight-drinks", "label": "Straight Drinks", "shortLabel": "Drinks", "order": 4 }, + { "slug": "delivery", "label": "Delivery", "shortLabel": "Delivery", "order": 5 }, + { "slug": "whats-on", "label": "What's On", "shortLabel": "What's On", "order": 6 } + ], + "limits": { + "maxUploadBytes": 31457280, + "maxPagesPerSet": 40 + } +} diff --git a/package-lock.json b/package-lock.json index 1974c6b..4d6d567 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/multipart": "^10.1.2", + "@fastify/static": "^10.1.4", "fastify": "^5.12.5", "sharp": "^0.35.4" }, @@ -31,6 +32,22 @@ "tslib": "^2.4.0" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", + "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", @@ -207,6 +224,54 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/send": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", + "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.4.tgz", + "integrity": "sha512-YWYmXV0c3t/3DDyV32kRXJTgTY16FOkqtQ4mmBjbkWeukGR5n+Sr+7ppknNpS7hiRPrLU9zcySZZ1nLJVHTZkg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^3.0.0", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -755,6 +820,15 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1195,6 +1269,40 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/content-disposition": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz", + "integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", @@ -1208,6 +1316,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1226,6 +1343,12 @@ "node": ">=8" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -1359,6 +1482,49 @@ "node": ">=20" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", @@ -1443,6 +1609,51 @@ ], "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1452,6 +1663,22 @@ "node": ">=14.0.0" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -1619,6 +1846,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", @@ -1686,6 +1919,15 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -1713,6 +1955,15 @@ "node": ">=20" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index fa3b255..372732b 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,15 @@ }, "scripts": { "start": "node src/server.ts", - "dev": "node --watch src/server.ts", + "dev": "SECURE_COOKIES=false node --watch src/server.ts", + "seed": "node scripts/seed-dev.ts", "typecheck": "tsc --noEmit", "test": "node --test test/*.test.ts" }, "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/multipart": "^10.1.2", + "@fastify/static": "^10.1.4", "fastify": "^5.12.5", "sharp": "^0.35.4" }, diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..5d4170e --- /dev/null +++ b/public/app.js @@ -0,0 +1,120 @@ +/* Progressive enhancement only: the pour line, the page counter, and + tap-to-zoom. Without this file every menu is still readable and every + Page opens as a plain link. */ +(function () { + 'use strict'; + + var reducesMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + var pages = document.querySelector('[data-pages]'); + var fill = document.querySelector('[data-pour]'); + var counter = document.querySelector('[data-counter]'); + var counterCurrent = counter && counter.querySelector('[data-counter-current]'); + var figures = pages ? Array.prototype.slice.call(pages.querySelectorAll('[data-page]')) : []; + var currentPage = 1; + + function updatePour() { + if (!pages || !fill) return; + var rect = pages.getBoundingClientRect(); + var travel = rect.height - window.innerHeight; + if (travel <= 0) { + fill.style.width = '100%'; + return; + } + var progress = Math.min(Math.max(-rect.top, 0), travel) / travel; + fill.style.width = (progress * 100).toFixed(1) + '%'; + } + + function updateCounter() { + if (!counter || figures.length === 0) return; + var middle = window.innerHeight / 2; + var best = 1; + var bestDistance = Infinity; + figures.forEach(function (figure, index) { + var rect = figure.getBoundingClientRect(); + var distance = Math.abs(rect.top + rect.height / 2 - middle); + if (distance < bestDistance) { + bestDistance = distance; + best = index + 1; + } + }); + if (best !== currentPage) { + currentPage = best; + if (counterCurrent) counterCurrent.textContent = String(best); + } + } + + function onScroll() { + updatePour(); + updateCounter(); + } + + if (counter) counter.hidden = false; + if (figures.length > 0) { + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll); + onScroll(); + } + + if (counter) { + counter.addEventListener('click', function () { + var fold = document.querySelector('.fold--index'); + if (!fold) return; + fold.open = true; + fold.scrollIntoView({ behavior: reducesMotion ? 'auto' : 'smooth', block: 'center' }); + var first = fold.querySelector('a'); + if (first) first.focus({ preventScroll: true }); + }); + } + + var overlay = null; + + function closeOverlay() { + if (!overlay) return; + overlay.remove(); + overlay = null; + document.body.style.overflow = ''; + document.removeEventListener('keydown', onKeydown); + } + + function onKeydown(event) { + if (event.key === 'Escape') closeOverlay(); + } + + function openOverlay(href, alt) { + overlay = document.createElement('div'); + overlay.className = 'zoom'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', alt); + + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'zoom__close'; + close.textContent = 'Close'; + + var image = document.createElement('img'); + image.src = href; + image.alt = alt; + + overlay.appendChild(close); + overlay.appendChild(image); + overlay.addEventListener('click', function (event) { + if (event.target === overlay || event.target === close) closeOverlay(); + }); + document.addEventListener('keydown', onKeydown); + document.body.appendChild(overlay); + document.body.style.overflow = 'hidden'; + close.focus(); + } + + Array.prototype.forEach.call(document.querySelectorAll('[data-zoom]'), function (link) { + link.addEventListener('click', function (event) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + var image = link.querySelector('img'); + event.preventDefault(); + openOverlay(link.getAttribute('href'), (image && image.alt) || 'Menu page'); + }); + }); +})(); diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..a3a8b9d --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/fonts/OFL.txt b/public/fonts/OFL.txt new file mode 100644 index 0000000..8597481 --- /dev/null +++ b/public/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/archivo-latin-ext.woff2 b/public/fonts/archivo-latin-ext.woff2 new file mode 100644 index 0000000..c0dbc07 Binary files /dev/null and b/public/fonts/archivo-latin-ext.woff2 differ diff --git a/public/fonts/archivo-latin.woff2 b/public/fonts/archivo-latin.woff2 new file mode 100644 index 0000000..e2448d0 Binary files /dev/null and b/public/fonts/archivo-latin.woff2 differ diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..f6f530f --- /dev/null +++ b/public/styles.css @@ -0,0 +1,433 @@ +/* Hops n Grains menu. Steel is the taproom, paper is the menu: the interface + stays quiet so the photographed pages are the only loud thing. */ + +@font-face { + font-family: 'Archivo'; + font-style: normal; + font-weight: 100 900; + font-stretch: 62% 125%; + font-display: swap; + src: url('/assets/fonts/archivo-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Archivo'; + font-style: normal; + font-weight: 100 900; + font-stretch: 62% 125%; + font-display: swap; + src: url('/assets/fonts/archivo-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, + U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, + U+2C60-2C7F, U+A720-A7FF; +} + +:root { + --steel: #14181b; + --rail: #1f262b; + --paper: #f2efe9; + --accent: #0088aa; + --copper: #b2703b; + --ink: #e8e6e1; + --ink-dim: #99a2a8; + --ink-on-paper: #1b1f22; + --hairline: color-mix(in srgb, var(--copper) 40%, transparent); + --hairline-soft: color-mix(in srgb, var(--copper) 22%, transparent); + --step-0: 1rem; + --step-1: 1.25rem; + --step-2: 1.55rem; + --step-3: 2.4rem; + --page-max: 780px; + --content-max: 56rem; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--steel); + color: var(--ink); + font-family: 'Archivo', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: var(--step-0); + line-height: 1.5; +} + +a { + color: inherit; + text-decoration: none; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +/* ---------- Landing ---------- */ + +.landing { + max-width: var(--content-max); + margin: 0 auto; + padding: 0 1.25rem 3rem; +} + +.masthead { + padding: clamp(2.5rem, 11vh, 5.5rem) 0 1.75rem; +} + +.wordmark { + margin: 0; + font-size: clamp(1.9rem, 8vw, 2.9rem); + font-weight: 600; + font-stretch: 122%; + letter-spacing: -0.01em; + line-height: 1.05; +} + +.masthead__tagline { + margin: 0.4rem 0 0; + color: var(--ink-dim); + font-size: var(--step-1); +} + +.rows { + border-top: 1px solid var(--hairline); +} + +.row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 1.1rem 0.15rem; + border-bottom: 1px solid var(--hairline); +} + +.row__label { + font-size: clamp(1.45rem, 6.5vw, 2rem); + font-weight: 600; + font-stretch: 116%; + line-height: 1.15; +} + +.row__meta { + color: var(--ink-dim); + font-size: 0.95rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.row:hover .row__label, +.row:focus-visible .row__label { + color: var(--accent); +} + +.empty { + max-width: 34ch; + margin: 0; + color: var(--ink-dim); + font-size: var(--step-1); +} + +/* ---------- Section page: the rail and the pour line ---------- */ + +.bar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.5rem 0.9rem 0.4rem; + background: var(--rail); +} + +.bar__brand { + font-size: 0.95rem; + font-weight: 600; + font-stretch: 118%; + white-space: nowrap; +} + +.rail { + display: flex; + flex: 1 1 auto; + gap: 0.15rem; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} + +.rail::-webkit-scrollbar { + display: none; +} + +.rail__item { + padding: 0.35rem 0.6rem; + color: var(--ink-dim); + font-size: 0.95rem; + white-space: nowrap; +} + +.rail__item.is-current { + background: var(--accent); + color: var(--steel); + font-weight: 600; +} + +.pour { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + background: var(--hairline-soft); +} + +.pour__fill { + display: block; + width: 0; + height: 100%; + background: var(--copper); +} + +.section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + max-width: var(--page-max); + margin: 1.35rem auto 0.85rem; + padding: 0 1rem; +} + +.section-heading__label { + font-size: var(--step-2); + font-weight: 600; + font-stretch: 116%; +} + +.section-heading__date { + color: var(--ink-dim); + font-size: 0.9rem; + font-variant-numeric: tabular-nums; +} + +.notice { + max-width: var(--page-max); + margin: 0 auto 1rem; + padding: 0.6rem 0.8rem; + border-left: 2px solid var(--copper); + background: var(--rail); + font-size: 0.95rem; +} + +.notice a { + color: var(--accent); + text-decoration: underline; +} + +/* ---------- The Pages ---------- */ + +.pages { + max-width: var(--page-max); + margin: 0 auto; + padding: 0 0.75rem 1.25rem; +} + +.page { + margin: 0 0 0.7rem; + background: var(--paper); +} + +.page__zoom { + display: block; +} + +.page img { + display: block; + width: 100%; + height: auto; +} + +/* ---------- Footer: page index and older menus ---------- */ + +.foot { + max-width: var(--page-max); + margin: 0 auto 4.5rem; + padding: 0 1rem 1rem; + border-top: 1px solid var(--hairline-soft); +} + +.fold { + padding: 0.7rem 0; + border-bottom: 1px solid var(--hairline-soft); +} + +.fold summary { + color: var(--ink-dim); + font-size: 0.95rem; + cursor: pointer; +} + +.fold[open] summary { + color: var(--ink); +} + +.index { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0; + padding: 0.85rem 0 0; + list-style: none; +} + +.index a { + display: block; + min-width: 2.3rem; + padding: 0.3rem 0.45rem; + background: var(--rail); + font-variant-numeric: tabular-nums; + text-align: center; +} + +.index a:hover, +.index a:focus-visible { + background: var(--accent); + color: var(--steel); +} + +.older { + margin: 0.5rem 0 0; + padding: 0; + list-style: none; +} + +.older a { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.45rem 0.15rem; +} + +.older__label { + font-weight: 500; +} + +.older__date { + color: var(--ink-dim); + font-size: 0.9rem; + font-variant-numeric: tabular-nums; +} + +.fold__back { + margin: 0.7rem 0 0; +} + +.fold__back a { + color: var(--accent); + text-decoration: underline; +} + +/* ---------- Counter and zoom ---------- */ + +.counter { + position: fixed; + right: 0.9rem; + bottom: calc(0.9rem + env(safe-area-inset-bottom)); + z-index: 30; + display: inline-flex; + align-items: baseline; + gap: 0.3rem; + padding: 0.45rem 0.7rem; + border: 1px solid var(--hairline); + background: var(--paper); + color: var(--ink-on-paper); + font: inherit; + font-size: 0.9rem; + font-variant-numeric: tabular-nums; + cursor: pointer; +} + +.counter[hidden] { + display: none; +} + +.counter__total { + color: color-mix(in srgb, var(--ink-on-paper) 55%, transparent); +} + +.zoom { + position: fixed; + inset: 0; + z-index: 40; + overflow: auto; + background: rgba(8, 10, 12, 0.95); + -webkit-overflow-scrolling: touch; +} + +.zoom img { + display: block; + width: min(100%, 1600px); + height: auto; + margin: 0 auto; +} + +.zoom__close { + position: fixed; + top: 0.6rem; + right: 0.6rem; + z-index: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--hairline); + background: var(--paper); + color: var(--ink-on-paper); + font: inherit; + cursor: pointer; +} + +/* ---------- Wider screens ---------- */ + +@media (min-width: 60rem) { + .pages { + max-width: 820px; + } +} + +@media print { + .bar, + .counter, + .fold, + .notice { + display: none; + } + + body { + background: #fff; + color: #000; + } + + .page { + break-inside: avoid; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +} diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts new file mode 100644 index 0000000..8ef6b36 --- /dev/null +++ b/scripts/seed-dev.ts @@ -0,0 +1,104 @@ +/** + * Seeds a local development Site: placeholder Pages that look enough like + * printed menus to design against. Destructive: it wipes the data directory + * under MENU_ROOT. + * + * npm run seed + */ + +import { mkdir, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; +import { parseSiteConfig } from '../src/config.ts'; +import { createImagePipeline } from '../src/images/pipeline.ts'; +import { SetStore } from '../src/storage/sets.ts'; + +const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev'); +const configPath = path.resolve( + process.env['MENU_SITE_CONFIG'] ?? 'config/hopsngrains.site.json', +); +const dataDir = path.join(root, 'data'); + +const config = parseSiteConfig(JSON.parse(await readFile(configPath, 'utf8'))); + +function menuPage(title: string, section: string, page: number, total: number, seed: number): string { + const items = Array.from({ length: 7 }, (_, index) => { + const y = 420 + index * 130; + const width = 180 + ((seed * 37 + index * 53) % 220); + return ` + ${ + ['Wood-fired pizza', 'Smash burger', 'Tenderloin steak', 'Craft platter', 'Fish and chips', 'Risotto', 'Cheesecake'][index % 7] + } + ${ + ['thin crust, basil', 'double patty, cheddar', 'pepper sauce', 'for two', 'tartare', 'wild mushroom', 'berry coulis'][index % 7] + } + ${ + 240 + ((seed * 91 + index * 71) % 620) + } + `; + }).join(''); + + return ` + + + ${title} + ${section} · page ${page} of ${total} + + ${items} + Placeholder page for design review + ${page} / ${total} + `; +} + +async function pageImage(svg: string): Promise { + return sharp(Buffer.from(svg)).jpeg({ quality: 82 }).toBuffer(); +} + +const plan: { section: string; label: string; validUntil: string | null; pages: number }[] = [ + { section: 'food', label: 'Food Menu', validUntil: '2026-12-31', pages: 4 }, + { section: 'bar', label: 'Bar Menu', validUntil: '2026-06-26', pages: 3 }, + { section: 'wine', label: 'Wine List', validUntil: null, pages: 2 }, + { section: 'whats-on', label: "What's On", validUntil: null, pages: 2 }, +]; + +await rm(dataDir, { recursive: true, force: true }); +await mkdir(dataDir, { recursive: true }); + +const store = new SetStore({ + dataDir, + pipeline: createImagePipeline(), + sections: config.sections.map((section) => section.slug), + maxPagesPerSet: config.limits.maxPagesPerSet, +}); + +for (const [index, entry] of plan.entries()) { + const section = config.sections.find((candidate) => candidate.slug === entry.section); + if (section === undefined) continue; + const set = await store.create(entry.section, { label: entry.label, validUntil: entry.validUntil }); + for (let page = 1; page <= entry.pages; page += 1) { + const svg = menuPage(entry.label, section.label, page, entry.pages, index + page); + await store.addPage(set.id, { + data: await pageImage(svg), + originalName: `${entry.section}-${String(page).padStart(2, '0')}.jpg`, + }); + } + const published = await store.publish(set.id); + console.log(`published ${published.section}/${published.slug} (${entry.pages} pages)`); +} + +// One archived set, so the "Older menus" fold has something to show. +const previous = await store.create('bar', { label: 'Bar Menu, spring', validUntil: '2026-03-31' }); +for (let page = 1; page <= 2; page += 1) { + await store.addPage(previous.id, { + data: await pageImage(menuPage('Bar Menu, spring', 'Bar', page, 2, page)), + originalName: `bar-spring-${page}.jpg`, + }); +} +await store.publish(previous.id); + +// Publishing the spring set archived the June one; put June back as Current so +// the archive is the older menu rather than the live one. +const june = (await store.list()).find((set) => set.section === 'bar' && set.slug === '2026-06-26'); +if (june !== undefined) await store.restore(june.id); + +console.log(`seeded ${dataDir}`); diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..82c37b1 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,149 @@ +/** + * The single shared admin password, the session cookie that follows a + * successful login, and a per-IP throttle so the login form is not a free + * guessing oracle. Uses node:crypto only, so the container needs no compiler. + */ + +import { createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; +import type { ScryptOptions } from 'node:crypto'; + +export const SESSION_COOKIE = 'menu_session'; + +/** ~34 MB of memory per hash; comfortably above the 16 MB needed. */ +const DEFAULT_PARAMS = { N: 1 << 15, r: 8, p: 1 } as const; +const KEY_LENGTH = 32; +const MAX_MEM = 64 * 1024 * 1024; + +function derive( + password: string, + salt: Buffer, + keyLength: number, + options: ScryptOptions, +): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, keyLength, options, (error, derivedKey) => { + if (error) reject(error); + else resolve(derivedKey); + }); + }); +} + +function b64(buffer: Buffer): string { + return buffer.toString('base64url'); +} + +/** Format: scrypt$N$r$p$salt$hash */ +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const derived = await derive(password, salt, KEY_LENGTH, { ...DEFAULT_PARAMS, maxmem: MAX_MEM }); + const { N, r, p } = DEFAULT_PARAMS; + return `scrypt$${N}$${r}$${p}$${b64(salt)}$${b64(derived)}`; +} + +export async function verifyPassword(password: string, stored: string): Promise { + const parts = stored.split('$'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return false; + const N = Number(parts[1]); + const r = Number(parts[2]); + const p = Number(parts[3]); + if (!Number.isSafeInteger(N) || !Number.isSafeInteger(r) || !Number.isSafeInteger(p)) return false; + let salt: Buffer; + let expected: Buffer; + try { + salt = Buffer.from(parts[4] ?? '', 'base64url'); + expected = Buffer.from(parts[5] ?? '', 'base64url'); + } catch { + return false; + } + if (salt.length === 0 || expected.length === 0) return false; + let derived: Buffer; + try { + derived = await derive(password, salt, expected.length, { N, r, p, maxmem: MAX_MEM }); + } catch { + return false; + } + return derived.length === expected.length && timingSafeEqual(derived, expected); +} + +function sign(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('base64url'); +} + +export function sessionTtl(ttlMs: number): number { + return ttlMs; +} + +/** Token: . */ +export function createSessionToken(secret: string, ttlMs: number, now: Date = new Date()): string { + const expiry = String(now.getTime() + ttlMs); + return `${expiry}.${sign(expiry, secret)}`; +} + +export function verifySessionToken(token: string | undefined, secret: string, now: Date = new Date()): boolean { + if (!token) return false; + const separator = token.indexOf('.'); + if (separator <= 0) return false; + const payload = token.slice(0, separator); + const provided = token.slice(separator + 1); + const expected = sign(payload, secret); + const providedBuffer = Buffer.from(provided); + const expectedBuffer = Buffer.from(expected); + if (providedBuffer.length !== expectedBuffer.length) return false; + if (!timingSafeEqual(providedBuffer, expectedBuffer)) return false; + const expiry = Number(payload); + return Number.isSafeInteger(expiry) && expiry > now.getTime(); +} + +export interface ThrottleVerdict { + allowed: boolean; + retryAfterSeconds: number; +} + +export interface LoginThrottleOptions { + maxFailures?: number; + windowMs?: number; + now?: () => Date; +} + +/** Fixed-window failure counter per client key (an IP address). */ +export class LoginThrottle { + private readonly maxFailures: number; + private readonly windowMs: number; + private readonly now: () => Date; + private readonly failures = new Map(); + + constructor(options: LoginThrottleOptions = {}) { + this.maxFailures = options.maxFailures ?? 5; + this.windowMs = options.windowMs ?? 15 * 60 * 1000; + this.now = options.now ?? (() => new Date()); + } + + private recent(key: string): number[] { + const cutoff = this.now().getTime() - this.windowMs; + const kept = (this.failures.get(key) ?? []).filter((at) => at > cutoff); + if (kept.length === 0) this.failures.delete(key); + else this.failures.set(key, kept); + return kept; + } + + check(key: string): ThrottleVerdict { + const recent = this.recent(key); + if (recent.length < this.maxFailures) return { allowed: true, retryAfterSeconds: 0 }; + const oldest = recent[0] ?? this.now().getTime(); + const retryAfterSeconds = Math.max( + 1, + Math.ceil((oldest + this.windowMs - this.now().getTime()) / 1000), + ); + return { allowed: false, retryAfterSeconds }; + } + + recordFailure(key: string): void { + const recent = this.recent(key); + recent.push(this.now().getTime()); + this.failures.set(key, recent); + } + + reset(key: string): void { + this.failures.delete(key); + } +} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..ab6a077 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,36 @@ +/** + * `site.env` is written on the server by scripts/provision-site-env.sh and is + * never committed. Parsing it here keeps the app free of a dotenv dependency. + */ + +import { readFile } from 'node:fs/promises'; + +export function parseEnvFile(text: string): Record { + const values: Record = {}; + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (line === '' || line.startsWith('#')) continue; + const withoutExport = line.startsWith('export ') ? line.slice('export '.length) : line; + const separator = withoutExport.indexOf('='); + if (separator <= 0) continue; + const key = withoutExport.slice(0, separator).trim(); + let value = withoutExport.slice(separator + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || + (value.startsWith("'") && value.endsWith("'") && value.length >= 2) + ) { + value = value.slice(1, -1); + } + if (key !== '') values[key] = value; + } + return values; +} + +export async function readEnvFile(file: string): Promise> { + try { + return parseEnvFile(await readFile(file, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw error; + } +} diff --git a/src/render/html.ts b/src/render/html.ts new file mode 100644 index 0000000..7de6b7b --- /dev/null +++ b/src/render/html.ts @@ -0,0 +1,53 @@ +/** + * A very small HTML builder. Interpolated values are escaped unless they are + * already Html, which is what makes composition safe rather than hopeful. + */ + +export class Html { + readonly value: string; + + private constructor(value: string) { + this.value = value; + } + + static raw(value: string): Html { + return new Html(value); + } + + toString(): string { + return this.value; + } +} + +const ESCAPES: Readonly> = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => ESCAPES[character] ?? character); +} + +function render(value: unknown): string { + if (value === null || value === undefined || value === false || value === true) return ''; + if (value instanceof Html) return value.value; + if (Array.isArray(value)) return value.map(render).join(''); + if (typeof value === 'number' || typeof value === 'bigint') return String(value); + if (typeof value === 'string') return escapeHtml(value); + return escapeHtml(String(value)); +} + +export function html(strings: TemplateStringsArray, ...values: readonly unknown[]): Html { + let out = strings[0] ?? ''; + for (let index = 0; index < values.length; index += 1) { + out += render(values[index]) + (strings[index + 1] ?? ''); + } + return Html.raw(out); +} + +export function join(values: readonly unknown[], separator = ''): Html { + return Html.raw(values.map(render).join(separator)); +} diff --git a/src/render/layout.ts b/src/render/layout.ts new file mode 100644 index 0000000..f31419d --- /dev/null +++ b/src/render/layout.ts @@ -0,0 +1,57 @@ +import { html, type Html } from './html.ts'; +import type { SiteConfig } from '../config.ts'; + +export interface DocumentInput { + config: SiteConfig; + /** Section name shown before the brand, or null for the brand alone. */ + title: string | null; + bodyClass: string; + body: Html; + description: string | null; + noindex: boolean; + script: boolean; +} + +export function renderDocument(input: DocumentInput): Html { + const { config } = input; + const title = input.title === null ? config.brand.name : `${input.title} · ${config.brand.name}`; + const theme = config.theme; + const logo = config.brand.logo ?? '/assets/favicon.svg'; + + return html` + + + + + ${title} + ${input.description === null + ? '' + : html``} + ${input.noindex ? html`` : ''} + + + + + + + + ${input.body} + ${input.script ? html`` : ''} + + +`; +} diff --git a/src/render/pages.ts b/src/render/pages.ts new file mode 100644 index 0000000..c451a35 --- /dev/null +++ b/src/render/pages.ts @@ -0,0 +1,240 @@ +/** + * The customer-facing pages. Server-rendered HTML, one small script for the + * pour line, the page counter and tap-to-zoom; every one of those degrades to + * a plain link without JavaScript. + */ + +import { html, Html, join } from './html.ts'; +import { renderDocument } from './layout.ts'; +import type { SectionConfig, SiteConfig } from '../config.ts'; +import type { PageRecord, SetManifest } from '../storage/manifest.ts'; +import type { StoredSet } from '../storage/sets.ts'; + +export interface MediaUrls { + webp: string; + jpeg: string; + thumb: string; + /** The Viewing image at full width, for the zoom overlay and no-JS links. */ + view: string; +} + +export function mediaUrls(page: PageRecord): MediaUrls { + const webp = `/media/${page.id}.${page.hash}.webp`; + const jpeg = `/media/${page.id}.${page.hash}.jpg`; + return { webp, jpeg, thumb: `/media/thumb-${page.id}.${page.hash}.webp`, view: webp }; +} + +export function pageCountLabel(count: number): string { + return count === 1 ? '1 page' : `${count} pages`; +} + +const DATE_FORMAT = new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', +}); + +export function formatCalendarDate(value: string): string { + return DATE_FORMAT.format(new Date(`${value}T00:00:00Z`)); +} + +export function setDateLabel(manifest: SetManifest): string { + if (manifest.validUntil !== null) return `until ${formatCalendarDate(manifest.validUntil)}`; + if (manifest.publishedAt !== null) { + return `published ${DATE_FORMAT.format(new Date(manifest.publishedAt))}`; + } + return 'not published'; +} + +function renderRail(config: SiteConfig, currentSection: string, published: ReadonlySet): Html { + return html``; +} + +function renderPages(section: SectionConfig, pages: readonly PageRecord[]): Html { + const total = pages.length; + return html`
+ ${join( + pages.map((page, index) => { + const urls = mediaUrls(page); + const number = index + 1; + return html`
+ + + + ${section.label}, page ${number} of ${total} + + +
`; + }), + )} +
`; +} + +function renderPageIndex(total: number): Html { + return html`
+ ${pageCountLabel(total)} +
    + ${join( + Array.from({ length: total }, (_, index) => index + 1).map( + (number) => html`
  1. ${number}
  2. `, + ), + )} +
+
`; +} + +function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[], currentHref: string): Html { + if (archives.length === 0) return Html.raw(''); + return html`
+ Older menus + +

Back to the current menu

+
`; +} + +export interface LandingInput { + config: SiteConfig; + /** Sections that have a Current Menu Set, in config order. */ + live: readonly { section: SectionConfig; set: StoredSet }[]; +} + +export function renderLanding(input: LandingInput): Html { + const { config } = input; + const rows = + input.live.length === 0 + ? html`

The menus are being updated. Please ask a member of staff.

` + : html``; + + return renderDocument({ + config, + title: null, + bodyClass: 'landing-page', + description: config.brand.tagline, + noindex: false, + script: false, + body: html`
+
+

${config.brand.name}

+ ${config.brand.tagline === null + ? '' + : html`

${config.brand.tagline}

`} +
+ ${rows} +
`, + }); +} + +export interface SectionInput { + config: SiteConfig; + section: SectionConfig; + set: StoredSet; + /** Section slugs that have a Current Menu Set, for the rail. */ + published: ReadonlySet; + current: boolean; + archives: readonly StoredSet[]; +} + +export function renderSectionPage(input: SectionInput): Html { + const { config, section, set } = input; + const currentHref = `/${section.slug}/`; + const total = set.manifest.pages.length; + + const body = html`
+ ${config.brand.name} + ${renderRail(config, section.slug, input.published)} + +
+

+ ${set.manifest.label} + ${setDateLabel(set.manifest)} +

+ ${input.current ? '' : html`

This is an older menu. See the current menu.

`} + ${renderPages(section, set.manifest.pages)} +
+ ${renderPageIndex(total)} ${renderOlderMenus(section, input.archives, currentHref)} +
+ `; + + return renderDocument({ + config, + title: `${set.manifest.label} · ${section.label}`, + bodyClass: 'section-page', + description: config.brand.tagline, + noindex: !input.current, + script: true, + body, + }); +} + +export function renderNotFound(config: SiteConfig, live: readonly SectionConfig[]): Html { + const rows = html``; + + return renderDocument({ + config, + title: 'Not found', + bodyClass: 'landing-page', + description: null, + noindex: true, + script: false, + body: html`
+
+

${config.brand.name}

+
+

That menu does not exist.

+ ${live.length === 0 ? '' : rows} +
`, + }); +} diff --git a/src/routes/public.ts b/src/routes/public.ts new file mode 100644 index 0000000..47a9d73 --- /dev/null +++ b/src/routes/public.ts @@ -0,0 +1,150 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import path from 'node:path'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { sectionBySlug, type SiteConfig } from '../config.ts'; +import { SESSION_COOKIE, verifySessionToken } from '../auth.ts'; +import { variantFileNames } from '../storage/manifest.ts'; +import type { SetStore } from '../storage/sets.ts'; +import { renderLanding, renderNotFound, renderSectionPage } from '../render/pages.ts'; + +const HTML = 'text/html; charset=utf-8'; +const MEDIA_NAME = /^(thumb-)?([0-9a-f-]{36})\.([0-9a-f]{8,64})\.(webp|jpg)$/; + +export interface PublicRouteDeps { + config: SiteConfig; + store: SetStore; + sessionSecret: string; +} + +interface MediaName { + thumb: boolean; + pageId: string; + hash: string; + ext: 'webp' | 'jpg'; +} + +export function parseMediaName(name: string): MediaName | null { + const match = MEDIA_NAME.exec(name); + if (match === null) return null; + return { + thumb: match[1] === 'thumb-', + pageId: match[2] ?? '', + hash: match[3] ?? '', + ext: (match[4] ?? 'webp') as 'webp' | 'jpg', + }; +} + +function sendHtml(reply: FastifyReply, body: string, status = 200): FastifyReply { + return reply.code(status).type(HTML).send(body); +} + +export async function registerPublicRoutes( + app: FastifyInstance, + deps: PublicRouteDeps, +): Promise { + const { config, store } = deps; + + const isAuthed = (request: FastifyRequest): boolean => + verifySessionToken(request.cookies[SESSION_COOKIE], deps.sessionSecret); + + const publishedSections = async (): Promise> => + new Set((await store.currentBySection()).keys()); + + app.get('/healthz', async (_request, reply) => reply.type('text/plain').send('ok')); + + app.get('/robots.txt', async (_request, reply) => + reply.type('text/plain').send('User-agent: *\nDisallow: /admin\n'), + ); + + app.get('/', async (_request, reply) => { + const current = await store.currentBySection(); + const live = config.sections + .filter((section) => current.has(section.slug)) + .map((section) => ({ section, set: current.get(section.slug)! })); + return sendHtml(reply, renderLanding({ config, live }).value); + }); + + app.get<{ Params: { section: string } }>('/:section/', async (request, reply) => { + const section = sectionBySlug(config, request.params.section); + if (section === undefined) { + return sendHtml(reply, renderNotFound(config, []).value, 404); + } + const current = (await store.currentBySection()).get(section.slug); + if (current === undefined) { + return sendHtml(reply, renderNotFound(config, config.sections).value, 404); + } + return sendHtml( + reply, + renderSectionPage({ + config, + section, + set: current, + published: await publishedSections(), + current: true, + archives: await store.archives(section.slug), + }).value, + ); + }); + + app.get<{ Params: { section: string; slug: string } }>( + '/:section/:slug/', + async (request, reply) => { + const section = sectionBySlug(config, request.params.section); + if (section === undefined) { + return sendHtml(reply, renderNotFound(config, []).value, 404); + } + const set = (await store.list()).find( + (candidate) => + candidate.state === 'published' && + candidate.section === section.slug && + candidate.slug === request.params.slug, + ); + if (set === undefined) { + return sendHtml(reply, renderNotFound(config, config.sections).value, 404); + } + const current = (await store.currentBySection()).get(section.slug); + const archives = (await store.archives(section.slug)).filter( + (candidate) => candidate.id !== set.id, + ); + return sendHtml( + reply, + renderSectionPage({ + config, + section, + set, + published: await publishedSections(), + current: current?.id === set.id, + archives, + }).value, + ); + }, + ); + + app.get<{ Params: { file: string } }>('/media/:file', async (request, reply) => { + const name = parseMediaName(request.params.file); + if (name === null) return reply.code(404).send(); + + const entry = (await store.pageIndex()).get(name.pageId); + if (entry === undefined || entry.page.hash !== name.hash) return reply.code(404).send(); + + const draft = entry.set.state === 'draft'; + if (draft && !isAuthed(request)) return reply.code(404).send(); + + const names = variantFileNames(entry.page); + const file = path.join(entry.set.dir, name.thumb ? names.thumb : name.ext === 'webp' ? names.webp : names.jpeg); + let size: number; + try { + size = (await stat(file)).size; + } catch { + return reply.code(404).send(); + } + + return reply + .header('cache-control', draft ? 'private, max-age=60' : 'public, max-age=31536000, immutable') + .header('etag', `"${entry.page.hash}-${name.thumb ? 'thumb' : name.ext}"`) + .header('content-length', String(size)) + .type(name.thumb || name.ext === 'webp' ? 'image/webp' : 'image/jpeg') + .send(createReadStream(file)); + }); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..5dbf013 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,172 @@ +/** + * Wires the Site config, the Menu Set store and the routes into a Fastify app. + * `buildApp` is what the tests drive; `main` is what the container runs. + */ + +import { randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import Fastify, { type FastifyError, type FastifyInstance } from 'fastify'; +import cookie from '@fastify/cookie'; +import fastifyStatic from '@fastify/static'; +import { parseSiteConfig, type SiteConfig } from './config.ts'; +import { SESSION_COOKIE, sessionTtl } from './auth.ts'; +import { createImagePipeline } from './images/pipeline.ts'; +import { SetStore, type ImagePipeline } from './storage/sets.ts'; +import { hasFatal, validateSite } from './storage/validate.ts'; +import { renderNotFound } from './render/pages.ts'; +import { registerPublicRoutes } from './routes/public.ts'; +import { readEnvFile } from './env.ts'; + +export const SESSION_TTL_MS = 8 * 60 * 60 * 1000; + +/** What every route reaches for: the store, the session secret, the admin hash. */ +export interface MenuContext { + store: SetStore; + sessionSecret: string; + sessionTtlMs: number; + adminPasswordHash: string | null; + secureCookies: boolean; +} + +declare module 'fastify' { + interface FastifyInstance { + menu: MenuContext; + } +} + +export interface AppOptions { + config: SiteConfig; + dataDir: string; + sessionSecret: string; + /** Admin is disabled, with a warning, when this is null. */ + adminPasswordHash: string | null; + assetsDir: string; + secureCookies: boolean; + logLevel: string; + /** Test seam: defaults to the sharp pipeline. */ + pipeline?: ImagePipeline; + /** Test seam: defaults to the real clock. */ + now?: () => Date; +} + +export async function buildApp(options: AppOptions): Promise { + const app = Fastify({ + ignoreTrailingSlash: true, + trustProxy: true, + logger: { level: options.logLevel }, + }); + + await app.register(cookie); + await app.register(fastifyStatic, { + root: options.assetsDir, + prefix: '/assets/', + index: false, + cacheControl: true, + maxAge: '1h', + }); + + const store = new SetStore({ + dataDir: options.dataDir, + pipeline: options.pipeline ?? createImagePipeline(), + sections: options.config.sections.map((section) => section.slug), + maxPagesPerSet: options.config.limits.maxPagesPerSet, + now: options.now, + }); + + await registerPublicRoutes(app, { + config: options.config, + store, + sessionSecret: options.sessionSecret, + }); + + app.setNotFoundHandler(async (_request, reply) => { + const current = await store.currentBySection(); + const live = options.config.sections.filter((section) => current.has(section.slug)); + return reply + .code(404) + .type('text/html; charset=utf-8') + .send(renderNotFound(options.config, live).value); + }); + + app.setErrorHandler(async (error: FastifyError, request, reply) => { + request.log.error({ err: error }, 'request failed'); + const status = error.statusCode !== undefined && error.statusCode < 500 ? error.statusCode : 500; + return reply.code(status).type('text/plain; charset=utf-8').send('Something went wrong.'); + }); + + app.decorate('menu', { + store, + sessionSecret: options.sessionSecret, + sessionTtlMs: SESSION_TTL_MS, + adminPasswordHash: options.adminPasswordHash, + secureCookies: options.secureCookies, + }); + + return app; +} + +export function sessionCookieAttributes(secure: boolean) { + return { + name: SESSION_COOKIE, + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure, + maxAge: Math.floor(sessionTtl(SESSION_TTL_MS) / 1000), + }; +} + +async function main(): Promise { + const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev'); + const configPath = path.resolve( + process.env['MENU_SITE_CONFIG'] ?? 'config/hopsngrains.site.json', + ); + const assetsDir = path.join(import.meta.dirname, '..', 'public'); + const dataDir = path.join(root, 'data'); + + const config = parseSiteConfig(JSON.parse(await readFile(configPath, 'utf8'))); + const env = await readEnvFile(path.join(root, 'site.env')); + + let sessionSecret = env['SESSION_SECRET'] ?? ''; + if (sessionSecret === '') { + sessionSecret = randomBytes(32).toString('base64url'); + console.warn('SESSION_SECRET is not set; using an ephemeral secret (logins reset on restart)'); + } + const adminPasswordHash = env['ADMIN_PASSWORD_HASH'] ?? null; + if (adminPasswordHash === null) { + console.warn('ADMIN_PASSWORD_HASH is not set; the admin is disabled'); + } + + const problems = await validateSite({ + dataDir, + sections: config.sections.map((section) => section.slug), + }); + for (const problem of problems) { + const line = `${problem.level}: ${problem.message}`; + if (problem.level === 'fatal') console.error(line); + else console.warn(line); + } + if (hasFatal(problems)) { + console.error('refusing to serve a data directory with fatal integrity problems'); + process.exitCode = 1; + return; + } + + const app = await buildApp({ + config, + dataDir, + sessionSecret, + adminPasswordHash, + assetsDir, + secureCookies: (process.env['SECURE_COOKIES'] ?? 'true') !== 'false', + logLevel: process.env['LOG_LEVEL'] ?? 'info', + }); + + const port = Number(process.env['PORT'] ?? 3000); + await app.listen({ port, host: process.env['HOST'] ?? '0.0.0.0' }); +} + +if (process.argv[1] !== undefined && import.meta.filename === process.argv[1]) { + await main(); +} diff --git a/src/storage/sets.ts b/src/storage/sets.ts index a640b61..193d7c7 100644 --- a/src/storage/sets.ts +++ b/src/storage/sets.ts @@ -91,8 +91,8 @@ export interface SetStoreOptions { /** Configured Section slugs; a set may only belong to one of these. */ sections: readonly string[]; maxPagesPerSet: number; - timeZone?: string; - now?: () => Date; + timeZone?: string | undefined; + now?: (() => Date) | undefined; } const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..0ecb87e --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,91 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + LoginThrottle, + createSessionToken, + hashPassword, + verifyPassword, + verifySessionToken, +} from '../src/auth.ts'; + +const NOW = new Date('2026-05-01T06:00:00.000Z'); + +describe('the admin password', () => { + it('verifies a correct password and rejects a wrong one', async () => { + const stored = await hashPassword('correct horse battery staple'); + assert.match(stored, /^scrypt\$32768\$8\$1\$/); + assert.equal(await verifyPassword('correct horse battery staple', stored), true); + assert.equal(await verifyPassword('Correct horse battery staple', stored), false); + assert.equal(await verifyPassword('', stored), false); + }); + + it('salts every hash, so the same password never produces the same string', async () => { + const first = await hashPassword('same password'); + const second = await hashPassword('same password'); + assert.notEqual(first, second); + assert.equal(await verifyPassword('same password', first), true); + assert.equal(await verifyPassword('same password', second), true); + }); + + it('rejects malformed stored values instead of throwing', async () => { + for (const stored of ['', 'plaintext', 'scrypt$1$2$3', 'bcrypt$1$2$3$4$5', 'scrypt$x$8$1$aa$bb']) { + assert.equal(await verifyPassword('anything', stored), false); + } + }); +}); + +describe('the session token', () => { + const secret = 'a-long-random-secret'; + + it('accepts a fresh token', () => { + const token = createSessionToken(secret, 60_000, NOW); + assert.equal(verifySessionToken(token, secret, NOW), true); + assert.equal(verifySessionToken(token, secret, new Date(NOW.getTime() + 59_000)), true); + }); + + it('rejects an expired token', () => { + const token = createSessionToken(secret, 60_000, NOW); + assert.equal(verifySessionToken(token, secret, new Date(NOW.getTime() + 61_000)), false); + }); + + it('rejects a token signed with another secret, a tampered expiry, and rubbish', () => { + const token = createSessionToken(secret, 60_000, NOW); + assert.equal(verifySessionToken(token, 'another-secret', NOW), false); + const [, signature] = token.split('.'); + assert.equal(verifySessionToken(`${NOW.getTime() + 999_999}.${signature}`, secret, NOW), false); + assert.equal(verifySessionToken(undefined, secret, NOW), false); + assert.equal(verifySessionToken('', secret, NOW), false); + assert.equal(verifySessionToken('no-dot', secret, NOW), false); + assert.equal(verifySessionToken(`.${signature}`, secret, NOW), false); + }); +}); + +describe('the login throttle', () => { + it('blocks the sixth failure inside the window and reports how long to wait', () => { + let time = NOW.getTime(); + const throttle = new LoginThrottle({ maxFailures: 5, windowMs: 60_000, now: () => new Date(time) }); + + for (let attempt = 0; attempt < 5; attempt += 1) { + assert.equal(throttle.check('1.2.3.4').allowed, true); + throttle.recordFailure('1.2.3.4'); + } + + const blocked = throttle.check('1.2.3.4'); + assert.equal(blocked.allowed, false); + assert.ok(blocked.retryAfterSeconds > 0); + + assert.equal(throttle.check('5.6.7.8').allowed, true); + + time += 61_000; + assert.equal(throttle.check('1.2.3.4').allowed, true); + }); + + it('clears failures on a successful login', () => { + const throttle = new LoginThrottle({ maxFailures: 2, windowMs: 60_000, now: () => NOW }); + throttle.recordFailure('ip'); + throttle.recordFailure('ip'); + assert.equal(throttle.check('ip').allowed, false); + throttle.reset('ip'); + assert.equal(throttle.check('ip').allowed, true); + }); +}); diff --git a/test/render.test.ts b/test/render.test.ts new file mode 100644 index 0000000..87821a3 --- /dev/null +++ b/test/render.test.ts @@ -0,0 +1,47 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { parseEnvFile } from '../src/env.ts'; +import { Html, escapeHtml, html, join } from '../src/render/html.ts'; + +describe('parseEnvFile', () => { + it('reads keys, skips comments and blanks, and strips quotes', () => { + const values = parseEnvFile( + [ + '# a comment', + '', + 'ADMIN_PASSWORD_HASH=scrypt$1$2$3$abc$def', + 'export SESSION_SECRET="quoted value"', + "OTHER='single quoted'", + 'not a pair', + 'EMPTY=', + ].join('\n'), + ); + assert.deepEqual(values, { + ADMIN_PASSWORD_HASH: 'scrypt$1$2$3$abc$def', + SESSION_SECRET: 'quoted value', + OTHER: 'single quoted', + EMPTY: '', + }); + }); +}); + +describe('the html builder', () => { + it('escapes interpolated values', () => { + const rendered = html`

${'' }); + await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + await store.publish(set.id); + + const response = await app.inject({ method: 'GET', url: '/bar/' }); + assert.equal(response.statusCode, 200); + assert.doesNotMatch(response.body, /