Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<p>still here</p>","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
Expand Down
9 changes: 8 additions & 1 deletion cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(' ');
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<base>/a/<slug>`, 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=<name>` 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).

Expand Down
23 changes: 23 additions & 0 deletions lib/expiry.js
Original file line number Diff line number Diff line change
@@ -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();
}
14 changes: 11 additions & 3 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1544,7 +1544,11 @@ <h2>Published</h2><span class="count" id="count"></span>

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' : '');

Expand Down Expand Up @@ -1778,11 +1782,15 @@ <h2>Published</h2><span class="count" id="count"></span>

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 });
Expand Down
13 changes: 9 additions & 4 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions test/expiry.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading