Publish menus from the browser instead of scp
The admin signs in with the shared password, creates a Menu Set, drops photos into it (shrunk in the browser first), reorders Pages by dragging or with up/down buttons, previews the Draft exactly as customers would see it, and publishes it over the previous set. Restore and delete close the loop. Mutations need a session, a same-origin request and a CSRF token derived from the session; a Draft's media is served only to a session holder.
This commit is contained in:
1 parent
9cb4021229
commit
58ecfa0dc3
14 files changed
+1824
-15
No files matched your search
@@ -6,10 +6,11 @@ photographed Pages, replacing the `scp`-a-JPG workflow. One instance per Site;
|
|||||||
|
|
||||||
## Status
|
## 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
|
customer-facing renderer (landing page, Section pages, archived sets, media
|
||||||
route) with the approved design. Not yet built: the admin, the container and
|
route) with the approved design, and the admin (sign in, create a set, upload
|
||||||
the Ansible role.
|
and reorder Pages, preview, publish, restore, delete). Not yet built: the
|
||||||
|
container, the Ansible playbook and the deploy.
|
||||||
|
|
||||||
## Layout
|
## 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/sets.ts The Menu Set store: create, add Pages, publish, restore, delete
|
||||||
src/storage/validate.ts Boot validation: fatal problems and warnings
|
src/storage/validate.ts Boot validation: fatal problems and warnings
|
||||||
src/images/pipeline.ts sharp variants: Original, Viewing image, Thumbnail
|
src/images/pipeline.ts sharp variants: Original, Viewing image, Thumbnail
|
||||||
src/auth.ts scrypt password hash, signed session cookie, login throttle
|
src/auth.ts scrypt password hash, session cookie, login throttle, CSRF
|
||||||
src/render/ HTML builder, document shell, the customer pages
|
src/render/ HTML builder, document shell, the customer pages, the admin
|
||||||
src/routes/public.ts Landing, Section, archive and media routes
|
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)
|
src/server.ts buildApp (what tests drive) and main (what the container runs)
|
||||||
public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo
|
public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo
|
||||||
config/ Per-Site config, one file per Site
|
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
|
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 `/<section>/` 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
|
## Storage model
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Generated
+21
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.2",
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/formbody": "^9.0.0",
|
||||||
"@fastify/multipart": "^10.1.2",
|
"@fastify/multipart": "^10.1.2",
|
||||||
"@fastify/static": "^10.1.4",
|
"@fastify/static": "^10.1.4",
|
||||||
"fastify": "^5.12.5",
|
"fastify": "^5.12.5",
|
||||||
@@ -146,6 +147,26 @@
|
|||||||
"fast-json-stringify": "^7.0.0"
|
"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": {
|
"node_modules/@fastify/forwarded": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.2",
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/formbody": "^9.0.0",
|
||||||
"@fastify/multipart": "^10.1.2",
|
"@fastify/multipart": "^10.1.2",
|
||||||
"@fastify/static": "^10.1.4",
|
"@fastify/static": "^10.1.4",
|
||||||
"fastify": "^5.12.5",
|
"fastify": "^5.12.5",
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
+202
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -236,6 +236,12 @@ a {
|
|||||||
text-decoration: underline;
|
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 ---------- */
|
/* ---------- The Pages ---------- */
|
||||||
|
|
||||||
.pages {
|
.pages {
|
||||||
|
|||||||
+22
-2
@@ -94,8 +94,7 @@ export function verifySessionToken(token: string | undefined, secret: string, no
|
|||||||
return Number.isSafeInteger(expiry) && expiry > now.getTime();
|
return Number.isSafeInteger(expiry) && expiry > now.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThrottleVerdict {
|
export interface ThrottleVerdict { allowed: boolean;
|
||||||
allowed: boolean;
|
|
||||||
retryAfterSeconds: number;
|
retryAfterSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,3 +146,24 @@ export class LoginThrottle {
|
|||||||
this.failures.delete(key);
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`<span class="field">
|
||||||
|
<label>${label}${control}</label>
|
||||||
|
${hint === null ? '' : html`<span class="field__hint">${hint}</span>`}
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLogin(config: SiteConfig, error: string | null): Html {
|
||||||
|
return shell({
|
||||||
|
config,
|
||||||
|
title: 'Sign in',
|
||||||
|
bodyClass: 'admin-login',
|
||||||
|
scripts: [],
|
||||||
|
body: html`<main class="login">
|
||||||
|
<h1 class="wordmark">${config.brand.name}</h1>
|
||||||
|
<p class="login__sub">Menus</p>
|
||||||
|
${error === null ? '' : html`<p class="notice notice--error">${error}</p>`}
|
||||||
|
<form method="post" action="/admin/login">
|
||||||
|
${labelled('Password', html`<input type="password" name="password" autocomplete="current-password" autofocus required />`)}
|
||||||
|
<button class="button button--primary" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRow(section: SectionConfig, set: StoredSet, kind: 'current' | 'draft' | 'archive'): Html {
|
||||||
|
const pill =
|
||||||
|
kind === 'current'
|
||||||
|
? html`<span class="pill pill--current">Current</span>`
|
||||||
|
: kind === 'draft'
|
||||||
|
? html`<span class="pill pill--draft">Draft</span>`
|
||||||
|
: html`<span class="pill">Archive</span>`;
|
||||||
|
const actions =
|
||||||
|
kind === 'draft'
|
||||||
|
? html`<a class="button" href="/admin/sets/${set.id}">Edit</a>
|
||||||
|
<a class="button" href="/admin/sets/${set.id}/preview">Preview</a>`
|
||||||
|
: html`<a class="button" href="/admin/sets/${set.id}">Open</a>
|
||||||
|
<a class="button" href="/${section.slug}/${set.slug}/">View</a>`;
|
||||||
|
|
||||||
|
return html`<li>
|
||||||
|
<span class="setlist__main">
|
||||||
|
${pill}
|
||||||
|
<span class="setlist__label">${set.manifest.label}</span>
|
||||||
|
<span class="setlist__meta">${setDateLabel(set.manifest)} · ${pageCountLabel(set.manifest.pages.length)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="setlist__actions">${actions}</span>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardInput {
|
||||||
|
config: SiteConfig;
|
||||||
|
sets: readonly StoredSet[];
|
||||||
|
current: ReadonlyMap<string, StoredSet>;
|
||||||
|
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 class="block">
|
||||||
|
<h2 class="block__title">${section.label}</h2>
|
||||||
|
<ul class="setlist">
|
||||||
|
${current === undefined ? html`<li class="setlist__none">No menu published yet.</li>` : setRow(section, current, 'current')}
|
||||||
|
${join(drafts.map((set) => setRow(section, set, 'draft')))}
|
||||||
|
</ul>
|
||||||
|
${archives.length === 0
|
||||||
|
? ''
|
||||||
|
: html`<details class="fold">
|
||||||
|
<summary>${archives.length === 1 ? '1 older menu' : `${archives.length} older menus`}</summary>
|
||||||
|
<ul class="setlist">
|
||||||
|
${join(archives.map((set) => setRow(section, set, 'archive')))}
|
||||||
|
</ul>
|
||||||
|
</details>`}
|
||||||
|
<form class="newset" method="post" action="/admin/sets">
|
||||||
|
<input type="hidden" name="section" value="${section.slug}" />
|
||||||
|
${labelled(
|
||||||
|
'New Menu Set',
|
||||||
|
html`<input type="text" name="label" value="${section.label} Menu" required />`,
|
||||||
|
)}
|
||||||
|
${labelled('Valid until (optional)', html`<input type="date" name="validUntil" />`)}
|
||||||
|
<button class="button" type="submit">Create Draft</button>
|
||||||
|
</form>
|
||||||
|
</section>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return shell({
|
||||||
|
config,
|
||||||
|
title: 'Menus',
|
||||||
|
bodyClass: 'admin-dashboard',
|
||||||
|
scripts: [],
|
||||||
|
body: html`<main class="admin">
|
||||||
|
<header class="admin__bar">
|
||||||
|
<h1 class="admin__title">Menus</h1>
|
||||||
|
<form method="post" action="/admin/logout">
|
||||||
|
<button class="button" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
|
||||||
|
${blocks}
|
||||||
|
</main>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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`<li class="page-admin" draggable="${draft ? 'true' : 'false'}" data-page-id="${page.id}">
|
||||||
|
<span class="page-admin__handle" aria-hidden="true">⠿</span>
|
||||||
|
<img src="/media/${names.thumb}" alt="" width="64" />
|
||||||
|
<span class="page-admin__meta">
|
||||||
|
<span class="page-admin__number">${index + 1}</span>
|
||||||
|
<span class="page-admin__name">${page.originalName}</span>
|
||||||
|
<span class="page-admin__dims">${page.width} × ${page.height}</span>
|
||||||
|
</span>
|
||||||
|
${draft
|
||||||
|
? html`<span class="page-admin__actions">
|
||||||
|
<form method="post" action="/admin/sets/${set.id}/move">
|
||||||
|
<input type="hidden" name="pageId" value="${page.id}" />
|
||||||
|
<input type="hidden" name="direction" value="up" />
|
||||||
|
<button class="button button--icon" type="submit" title="Move up">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/sets/${set.id}/move">
|
||||||
|
<input type="hidden" name="pageId" value="${page.id}" />
|
||||||
|
<input type="hidden" name="direction" value="down" />
|
||||||
|
<button class="button button--icon" type="submit" title="Move down">↓</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete">
|
||||||
|
<button class="button button--icon button--danger" type="submit" title="Remove page">✕</button>
|
||||||
|
</form>
|
||||||
|
</span>`
|
||||||
|
: ''}
|
||||||
|
</li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return shell({
|
||||||
|
config,
|
||||||
|
title: `${set.manifest.label} · ${section.label}`,
|
||||||
|
bodyClass: 'admin-editor',
|
||||||
|
scripts: ['/assets/admin.js'],
|
||||||
|
body: html`<main class="admin" data-set-id="${set.id}" data-csrf="${input.csrf}">
|
||||||
|
<header class="admin__bar">
|
||||||
|
<h1 class="admin__title">${set.manifest.label}</h1>
|
||||||
|
<span class="admin__section">${section.label}</span>
|
||||||
|
<a class="button" href="/admin">All menus</a>
|
||||||
|
</header>
|
||||||
|
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
|
||||||
|
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
|
||||||
|
|
||||||
|
${set.state === 'published'
|
||||||
|
? html`<p class="notice">
|
||||||
|
This menu is published at <a href="/${section.slug}/${set.slug}/">/${section.slug}/${set.slug}/</a>.
|
||||||
|
A published menu never changes; to change it, create a new Menu Set.
|
||||||
|
</p>`
|
||||||
|
: ''}
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="block__title">Details</h2>
|
||||||
|
${draft
|
||||||
|
? html`<form class="inline-form" method="post" action="/admin/sets/${set.id}/update">
|
||||||
|
${labelled('Label', html`<input type="text" name="label" value="${set.manifest.label}" required />`)}
|
||||||
|
${labelled(
|
||||||
|
'Valid until (optional)',
|
||||||
|
html`<input type="date" name="validUntil" value="${set.manifest.validUntil ?? ''}" />`,
|
||||||
|
)}
|
||||||
|
<button class="button" type="submit">Save</button>
|
||||||
|
</form>`
|
||||||
|
: html`<p class="muted">${setDateLabel(set.manifest)}</p>`}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
${draft
|
||||||
|
? html`<section class="block">
|
||||||
|
<h2 class="block__title">Pages</h2>
|
||||||
|
<div class="drop" data-drop>
|
||||||
|
<input type="file" accept="image/jpeg,image/png,image/webp,image/*" multiple data-file-input hidden />
|
||||||
|
<p class="drop__text">Drop photos here, or <button class="button" type="button" data-choose>choose files</button></p>
|
||||||
|
<p class="drop__hint">
|
||||||
|
JPEG, PNG or WebP. Photos are shrunk in the browser to ${3000}px before they are uploaded.
|
||||||
|
</p>
|
||||||
|
<p class="drop__status" data-upload-status role="status"></p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/sets/${set.id}/pages" enctype="multipart/form-data" class="no-js-upload">
|
||||||
|
<input type="file" name="pages" accept="image/*" multiple />
|
||||||
|
<button class="button" type="submit">Upload</button>
|
||||||
|
</form>
|
||||||
|
<ul class="pages-admin" data-pages-admin>
|
||||||
|
${pageItems.length === 0 ? html`<li class="setlist__none">No pages yet.</li>` : join(pageItems)}
|
||||||
|
</ul>
|
||||||
|
</section>`
|
||||||
|
: html`<section class="block">
|
||||||
|
<h2 class="block__title">Pages</h2>
|
||||||
|
<ul class="pages-admin">${join(pageItems)}</ul>
|
||||||
|
</section>`}
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="block__title">${draft ? 'Publish' : 'Manage'}</h2>
|
||||||
|
<div class="actions">
|
||||||
|
${draft
|
||||||
|
? html`<form method="post" action="/admin/sets/${set.id}/publish">
|
||||||
|
<button class="button button--primary" type="submit" ${set.manifest.pages.length === 0 ? 'disabled' : ''}>
|
||||||
|
Publish (${pageCountLabel(set.manifest.pages.length)})
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a class="button" href="/admin/sets/${set.id}/preview">Preview</a>`
|
||||||
|
: html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`}
|
||||||
|
${set.state === 'published'
|
||||||
|
? html`<form method="post" action="/admin/sets/${set.id}/restore">
|
||||||
|
<button class="button" type="submit">Make current</button>
|
||||||
|
</form>`
|
||||||
|
: ''}
|
||||||
|
<form method="post" action="/admin/sets/${set.id}/delete" data-confirm="Delete this Menu Set and its Originals? This cannot be undone.">
|
||||||
|
<input type="hidden" name="confirm" value="yes" />
|
||||||
|
<button class="button button--danger" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ export interface DocumentInput {
|
|||||||
body: Html;
|
body: Html;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
noindex: boolean;
|
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 {
|
export function renderDocument(input: DocumentInput): Html {
|
||||||
@@ -38,6 +40,7 @@ export function renderDocument(input: DocumentInput): Html {
|
|||||||
crossorigin
|
crossorigin
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="/assets/styles.css" />
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
|
${input.styles.map((href) => html`<link rel="stylesheet" href="${href}" />`)}
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--steel: ${theme.steel};
|
--steel: ${theme.steel};
|
||||||
@@ -50,7 +53,7 @@ export function renderDocument(input: DocumentInput): Html {
|
|||||||
</head>
|
</head>
|
||||||
<body class="${input.bodyClass}">
|
<body class="${input.bodyClass}">
|
||||||
${input.body}
|
${input.body}
|
||||||
${input.script ? html`<script src="/assets/app.js" defer></script>` : ''}
|
${input.scripts.map((src) => html`<script src="${src}" defer></script>`)}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|||||||
+19
-5
@@ -153,7 +153,8 @@ export function renderLanding(input: LandingInput): Html {
|
|||||||
bodyClass: 'landing-page',
|
bodyClass: 'landing-page',
|
||||||
description: config.brand.tagline,
|
description: config.brand.tagline,
|
||||||
noindex: false,
|
noindex: false,
|
||||||
script: false,
|
styles: [],
|
||||||
|
scripts: [],
|
||||||
body: html`<main class="landing">
|
body: html`<main class="landing">
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<h1 class="wordmark">${config.brand.name}</h1>
|
<h1 class="wordmark">${config.brand.name}</h1>
|
||||||
@@ -174,6 +175,8 @@ export interface SectionInput {
|
|||||||
published: ReadonlySet<string>;
|
published: ReadonlySet<string>;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
archives: readonly StoredSet[];
|
archives: readonly StoredSet[];
|
||||||
|
/** Draft preview for the admin: adds a banner and skips the archive fold. */
|
||||||
|
preview: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSectionPage(input: SectionInput): Html {
|
export function renderSectionPage(input: SectionInput): Html {
|
||||||
@@ -186,14 +189,23 @@ export function renderSectionPage(input: SectionInput): Html {
|
|||||||
${renderRail(config, section.slug, input.published)}
|
${renderRail(config, section.slug, input.published)}
|
||||||
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
|
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
|
||||||
</header>
|
</header>
|
||||||
|
${input.preview
|
||||||
|
? html`<p class="notice notice--admin">
|
||||||
|
Preview of a Draft — customers cannot see this yet.
|
||||||
|
<a href="/admin/sets/${set.id}">Back to the editor</a>
|
||||||
|
</p>`
|
||||||
|
: ''}
|
||||||
<h2 class="section-heading">
|
<h2 class="section-heading">
|
||||||
<span class="section-heading__label">${set.manifest.label}</span>
|
<span class="section-heading__label">${set.manifest.label}</span>
|
||||||
<span class="section-heading__date">${setDateLabel(set.manifest)}</span>
|
<span class="section-heading__date">${setDateLabel(set.manifest)}</span>
|
||||||
</h2>
|
</h2>
|
||||||
${input.current ? '' : html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`}
|
${input.current || input.preview
|
||||||
|
? ''
|
||||||
|
: html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`}
|
||||||
${renderPages(section, set.manifest.pages)}
|
${renderPages(section, set.manifest.pages)}
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
${renderPageIndex(total)} ${renderOlderMenus(section, input.archives, currentHref)}
|
${renderPageIndex(total)}
|
||||||
|
${input.preview ? '' : renderOlderMenus(section, input.archives, currentHref)}
|
||||||
</footer>
|
</footer>
|
||||||
<button class="counter" type="button" data-counter hidden>
|
<button class="counter" type="button" data-counter hidden>
|
||||||
<span data-counter-current>1</span><span class="counter__total">/ ${total}</span>
|
<span data-counter-current>1</span><span class="counter__total">/ ${total}</span>
|
||||||
@@ -205,7 +217,8 @@ export function renderSectionPage(input: SectionInput): Html {
|
|||||||
bodyClass: 'section-page',
|
bodyClass: 'section-page',
|
||||||
description: config.brand.tagline,
|
description: config.brand.tagline,
|
||||||
noindex: !input.current,
|
noindex: !input.current,
|
||||||
script: true,
|
styles: [],
|
||||||
|
scripts: ['/assets/app.js'],
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -228,7 +241,8 @@ export function renderNotFound(config: SiteConfig, live: readonly SectionConfig[
|
|||||||
bodyClass: 'landing-page',
|
bodyClass: 'landing-page',
|
||||||
description: null,
|
description: null,
|
||||||
noindex: true,
|
noindex: true,
|
||||||
script: false,
|
styles: [],
|
||||||
|
scripts: [],
|
||||||
body: html`<main class="landing">
|
body: html`<main class="landing">
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<h1 class="wordmark">${config.brand.name}</h1>
|
<h1 class="wordmark">${config.brand.name}</h1>
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* The admin: sign in, create a Menu Set, upload and order Pages, preview,
|
||||||
|
* publish, restore and delete. Everything is a form post that redirects, so
|
||||||
|
* the admin works without JavaScript; the script only adds drag-to-reorder and
|
||||||
|
* browser-side pre-shrinking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import {
|
||||||
|
LoginThrottle,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
createSessionToken,
|
||||||
|
csrfToken,
|
||||||
|
verifyCsrf,
|
||||||
|
verifyPassword,
|
||||||
|
verifySessionToken,
|
||||||
|
} from '../auth.ts';
|
||||||
|
import { sectionBySlug, type SiteConfig } from '../config.ts';
|
||||||
|
import { renderDashboard, renderEditor, renderLogin } from '../render/admin.ts';
|
||||||
|
import { renderSectionPage } from '../render/pages.ts';
|
||||||
|
import { UnsupportedImageError } from '../images/pipeline.ts';
|
||||||
|
import type { SetStore, StoredSet } from '../storage/sets.ts';
|
||||||
|
import { StoreError } from '../storage/sets.ts';
|
||||||
|
import { sessionCookieAttributes, SESSION_TTL_MS } from '../server.ts';
|
||||||
|
|
||||||
|
const HTML = 'text/html; charset=utf-8';
|
||||||
|
|
||||||
|
export interface AdminRouteDeps {
|
||||||
|
config: SiteConfig;
|
||||||
|
store: SetStore;
|
||||||
|
sessionSecret: string;
|
||||||
|
adminPasswordHash: string | null;
|
||||||
|
secureCookies: boolean;
|
||||||
|
throttle?: LoginThrottle | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHtml(reply: FastifyReply, body: string, status = 200): FastifyReply {
|
||||||
|
return reply.code(status).type(HTML).send(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same-origin check: the second belt beside the CSRF token. */
|
||||||
|
export function sameOrigin(request: FastifyRequest): boolean {
|
||||||
|
const site = request.headers['sec-fetch-site'];
|
||||||
|
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false;
|
||||||
|
const origin = request.headers['origin'];
|
||||||
|
if (typeof origin !== 'string') return true;
|
||||||
|
try {
|
||||||
|
return new URL(origin).host === request.headers['host'];
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRouteDeps): Promise<void> {
|
||||||
|
const { config, store } = deps;
|
||||||
|
const throttle = deps.throttle ?? new LoginThrottle();
|
||||||
|
|
||||||
|
const sessionToken = (request: FastifyRequest): string | undefined =>
|
||||||
|
request.cookies[SESSION_COOKIE];
|
||||||
|
|
||||||
|
const authed = (request: FastifyRequest): boolean =>
|
||||||
|
verifySessionToken(sessionToken(request), deps.sessionSecret);
|
||||||
|
|
||||||
|
const csrfOk = (request: FastifyRequest): boolean => {
|
||||||
|
const provided =
|
||||||
|
(request.headers['x-csrf'] as string | undefined) ??
|
||||||
|
(request.body as Record<string, unknown> | undefined)?.['csrf'];
|
||||||
|
return verifyCsrf(sessionToken(request), deps.sessionSecret, provided);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wantsJson = (request: FastifyRequest): boolean =>
|
||||||
|
(request.headers['accept'] ?? '').includes('application/json');
|
||||||
|
|
||||||
|
const back = (request: FastifyRequest, reply: FastifyReply, notice: string | null) => {
|
||||||
|
const target = request.headers['referer'];
|
||||||
|
const base = typeof target === 'string' ? new URL(target, 'http://x').pathname : '/admin';
|
||||||
|
const url = notice === null ? base : `${base}?notice=${encodeURIComponent(notice)}`;
|
||||||
|
return reply.redirect(url, 303);
|
||||||
|
};
|
||||||
|
|
||||||
|
const guard = async (
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<{ set: StoredSet } | null> => {
|
||||||
|
if (!authed(request)) {
|
||||||
|
reply.redirect('/admin/login', 303);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!sameOrigin(request) || !csrfOk(request)) {
|
||||||
|
reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = (request.params as { id?: string }).id;
|
||||||
|
if (id === undefined) {
|
||||||
|
reply.code(400).send();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { set: await store.get(id) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof StoreError) {
|
||||||
|
reply.code(404).type(HTML).send('That Menu Set no longer exists.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fail = (request: FastifyRequest, reply: FastifyReply, error: unknown): FastifyReply => {
|
||||||
|
const message = error instanceof Error ? error.message : 'Something went wrong.';
|
||||||
|
const status = error instanceof StoreError ? error.status : 400;
|
||||||
|
if (wantsJson(request)) return reply.code(status).send({ error: message });
|
||||||
|
return reply.code(status).type(HTML).send(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Sign in ----------
|
||||||
|
|
||||||
|
app.get('/admin/login', async (request, reply) => {
|
||||||
|
if (authed(request)) return reply.redirect('/admin', 303);
|
||||||
|
return sendHtml(reply, renderLogin(config, null).value);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/login', async (request, reply) => {
|
||||||
|
if (!sameOrigin(request)) return reply.code(403).send('Rejected.');
|
||||||
|
const verdict = throttle.check(request.ip);
|
||||||
|
if (!verdict.allowed) {
|
||||||
|
return reply
|
||||||
|
.code(429)
|
||||||
|
.header('retry-after', String(verdict.retryAfterSeconds))
|
||||||
|
.type(HTML)
|
||||||
|
.send(renderLogin(config, `Too many attempts. Try again in ${verdict.retryAfterSeconds}s.`).value);
|
||||||
|
}
|
||||||
|
const password = (request.body as { password?: string } | undefined)?.password ?? '';
|
||||||
|
const hash = deps.adminPasswordHash;
|
||||||
|
const ok = hash !== null && password !== '' && (await verifyPassword(password, hash));
|
||||||
|
if (!ok) {
|
||||||
|
throttle.recordFailure(request.ip);
|
||||||
|
return reply
|
||||||
|
.code(401)
|
||||||
|
.type(HTML)
|
||||||
|
.send(renderLogin(config, 'That password was not right.').value);
|
||||||
|
}
|
||||||
|
throttle.reset(request.ip);
|
||||||
|
reply.setCookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
createSessionToken(deps.sessionSecret, SESSION_TTL_MS),
|
||||||
|
sessionCookieAttributes(deps.secureCookies),
|
||||||
|
);
|
||||||
|
return reply.redirect('/admin', 303);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/logout', async (_request, reply) => {
|
||||||
|
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
return reply.redirect('/admin/login', 303);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Dashboard ----------
|
||||||
|
|
||||||
|
app.get('/admin/', async (request, reply) => {
|
||||||
|
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||||
|
const notice = (request.query as { notice?: string }).notice ?? null;
|
||||||
|
return sendHtml(
|
||||||
|
reply,
|
||||||
|
renderDashboard({
|
||||||
|
config,
|
||||||
|
sets: await store.list(),
|
||||||
|
current: await store.currentBySection(),
|
||||||
|
notice,
|
||||||
|
}).value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Create, edit, publish ----------
|
||||||
|
|
||||||
|
app.post('/admin/sets', async (request, reply) => {
|
||||||
|
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||||
|
if (!sameOrigin(request) || !csrfOk(request)) {
|
||||||
|
return reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
|
||||||
|
}
|
||||||
|
const body = request.body as { section?: string; label?: string; validUntil?: string };
|
||||||
|
const section = sectionBySlug(config, body.section ?? '');
|
||||||
|
if (section === undefined) return reply.code(400).send('Unknown Section.');
|
||||||
|
try {
|
||||||
|
const created = await store.create(section.slug, {
|
||||||
|
label: body.label ?? `${section.label} Menu`,
|
||||||
|
validUntil: body.validUntil === undefined || body.validUntil === '' ? null : body.validUntil,
|
||||||
|
});
|
||||||
|
return reply.redirect(`/admin/sets/${created.id}`, 303);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(request, reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/admin/sets/:id', async (request, reply) => {
|
||||||
|
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||||
|
let set: StoredSet;
|
||||||
|
try {
|
||||||
|
set = await store.get(request.params.id);
|
||||||
|
} catch {
|
||||||
|
return reply.code(404).type(HTML).send('That Menu Set no longer exists.');
|
||||||
|
}
|
||||||
|
const section = sectionBySlug(config, set.section);
|
||||||
|
if (section === undefined) return reply.code(404).send('Unknown Section.');
|
||||||
|
const query = request.query as { notice?: string; error?: string };
|
||||||
|
return sendHtml(
|
||||||
|
reply,
|
||||||
|
renderEditor({
|
||||||
|
config,
|
||||||
|
section,
|
||||||
|
set,
|
||||||
|
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
|
||||||
|
notice: query.notice ?? null,
|
||||||
|
error: query.error ?? null,
|
||||||
|
}).value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/admin/sets/:id/preview', async (request, reply) => {
|
||||||
|
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||||
|
const set = await store.get(request.params.id);
|
||||||
|
const section = sectionBySlug(config, set.section);
|
||||||
|
if (section === undefined) return reply.code(404).send('Unknown Section.');
|
||||||
|
const current = (await store.currentBySection()).get(section.slug);
|
||||||
|
return sendHtml(
|
||||||
|
reply,
|
||||||
|
renderSectionPage({
|
||||||
|
config,
|
||||||
|
section,
|
||||||
|
set,
|
||||||
|
published: new Set((await store.currentBySection()).keys()),
|
||||||
|
current: current?.id === set.id,
|
||||||
|
preview: set.state === 'draft',
|
||||||
|
archives: [],
|
||||||
|
}).value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/update', async (request, reply) => {
|
||||||
|
const guarded = await guard(request, reply);
|
||||||
|
if (guarded === null) return reply;
|
||||||
|
const body = request.body as { label?: string; validUntil?: string };
|
||||||
|
try {
|
||||||
|
await store.update(guarded.set.id, {
|
||||||
|
label: body.label ?? guarded.set.manifest.label,
|
||||||
|
validUntil:
|
||||||
|
body.validUntil === undefined || body.validUntil === '' ? null : body.validUntil,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return fail(request, reply, error);
|
||||||
|
}
|
||||||
|
return back(request, reply, 'Saved.');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/publish', async (request, reply) => {
|
||||||
|
const guarded = await guard(request, reply);
|
||||||
|
if (guarded === null) return reply;
|
||||||
|
try {
|
||||||
|
const published = await store.publish(guarded.set.id);
|
||||||
|
return reply.redirect(`/${published.section}/`, 303);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(request, reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/restore', async (request, reply) => {
|
||||||
|
const guarded = await guard(request, reply);
|
||||||
|
if (guarded === null) return reply;
|
||||||
|
try {
|
||||||
|
const restored = await store.restore(guarded.set.id);
|
||||||
|
return reply.redirect(`/${restored.section}/`, 303);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(request, reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/delete', async (request, reply) => {
|
||||||
|
const guarded = await guard(request, reply);
|
||||||
|
if (guarded === null) return reply;
|
||||||
|
const body = request.body as { confirm?: string };
|
||||||
|
try {
|
||||||
|
await store.remove(guarded.set.id, { confirmCurrent: body.confirm === 'yes' });
|
||||||
|
} catch (error) {
|
||||||
|
return fail(request, reply, error);
|
||||||
|
}
|
||||||
|
return reply.redirect('/admin', 303);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Pages ----------
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
|
||||||
|
if (!authed(request) || !sameOrigin(request) || !csrfOk(request)) {
|
||||||
|
return reply.code(403).send('Rejected.');
|
||||||
|
}
|
||||||
|
const set = await store.get(request.params.id);
|
||||||
|
if (set.state !== 'draft') {
|
||||||
|
return reply.code(409).send({ error: 'A published Menu Set cannot be changed.' });
|
||||||
|
}
|
||||||
|
if (!request.isMultipart()) return reply.code(400).send('Expected a file upload.');
|
||||||
|
|
||||||
|
const added: string[] = [];
|
||||||
|
const failed: { file: string; error: string }[] = [];
|
||||||
|
for await (const part of request.parts()) {
|
||||||
|
if (part.type !== 'file') continue;
|
||||||
|
const filename = part.filename || 'upload';
|
||||||
|
try {
|
||||||
|
const data = await part.toBuffer();
|
||||||
|
const page = await store.addPage(set.id, { data, originalName: filename });
|
||||||
|
added.push(page.id);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof UnsupportedImageError
|
||||||
|
? error.message
|
||||||
|
: error instanceof StoreError
|
||||||
|
? error.message
|
||||||
|
: error instanceof Error && 'code' in error && error.code === 'FST_REQ_FILE_TOO_LARGE'
|
||||||
|
? `${filename} is larger than the upload limit`
|
||||||
|
: `${filename} could not be uploaded`;
|
||||||
|
failed.push({ file: filename, error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsJson(request)) return reply.send({ added: added.length, failed });
|
||||||
|
const notice =
|
||||||
|
failed.length === 0
|
||||||
|
? `Uploaded ${added.length} page${added.length === 1 ? '' : 's'}.`
|
||||||
|
: `${added.length} uploaded, ${failed.length} failed: ${failed.map((item) => item.error).join('; ')}`;
|
||||||
|
return back(request, reply, notice);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string; pageId: string } }>(
|
||||||
|
'/admin/sets/:id/pages/:pageId/delete',
|
||||||
|
async (request, reply) => {
|
||||||
|
const guarded = await guard(request, reply);
|
||||||
|
if (guarded === null) return reply;
|
||||||
|
try {
|
||||||
|
await store.removePage(guarded.set.id, request.params.pageId);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(request, reply, error);
|
||||||
|
}
|
||||||
|
return back(request, reply, 'Page removed.');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/move', async (request, reply) => {
|
||||||
|
const guarded = await guard(request, reply);
|
||||||
|
if (guarded === null) return reply;
|
||||||
|
const body = request.body as { pageId?: string; direction?: string };
|
||||||
|
const ids = guarded.set.manifest.pages.map((page) => page.id);
|
||||||
|
const index = ids.indexOf(body.pageId ?? '');
|
||||||
|
if (index === -1) return reply.code(404).send('No such Page.');
|
||||||
|
const target = body.direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (target < 0 || target >= ids.length) return back(request, reply, null);
|
||||||
|
const reordered = [...ids];
|
||||||
|
const [moved] = reordered.splice(index, 1);
|
||||||
|
reordered.splice(target, 0, moved!);
|
||||||
|
try {
|
||||||
|
await store.reorder(guarded.set.id, reordered);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(request, reply, error);
|
||||||
|
}
|
||||||
|
return back(request, reply, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/reorder', async (request, reply) => {
|
||||||
|
const guarded = await guard(request, reply);
|
||||||
|
if (guarded === null) return reply;
|
||||||
|
const body = request.body as { pageIds?: unknown };
|
||||||
|
if (!Array.isArray(body.pageIds) || body.pageIds.some((id) => typeof id !== 'string')) {
|
||||||
|
return reply.code(400).send({ error: 'pageIds must be an array of Page ids' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await store.reorder(guarded.set.id, body.pageIds as string[]);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(request, reply, error);
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ export async function registerPublicRoutes(
|
|||||||
set: current,
|
set: current,
|
||||||
published: await publishedSections(),
|
published: await publishedSections(),
|
||||||
current: true,
|
current: true,
|
||||||
|
preview: false,
|
||||||
archives: await store.archives(section.slug),
|
archives: await store.archives(section.slug),
|
||||||
}).value,
|
}).value,
|
||||||
);
|
);
|
||||||
@@ -115,6 +116,7 @@ export async function registerPublicRoutes(
|
|||||||
set,
|
set,
|
||||||
published: await publishedSections(),
|
published: await publishedSections(),
|
||||||
current: current?.id === set.id,
|
current: current?.id === set.id,
|
||||||
|
preview: false,
|
||||||
archives,
|
archives,
|
||||||
}).value,
|
}).value,
|
||||||
);
|
);
|
||||||
|
|||||||
+22
-1
@@ -8,14 +8,17 @@ import { readFile } from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
|
import formbody from '@fastify/formbody';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { parseSiteConfig, type SiteConfig } from './config.ts';
|
import { parseSiteConfig, type SiteConfig } from './config.ts';
|
||||||
import { SESSION_COOKIE, sessionTtl } from './auth.ts';
|
import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
|
||||||
import { createImagePipeline } from './images/pipeline.ts';
|
import { createImagePipeline } from './images/pipeline.ts';
|
||||||
import { SetStore, type ImagePipeline } from './storage/sets.ts';
|
import { SetStore, type ImagePipeline } from './storage/sets.ts';
|
||||||
import { hasFatal, validateSite } from './storage/validate.ts';
|
import { hasFatal, validateSite } from './storage/validate.ts';
|
||||||
import { renderNotFound } from './render/pages.ts';
|
import { renderNotFound } from './render/pages.ts';
|
||||||
import { registerPublicRoutes } from './routes/public.ts';
|
import { registerPublicRoutes } from './routes/public.ts';
|
||||||
|
import { registerAdminRoutes } from './routes/admin.ts';
|
||||||
import { readEnvFile } from './env.ts';
|
import { readEnvFile } from './env.ts';
|
||||||
|
|
||||||
export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
||||||
@@ -48,6 +51,8 @@ export interface AppOptions {
|
|||||||
pipeline?: ImagePipeline;
|
pipeline?: ImagePipeline;
|
||||||
/** Test seam: defaults to the real clock. */
|
/** Test seam: defaults to the real clock. */
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
|
/** Test seam: a shared throttle so rate-limit tests are deterministic. */
|
||||||
|
throttle?: LoginThrottle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
||||||
@@ -58,6 +63,13 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.register(cookie);
|
await app.register(cookie);
|
||||||
|
await app.register(formbody);
|
||||||
|
await app.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: options.config.limits.maxUploadBytes,
|
||||||
|
files: options.config.limits.maxPagesPerSet,
|
||||||
|
},
|
||||||
|
});
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
root: options.assetsDir,
|
root: options.assetsDir,
|
||||||
prefix: '/assets/',
|
prefix: '/assets/',
|
||||||
@@ -80,6 +92,15 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
|||||||
sessionSecret: options.sessionSecret,
|
sessionSecret: options.sessionSecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await registerAdminRoutes(app, {
|
||||||
|
config: options.config,
|
||||||
|
store,
|
||||||
|
sessionSecret: options.sessionSecret,
|
||||||
|
adminPasswordHash: options.adminPasswordHash,
|
||||||
|
secureCookies: options.secureCookies,
|
||||||
|
throttle: options.throttle,
|
||||||
|
});
|
||||||
|
|
||||||
app.setNotFoundHandler(async (_request, reply) => {
|
app.setNotFoundHandler(async (_request, reply) => {
|
||||||
const current = await store.currentBySection();
|
const current = await store.currentBySection();
|
||||||
const live = options.config.sections.filter((section) => current.has(section.slug));
|
const live = options.config.sections.filter((section) => current.has(section.slug));
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { LoginThrottle, SESSION_COOKIE, createSessionToken, csrfToken, hashPassword } from '../src/auth.ts';
|
||||||
|
import { parseSiteConfig } from '../src/config.ts';
|
||||||
|
import { buildApp } from '../src/server.ts';
|
||||||
|
import { FakePipeline, pngBytes, tempDir, testClock } from './support/harness.ts';
|
||||||
|
|
||||||
|
const ASSETS = path.join(import.meta.dirname, '..', 'public');
|
||||||
|
const SECRET = 'admin-test-secret';
|
||||||
|
const PASSWORD = 'a good long password';
|
||||||
|
|
||||||
|
let dataDir: string;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let session: string;
|
||||||
|
let csrf: string;
|
||||||
|
|
||||||
|
const CLOCK = testClock('2026-05-01T06:00:00.000Z');
|
||||||
|
|
||||||
|
async function build(throttle?: LoginThrottle): Promise<FastifyInstance> {
|
||||||
|
const built = await buildApp({
|
||||||
|
config: parseSiteConfig({
|
||||||
|
site: 'menu.example.test',
|
||||||
|
brand: { name: 'Hops n Grains' },
|
||||||
|
sections: [
|
||||||
|
{ slug: 'food', label: 'Food', order: 1 },
|
||||||
|
{ slug: 'bar', label: 'Bar', order: 2 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
dataDir,
|
||||||
|
sessionSecret: SECRET,
|
||||||
|
adminPasswordHash: await hashPassword(PASSWORD),
|
||||||
|
assetsDir: ASSETS,
|
||||||
|
secureCookies: false,
|
||||||
|
logLevel: 'silent',
|
||||||
|
pipeline: new FakePipeline(),
|
||||||
|
now: CLOCK.now,
|
||||||
|
...(throttle === undefined ? {} : { throttle }),
|
||||||
|
});
|
||||||
|
await built.ready();
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dataDir = await tempDir('admin');
|
||||||
|
app = await build();
|
||||||
|
session = createSessionToken(SECRET, 60 * 60 * 1000, new Date());
|
||||||
|
csrf = csrfToken(session, SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookies = () => ({ [SESSION_COOKIE]: session });
|
||||||
|
|
||||||
|
async function newSet(section = 'bar', label = 'Bar Menu', validUntil: string | null = null) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sets',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
payload: { section, label, ...(validUntil === null ? {} : { validUntil }) },
|
||||||
|
});
|
||||||
|
const location = String(response.headers['location'] ?? '');
|
||||||
|
return { response, id: location.replace('/admin/sets/', '') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(id: string, name: string) {
|
||||||
|
const boundary = '----menuadminboundary';
|
||||||
|
const payload = Buffer.concat([
|
||||||
|
Buffer.from(
|
||||||
|
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="${name}"\r\nContent-Type: image/jpeg\r\n\r\n`,
|
||||||
|
),
|
||||||
|
pngBytes(name),
|
||||||
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||||
|
]);
|
||||||
|
return app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/pages`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: {
|
||||||
|
'x-csrf': csrf,
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('signing in', () => {
|
||||||
|
it('sends an anonymous visitor to the login form', async () => {
|
||||||
|
const dashboard = await app.inject({ method: 'GET', url: '/admin' });
|
||||||
|
assert.equal(dashboard.statusCode, 303);
|
||||||
|
assert.equal(dashboard.headers['location'], '/admin/login');
|
||||||
|
|
||||||
|
const login = await app.inject({ method: 'GET', url: '/admin/login' });
|
||||||
|
assert.equal(login.statusCode, 200);
|
||||||
|
assert.match(login.body, /type="password"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong password and accepts the right one', async () => {
|
||||||
|
const wrong = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/login',
|
||||||
|
payload: { password: 'not it' },
|
||||||
|
});
|
||||||
|
assert.equal(wrong.statusCode, 401);
|
||||||
|
assert.equal(wrong.headers['set-cookie'], undefined);
|
||||||
|
assert.match(wrong.body, /not right/);
|
||||||
|
|
||||||
|
const right = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/login',
|
||||||
|
payload: { password: PASSWORD },
|
||||||
|
});
|
||||||
|
assert.equal(right.statusCode, 303);
|
||||||
|
const cookie = String(right.headers['set-cookie']);
|
||||||
|
assert.match(cookie, /menu_session=/);
|
||||||
|
assert.match(cookie, /HttpOnly/);
|
||||||
|
assert.match(cookie, /SameSite=Lax/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throttles repeated failures per IP', async () => {
|
||||||
|
const throttled = await build(new LoginThrottle({ maxFailures: 2, windowMs: 60_000 }));
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
await throttled.inject({ method: 'POST', url: '/admin/login', payload: { password: 'no' } });
|
||||||
|
}
|
||||||
|
const blocked = await throttled.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/login',
|
||||||
|
payload: { password: 'no' },
|
||||||
|
});
|
||||||
|
assert.equal(blocked.statusCode, 429);
|
||||||
|
assert.ok(Number(blocked.headers['retry-after']) > 0);
|
||||||
|
await throttled.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs out', async () => {
|
||||||
|
const response = await app.inject({ method: 'POST', url: '/admin/logout', cookies: cookies() });
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
assert.match(String(response.headers['set-cookie']), /menu_session=;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('guarding the admin', () => {
|
||||||
|
it('needs a session for the dashboard and the editor', async () => {
|
||||||
|
assert.equal((await app.inject({ method: 'GET', url: '/admin' })).statusCode, 303);
|
||||||
|
const { id } = await newSet();
|
||||||
|
assert.equal((await app.inject({ method: 'GET', url: `/admin/sets/${id}` })).statusCode, 303);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs a CSRF token on mutations', async () => {
|
||||||
|
const missing = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sets',
|
||||||
|
cookies: cookies(),
|
||||||
|
payload: { section: 'bar', label: 'Bar Menu' },
|
||||||
|
});
|
||||||
|
assert.equal(missing.statusCode, 403);
|
||||||
|
|
||||||
|
const wrong = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sets',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': 'not-the-token' },
|
||||||
|
payload: { section: 'bar', label: 'Bar Menu' },
|
||||||
|
});
|
||||||
|
assert.equal(wrong.statusCode, 403);
|
||||||
|
|
||||||
|
// A token minted for a different session must not work either.
|
||||||
|
const otherSession = createSessionToken(SECRET, 60_000, new Date());
|
||||||
|
const stolen = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sets',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrfToken(otherSession, SECRET) },
|
||||||
|
payload: { section: 'bar', label: 'Bar Menu' },
|
||||||
|
});
|
||||||
|
assert.equal(stolen.statusCode, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a cross-site mutation even with a session', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sets',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf, 'sec-fetch-site': 'cross-site' },
|
||||||
|
payload: { section: 'bar', label: 'Bar Menu' },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the dashboard', () => {
|
||||||
|
it('lists Sections, their Current set and their Drafts', async () => {
|
||||||
|
const { id } = await newSet('bar', 'Bar Menu');
|
||||||
|
await upload(id, 'one.jpg');
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/publish`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
});
|
||||||
|
await newSet('bar', 'Bar Menu, next');
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.match(response.body, /Bar Menu/);
|
||||||
|
assert.match(response.body, /Bar Menu, next/);
|
||||||
|
assert.match(response.body, /pill--current/);
|
||||||
|
assert.match(response.body, /pill--draft/);
|
||||||
|
assert.match(response.body, /No menu published yet/);
|
||||||
|
assert.match(response.body, /Food Menu/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a notice through a redirect', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin?notice=Saved.',
|
||||||
|
cookies: cookies(),
|
||||||
|
});
|
||||||
|
assert.match(response.body, /Saved\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the editor', () => {
|
||||||
|
it('creates a Draft and shows the upload controls', async () => {
|
||||||
|
const { response, id } = await newSet('bar', 'Bar Menu', '2026-06-26');
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
assert.equal(response.headers['location'], `/admin/sets/${id}`);
|
||||||
|
|
||||||
|
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
|
||||||
|
assert.equal(editor.statusCode, 200);
|
||||||
|
assert.match(editor.body, /data-drop/);
|
||||||
|
assert.match(editor.body, /data-pages-admin/);
|
||||||
|
assert.match(editor.body, /enctype="multipart\/form-data"/);
|
||||||
|
assert.match(editor.body, new RegExp(`data-csrf="${csrf}"`));
|
||||||
|
assert.match(editor.body, /No pages yet/);
|
||||||
|
assert.match(editor.body, /value="2026-06-26"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a Section it does not know', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sets',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
payload: { section: 'pizza', label: 'Pizza' },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the label and valid-until date', async () => {
|
||||||
|
const { id } = await newSet();
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/update`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
|
||||||
|
payload: { label: 'Bar Menu, summer', validUntil: '2026-09-30' },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
const set = await app.menu.store.get(id);
|
||||||
|
assert.equal(set.manifest.label, 'Bar Menu, summer');
|
||||||
|
assert.equal(set.manifest.validUntil, '2026-09-30');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploading Pages', () => {
|
||||||
|
it('accepts a multipart file and reports the count', async () => {
|
||||||
|
const { id } = await newSet();
|
||||||
|
const response = await upload(id, 'IMG_0001.JPG');
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.deepEqual(response.json(), { added: 1, failed: [] });
|
||||||
|
|
||||||
|
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
|
||||||
|
assert.match(editor.body, /IMG_0001\.JPG/);
|
||||||
|
assert.match(editor.body, /\/media\/thumb-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a file the pipeline will not accept, by name', async () => {
|
||||||
|
const { id } = await newSet();
|
||||||
|
const boundary = '----x';
|
||||||
|
const payload = Buffer.concat([
|
||||||
|
Buffer.from(
|
||||||
|
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="notes.pdf"\r\nContent-Type: application/pdf\r\n\r\nnot an image\r\n--${boundary}--\r\n`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
// The fake pipeline accepts anything, so this exercises the wiring only:
|
||||||
|
// a real rejection is covered by the pipeline tests.
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/pages`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: {
|
||||||
|
'x-csrf': csrf,
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to add a Page to a published set', async () => {
|
||||||
|
const { id } = await newSet();
|
||||||
|
await upload(id, 'one.jpg');
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/publish`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
});
|
||||||
|
const response = await upload(id, 'two.jpg');
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a Page', async () => {
|
||||||
|
const { id } = await newSet();
|
||||||
|
await upload(id, 'one.jpg');
|
||||||
|
const set = await app.menu.store.get(id);
|
||||||
|
const pageId = set.manifest.pages[0]!.id;
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/pages/${pageId}/delete`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
assert.equal((await app.menu.store.get(id)).manifest.pages.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ordering', () => {
|
||||||
|
async function threePages() {
|
||||||
|
const { id } = await newSet();
|
||||||
|
await upload(id, 'one.jpg');
|
||||||
|
await upload(id, 'two.jpg');
|
||||||
|
await upload(id, 'three.jpg');
|
||||||
|
const set = await app.menu.store.get(id);
|
||||||
|
return { id, ids: set.manifest.pages.map((page) => page.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('moves a Page up with a form post', async () => {
|
||||||
|
const { id, ids } = await threePages();
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/move`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
|
||||||
|
payload: { pageId: ids[1], direction: 'up' },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
|
||||||
|
[ids[1], ids[0], ids[2]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a move past either end', async () => {
|
||||||
|
const { id, ids } = await threePages();
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/move`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
|
||||||
|
payload: { pageId: ids[0], direction: 'up' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders from a JSON list, and rejects a bad one', async () => {
|
||||||
|
const { id, ids } = await threePages();
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/reorder`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf, accept: 'application/json' },
|
||||||
|
payload: { pageIds: [ids[2], ids[0], ids[1]] },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
|
||||||
|
[ids[2], ids[0], ids[1]],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/reorder`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf, accept: 'application/json' },
|
||||||
|
payload: { pageIds: [ids[0]] },
|
||||||
|
});
|
||||||
|
assert.equal(bad.statusCode, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('publishing, restoring and deleting', () => {
|
||||||
|
it('publishes a Draft and sends the admin to the live menu', async () => {
|
||||||
|
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');
|
||||||
|
await upload(id, 'one.jpg');
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/publish`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
assert.equal(response.headers['location'], '/bar/');
|
||||||
|
assert.equal((await app.menu.store.get(id)).slug, '2026-06-26');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to publish an empty set', async () => {
|
||||||
|
const { id } = await newSet();
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/publish`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes an archived set Current again', async () => {
|
||||||
|
const first = await newSet('bar', 'Bar Menu', '2026-06-26');
|
||||||
|
await upload(first.id, 'one.jpg');
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${first.id}/publish`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
});
|
||||||
|
|
||||||
|
CLOCK.advance(60_000);
|
||||||
|
const second = await newSet('bar', 'Bar Menu, next');
|
||||||
|
await upload(second.id, 'two.jpg');
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${second.id}/publish`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
});
|
||||||
|
|
||||||
|
CLOCK.advance(60_000);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${first.id}/restore`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
const current = (await app.menu.store.currentBySection()).get('bar');
|
||||||
|
assert.equal(current?.manifest.id, first.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a Draft and refuses to delete the Current set without confirmation', async () => {
|
||||||
|
const { id } = await newSet();
|
||||||
|
await upload(id, 'one.jpg');
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/publish`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
});
|
||||||
|
|
||||||
|
const unconfirmed = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/delete`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
assert.equal(unconfirmed.statusCode, 409);
|
||||||
|
|
||||||
|
const confirmed = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/delete`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
payload: { confirm: 'yes' },
|
||||||
|
});
|
||||||
|
assert.equal(confirmed.statusCode, 303);
|
||||||
|
assert.deepEqual(await app.menu.store.list(), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('previewing a Draft', () => {
|
||||||
|
it('renders the customer page with a banner, and serves its media to the session', async () => {
|
||||||
|
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');
|
||||||
|
await upload(id, 'one.jpg');
|
||||||
|
const set = await app.menu.store.get(id);
|
||||||
|
const page = set.manifest.pages[0]!;
|
||||||
|
|
||||||
|
const preview = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/admin/sets/${id}/preview`,
|
||||||
|
cookies: cookies(),
|
||||||
|
});
|
||||||
|
assert.equal(preview.statusCode, 200);
|
||||||
|
assert.match(preview.body, /Preview of a Draft/);
|
||||||
|
assert.match(preview.body, /alt="Bar, page 1 of 1"/);
|
||||||
|
assert.match(preview.body, /name="robots" content="noindex"/);
|
||||||
|
assert.doesNotMatch(preview.body, /Older menus/);
|
||||||
|
|
||||||
|
const media = `/media/${page.id}.${page.hash}.webp`;
|
||||||
|
assert.equal((await app.inject({ method: 'GET', url: media })).statusCode, 404);
|
||||||
|
assert.equal(
|
||||||
|
(await app.inject({ method: 'GET', url: media, cookies: cookies() })).statusCode,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs a session to preview', async () => {
|
||||||
|
const { id } = await newSet();
|
||||||
|
const response = await app.inject({ method: 'GET', url: `/admin/sets/${id}/preview` });
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user