diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30d5d2f..dce98bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,6 +245,45 @@ jobs: [ "$code" = "200" ] || { echo "FAIL: DELETE /api/keys/k_broken -> $code"; exit 1; } if curl -s -H "$AUTH" http://localhost:3000/api/keys | grep -q k_broken; then echo "FAIL: revoked record still listed"; exit 1; fi echo "ok: the broken record patches and revokes from the key screen" + - name: An artifact whose expiresAt cannot be read serves 410 + run: | + # parseExpiresAt refuses a non-ISO expiresAt on the way in, so the only route to this + # record is the one an operator takes: a hand edit, or a restore of an older backup. + # Plant it between two boots the way the auth.json step above does, then drive the + # real serve path rather than trusting the unit test on lib/expiry.js. Visibility is + # explicit because DEFAULT_VISIBILITY is private, and a private artifact answers 404 + # rather than 410, which would prove nothing either way. + AUTH='Authorization: Bearer ci-test-key' + curl -s -o /dev/null -X POST http://localhost:3000/api/artifacts -H "$AUTH" \ + -H 'Content-Type: application/json' \ + -d '{"slug":"expiry-junk","content":"

still here

","frame":false,"visibility":"public"}' + code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/a/expiry-junk) + [ "$code" = "200" ] || { echo "FAIL: the artifact did not publish (got $code)"; exit 1; } + pkill -f 'node server.js' || true + sleep 1 + node -e ' + const fs = require("fs"); + const p = process.env.DATA_DIR + "/artifacts/expiry-junk/meta.json"; + const m = JSON.parse(fs.readFileSync(p, "utf8")); + m.expiresAt = "garbage"; + fs.writeFileSync(p, JSON.stringify(m, null, 2)); + ' + node server.js & + for i in $(seq 1 20); do curl -sf http://localhost:3000/healthz && break; sleep 0.5; done + curl -sf http://localhost:3000/healthz > /dev/null \ + || { echo "FAIL: the server did not come back up after the restart"; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/a/expiry-junk) + [ "$code" = "410" ] || { echo "FAIL: /a/expiry-junk -> $code, expected 410"; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/a/expiry-junk/source) + [ "$code" = "410" ] || { echo "FAIL: /a/expiry-junk/source -> $code, expected 410"; exit 1; } + echo "ok: an unreadable expiresAt serves 410 on both serve paths" + # And the operator can clear it from the dashboard without touching the file again. + code=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"expiresAt":null}' http://localhost:3000/api/artifacts/expiry-junk) + [ "$code" = "200" ] || { echo "FAIL: PATCH clearing the expiry -> $code"; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/a/expiry-junk) + [ "$code" = "200" ] || { echo "FAIL: the artifact did not come back after clearing the expiry (got $code)"; exit 1; } + echo "ok: clearing the expiry over the API brings the artifact back" smoke-s3: runs-on: ubuntu-latest diff --git a/cli.js b/cli.js index 2d2d78e..80e37ae 100644 --- a/cli.js +++ b/cli.js @@ -5,6 +5,8 @@ import { parseArgs } from 'node:util'; import AdmZip from 'adm-zip'; +import { artifactExpired } from './lib/expiry.js'; + const USAGE = `artifacts — publish to a self-hosted artifacts instance Usage: @@ -196,7 +198,12 @@ switch (command) { for (const a of artifacts) { const frameFlag = a.frame === true ? 'frame:on' : a.frame === false ? 'frame:off' : null; const visFlag = a.visibility === 'private' ? 'private' : a.visibility === 'password' ? 'password' : null; - const flags = [a.disabled && 'disabled', visFlag, frameFlag, a.expiresAt && `expires ${a.expiresAt}`].filter(Boolean); + // The row printed an expiry and never said whether it had passed, so an artifact + // answering 410 listed the same as a live one, and a stored value that is not a string + // printed as "expires [object Object]". Same rule the server serves by. + const expired = artifactExpired(a); + const expiry = expired ? 'expired' : typeof a.expiresAt === 'string' && `expires ${a.expiresAt}`; + const flags = [a.disabled && 'disabled', visFlag, frameFlag, expiry].filter(Boolean); const project = a.project ? `@${a.project}` : ''; const tags = a.tags?.length ? `#${a.tags.join(' #')}` : ''; const meta = [project, tags].filter(Boolean).join(' '); diff --git a/docs/api.md b/docs/api.md index 9078d51..40f4e50 100644 --- a/docs/api.md +++ b/docs/api.md @@ -32,7 +32,7 @@ Semantics: - A write that stores a redirect answers with `target`, the normalized URL it stored, which is not always the string that was sent. - QR codes: `GET /api/artifacts/:slug/qr` returns an image of the artifact's canonical URL (`/a/`, with the trailing slash for a zip site). `format` is `svg` (default) or `png`, `scale` is 1 to 16 pixels per module (default 8) and `margin` is 0 to 8 modules of quiet zone (default 4). Both take digits only: anything else, including a value out of range, is a `400` rather than a silent fallback. Rendering a PNG is the only synchronous CPU on a `read` route, which is what the scale ceiling is for. The code always carries the permanent link, never a capability link: a `?k=` token expires and can be revoked, and a printed code cannot be reissued. A scan of a password artifact lands on the unlock page; a private artifact answers `404` on its bare link, so make it public or password-protected before printing the code. A disabled or expired artifact still has a QR, the same way it still has a share link. Encoding is byte mode at error-correction level M, up to 666 bytes, generated in-process with no external service. - `POST` with an existing slug → `409` (use `PUT` to update). -- Disabled artifacts return `404`; expired ones (`expiresAt` in the past) return `410`. Both keep their content — re-enable or clear/extend the expiry to serve again. +- Disabled artifacts return `404`; expired ones (`expiresAt` in the past, or holding a value that cannot be read as a date at all) return `410`. Both keep their content — re-enable or clear/extend the expiry to serve again. - Tags: an array of strings, or one comma-separated string (the only form the zip endpoint's `?tags=` accepts). Each tag must match `[a-z0-9][a-z0-9-]{0,31}`; max 10 per artifact. Input is lowercased and deduplicated. `PATCH` replaces the whole list; an empty list clears it. `PUT` without `tags` keeps the existing ones. Artifacts published before tags existed list as `"tags": []`. In the web UI, tags render as chips — click one to filter the list. - Project: a single grouping label (one per artifact), distinct from tags. Unicode letters/digits, spaces, and `-` `_` `.`, starting with a letter or digit, max 64 chars; internal whitespace is collapsed and case is preserved. Matching (`?project=` and UI grouping) is **exact and case-sensitive** — `Acme` and `acme` are different projects. `PATCH` sets it; an empty string clears it. `PUT` without `project` keeps the existing one. `GET /api/artifacts?project=` returns only that project's artifacts (an empty `?project=` is ignored, not a filter for "no project"). The web UI groups the list into collapsible sections per project (with a search box across project / title / slug / tags / a redirect's target). diff --git a/lib/expiry.js b/lib/expiry.js new file mode 100644 index 0000000..999d9f4 --- /dev/null +++ b/lib/expiry.js @@ -0,0 +1,23 @@ +// Whether an artifact has lapsed. +// +// This lives outside server.js so a unit test can hand it the shapes an API call cannot make. +// parseExpiresAt refuses anything but an ISO string on the way in, so the values below arrive +// from a hand-edited or restored meta.json, the same surface T1.2.11 and T1.2.20 covered on the +// auth side. CI reaches them the other way, by writing meta.json between two boots. +// +// The rule is keyExpired's, applied to artifact metadata: absent means no expiry, and anything +// present that cannot be read as a date counts as lapsed. Reading NaN as "not expired" is the +// direction that fails open, and it disables the auto-expire lifecycle the README promises for +// every record it touches. +// +// Non-strings are lapsed without asking Date.parse, because Date.parse stringifies first and +// then accepts more than an operator would expect: `12345` reads as the year 12345 and +// `["2026-01-01T00:00:00Z"]` reads as the string inside the array, so both would otherwise keep +// serving forever. Every value this server writes is an ISO string. +export function artifactExpired(meta) { + const value = meta.expiresAt; + if (value === undefined || value === null || value === '') return false; + if (typeof value !== 'string') return true; + const t = Date.parse(value); + return Number.isNaN(t) || t <= Date.now(); +} diff --git a/public/index.html b/public/index.html index 817174f..975d40d 100644 --- a/public/index.html +++ b/public/index.html @@ -1544,7 +1544,11 @@

Published

function buildItem(a, opts) { opts = opts || {}; - const expired = a.expiresAt && Date.parse(a.expiresAt) <= Date.now(); + // Same rule as artifactExpired in lib/expiry.js, kept in step by hand: a value the + // server cannot read is lapsed, so the row never shows a live artifact the server + // answers 410 for. Any change here belongs in both places. + const expired = a.expiresAt != null && a.expiresAt !== '' + && (typeof a.expiresAt !== 'string' || !(Date.parse(a.expiresAt) > Date.now())); const item = document.createElement('div'); item.className = 'item' + ((a.disabled || expired) ? ' off' : ''); @@ -1778,11 +1782,15 @@

Published

menuItem(menu, 'QR code…', '', () => openQr(a)); - menuItem(menu, a.expiresAt ? 'Expiry: ' + a.expiresAt.slice(0, 10) + '…' : 'Expiry…', '', async () => { + // A hand-edited meta.json can hold anything here, and the row menu is the screen the + // operator opens to clear it. slice() on a number or an object throws inside the fill + // callback, which stops the menu at this line: no Expiry, no Delete, nothing below. + const expiry = typeof a.expiresAt === 'string' ? a.expiresAt : ''; + menuItem(menu, expiry ? 'Expiry: ' + expiry.slice(0, 10) + '…' : 'Expiry…', '', async () => { const v = await dialog({ title: 'Expiry for “' + a.slug + '”', body: 'e.g. 2026-08-01 or 2026-08-01T12:00. Leave empty to clear.', - input: true, value: a.expiresAt ? a.expiresAt.slice(0, 16) : '', + input: true, value: expiry.slice(0, 16), }); if (v === null) return; patchItem(a, { expiresAt: v.trim() || null }, { expiresAt: v.trim() || undefined }); diff --git a/server.js b/server.js index 3812824..ca81e95 100644 --- a/server.js +++ b/server.js @@ -33,6 +33,7 @@ import { } from './lib/auth.js'; import { createConfigStore } from './lib/config.js'; import { ApiError } from './lib/errors.js'; +import { artifactExpired } from './lib/expiry.js'; import { qrPng, qrSvg } from './lib/qr.js'; import { parseRedirectTarget, resolveRedirectTarget } from './lib/redirect.js'; @@ -565,9 +566,9 @@ function parseExpiresAt(value) { return new Date(value).toISOString(); } -function isExpired(meta) { - return Boolean(meta.expiresAt && Date.parse(meta.expiresAt) <= Date.now()); -} +// The rule itself is in lib/expiry.js, where a unit test can reach the records a request +// cannot make. The five call sites below keep the short local name. +const isExpired = artifactExpired; async function saveArtifact({ content, type = 'html', slug, title, expiresAt, frame, tags, project, visibility, password }, { replace = false } = {}) { if (typeof content !== 'string' || !content.trim()) { @@ -1305,7 +1306,6 @@ app.post('/a/:slug/unlock', async (req, res, next) => { } const meta = SLUG_RE.test(slug) ? await readMeta(slug) : null; if (!meta || meta.disabled) return res.status(404).json({ error: 'not found' }); - if (isExpired(meta)) return res.status(410).json({ error: 'expired' }); const password = req.body?.password; if (meta.visibility !== 'password') { // private uses capability links, not passwords; public needs no unlock. Uniform 401 @@ -1321,6 +1321,11 @@ app.post('/a/:slug/unlock', async (req, res, next) => { logAuth('unlock', { ip, slug, outcome: 'fail' }); return res.status(401).json({ error: 'incorrect password' }); } + // Expiry is checked here rather than above the visibility branch. The 404/401 pair over + // this route is deliberately uniform (see the comment on that branch), and a 410 handed + // out before the password is proven told an anonymous caller the slug exists. The two GET + // paths already order it this way; this one did not. + if (isExpired(meta)) return res.status(410).json({ error: 'expired' }); await issueUnlock(res, meta); res.json({ ok: true }); } catch (err) { diff --git a/test/expiry.test.js b/test/expiry.test.js new file mode 100644 index 0000000..e167b6f --- /dev/null +++ b/test/expiry.test.js @@ -0,0 +1,36 @@ +// When an artifact has lapsed. +// +// This is here rather than in the smoke suite because the API cannot produce the records that +// matter: parseExpiresAt refuses anything that is not an ISO string on the way in, so a junk +// expiresAt only ever arrives from a hand-edited or restored meta.json. These tests pin what +// each shape means before Date.parse gets a say. The 410 those records serve is proven end to +// end in ci.yml, which plants one in meta.json between two boots. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { artifactExpired } from '../lib/expiry.js'; + +// `false` and `0` are the two that separate this from the shorter `if (!meta.expiresAt)` guard: +// both are falsy, so that version reads them as "no expiry" and the artifact serves forever. +// `12345` and a one-element array are the two Date.parse accepts after stringifying, which is +// why the rule refuses a non-string before it asks. +test('an unreadable expiresAt counts as expired', () => { + const junk = ['garbage', '2026-13-45', {}, true, false, [], 0, 12345, ['2026-01-01T00:00:00Z'], 'null', ' ']; + for (const value of junk) { + assert.equal(artifactExpired({ expiresAt: value }), true, `${JSON.stringify(value)} should be expired`); + } +}); + +test('no expiry means the artifact never lapses', () => { + for (const absent of [undefined, null, '']) { + assert.equal(artifactExpired({ expiresAt: absent }), false, `${String(absent)} should not be expired`); + } + assert.equal(artifactExpired({}), false); +}); + +test('a readable expiresAt is compared against now', () => { + const past = new Date(Date.now() - 60_000).toISOString(); + const future = new Date(Date.now() + 60_000).toISOString(); + assert.equal(artifactExpired({ expiresAt: past }), true); + assert.equal(artifactExpired({ expiresAt: future }), false); +});