domma-cms 0.49.2 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,9 +5,21 @@
5
5
  * POST /api/pages - create page
6
6
  * PUT /api/pages/* - update page
7
7
  * DELETE /api/pages/* - delete page
8
+ * POST /api/pages/preview - render markdown body to HTML (no page shell)
9
+ * POST /api/pages/preview/full - render UNSAVED frontmatter+body as a complete page
10
+ * GET /api/pages/preview-links - list share links (optionally ?urlPath=)
11
+ * POST /api/pages/preview-links - mint a share link for one page
12
+ * DELETE /api/pages/preview-links/:id - revoke a share link
8
13
  */
9
- import {createPage, deletePage, getPage, listPages, renamePage, updatePage} from '../../services/content.js';
14
+ import {buildPreviewPage, createPage, deletePage, getPage, listPages, renamePage, updatePage} from '../../services/content.js';
10
15
  import {parseMarkdown} from '../../services/markdown.js';
16
+ import {renderPage} from '../../services/renderer.js';
17
+ import {
18
+ createPreviewLink,
19
+ deletePreviewLinksForPage,
20
+ listPreviewLinks,
21
+ revokePreviewLink
22
+ } from '../../services/previewLinks.js';
11
23
  import {getVersionCount} from '../../services/versions.js';
12
24
  import {authenticate, requirePermission} from '../../middleware/auth.js';
13
25
  import {getConfig, saveConfig} from '../../config.js';
@@ -26,6 +38,73 @@ export async function pagesRoutes(fastify) {
26
38
  return {html};
27
39
  });
28
40
 
41
+ // Full-page preview of UNSAVED editor state.
42
+ //
43
+ // The body-only route above answers "did my shortcodes parse". This one
44
+ // answers "is the page right" - it runs the same parseMarkdown and
45
+ // renderPage the public site runs on save, so layout, navbar, footer,
46
+ // theme, menus, dconfig and custom CSS are all exactly what shipping
47
+ // would produce. No draft banner: the editor is already telling the user
48
+ // what they are looking at.
49
+ fastify.post('/pages/preview/full', canRead, async (request, reply) => {
50
+ const {frontmatter = {}, body = '', urlPath = '/'} = request.body || {};
51
+ if (typeof body !== 'string') return reply.status(400).send({error: 'body must be a string'});
52
+ if (typeof urlPath !== 'string' || !urlPath.startsWith('/')) {
53
+ return reply.status(400).send({error: 'urlPath must begin with /'});
54
+ }
55
+ if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
56
+ return reply.status(400).send({error: 'frontmatter must be an object'});
57
+ }
58
+
59
+ const page = await buildPreviewPage(urlPath, frontmatter, body, {user: request.user || null});
60
+ const html = await renderPage(page, {
61
+ baseUrl: `${request.protocol}://${request.host}`,
62
+ user: request.user || null
63
+ });
64
+ return {html};
65
+ });
66
+
67
+ // --- Share links --------------------------------------------------------
68
+ // Minting is gated on `pages.update`, not `pages.read`: handing an
69
+ // unauthenticated stranger a URL that renders unpublished content is an act
70
+ // of publishing, however narrow, and should sit with the people who could
71
+ // publish the page outright.
72
+
73
+ fastify.get('/pages/preview-links', canRead, async (request) => {
74
+ const {urlPath} = request.query || {};
75
+ return {links: await listPreviewLinks(urlPath || undefined)};
76
+ });
77
+
78
+ fastify.post('/pages/preview-links', canUpdate, async (request, reply) => {
79
+ const {urlPath, expiresIn, label} = request.body || {};
80
+ if (!urlPath || typeof urlPath !== 'string' || !urlPath.startsWith('/')) {
81
+ return reply.status(400).send({error: 'urlPath must begin with /'});
82
+ }
83
+ // Refuse to mint for a page that does not exist - otherwise a typo
84
+ // produces a link that 404s for the recipient and looks like a bug.
85
+ if (!(await getPage(urlPath))) {
86
+ return reply.status(404).send({error: 'Page not found'});
87
+ }
88
+ const link = await createPreviewLink({
89
+ urlPath,
90
+ jwt: fastify.jwt,
91
+ label,
92
+ expiresIn,
93
+ createdBy: request.user?.id || null,
94
+ createdByName: request.user?.name || ''
95
+ });
96
+ return reply.status(201).send({
97
+ ...link,
98
+ url: `${request.protocol}://${request.host}/_preview?token=${encodeURIComponent(link.token)}`
99
+ });
100
+ });
101
+
102
+ fastify.delete('/pages/preview-links/:id', canUpdate, async (request, reply) => {
103
+ const ok = await revokePreviewLink(request.params.id);
104
+ if (!ok) return reply.status(404).send({error: 'Link not found'});
105
+ return {success: true};
106
+ });
107
+
29
108
  // Aggregate unique tags from all pages - must be registered before the wildcard /pages/* route
30
109
  fastify.get('/pages/tags', canRead, async (request, reply) => {
31
110
  const pages = await listPages();
@@ -105,6 +184,9 @@ export async function pagesRoutes(fastify) {
105
184
 
106
185
  await renamePage(urlPath, newUrlPath);
107
186
  await rewriteNavLinks(urlPath, newUrlPath);
187
+ // Links name the old path in their signed payload, so they cannot
188
+ // follow the move - drop them rather than leave them 404ing.
189
+ await deletePreviewLinksForPage(urlPath);
108
190
 
109
191
  const page = await updatePage(newUrlPath, frontmatter || {}, body, {author: request.user.username});
110
192
  return page;
@@ -121,6 +203,8 @@ export async function pagesRoutes(fastify) {
121
203
  if (!existing) return reply.status(404).send({ error: 'Page not found' });
122
204
 
123
205
  await deletePage(urlPath);
206
+ // A share link must not outlive the page it pointed at.
207
+ await deletePreviewLinksForPage(urlPath);
124
208
  return { success: true };
125
209
  });
126
210
  }
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Public Site Routes
3
3
  * Catch-all that resolves URL paths to Markdown pages and renders them server-side.
4
- * Draft pages are not served publicly.
4
+ * Draft pages are not served publicly - but a signed-in user holding
5
+ * `pages.read` is shown the real page with a draft banner, so designers and
6
+ * developers can verify a page before it is released.
5
7
  * The admin panel is excluded (handled by static serving).
6
8
  */
7
9
  import {getPage, getPageMtime} from '../services/content.js';
@@ -9,6 +11,10 @@ import {getProjectForPage, isProjectEnabled} from '../services/projects.js';
9
11
  import {renderPage} from '../services/renderer.js';
10
12
  import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
11
13
  import {checkVisibility} from '../middleware/auth.js';
14
+ import {resolveViewer} from '../services/viewerSession.js';
15
+ import {touchPreviewLink, verifyPreviewToken} from '../services/previewLinks.js';
16
+ import {getPermissionsFor} from '../services/roles.js';
17
+ import {getEffectiveRoles} from '../services/userRoles.js';
12
18
  import {hooks} from '../services/hooks.js';
13
19
  import {getConfig} from '../config.js';
14
20
  import * as cache from '../services/cache/index.js';
@@ -76,6 +82,61 @@ export async function publicRoutes(fastify) {
76
82
  return reply.type('text/plain').send(buildRobotsTxt(getBaseUrl(request)));
77
83
  });
78
84
 
85
+ // Share-link preview: one signed token, one page, no account needed.
86
+ //
87
+ // The token rides in the query string, not the path: Fastify caps a route
88
+ // parameter at 100 characters by default and a signed token is three times
89
+ // that, so `/_preview/:token` silently fell through to the catch-all and
90
+ // 404'd. Raising maxParamLength would fix it only for instances that
91
+ // remember to set it - tests and embedders included - so the query string
92
+ // is the form that cannot come apart.
93
+ //
94
+ // The token names the page, so a holder cannot repoint the link; the
95
+ // server-side index means it can be withdrawn after the fact.
96
+ fastify.get('/_preview', async (request, reply) => {
97
+ // Referrer-Policy matters more here than anywhere else on the site: the
98
+ // credential is IN the URL, so a default referrer would hand the whole
99
+ // token to every third-party asset the page loads.
100
+ reply.header('referrer-policy', 'no-referrer');
101
+ reply.header('cache-control', 'no-store, private');
102
+ reply.header('x-robots-tag', 'noindex, nofollow');
103
+
104
+ const link = await verifyPreviewToken((request.query || {}).token, fastify.jwt);
105
+ if (!link) {
106
+ reply.status(404);
107
+ return reply.type('text/html').send(await render404('/_preview'));
108
+ }
109
+
110
+ const page = await getPage(link.urlPath);
111
+ if (!page) {
112
+ reply.status(404);
113
+ return reply.type('text/html').send(await render404(link.urlPath));
114
+ }
115
+
116
+ // A disabled project stays a hard kill switch. A share link narrows who
117
+ // may see one page; it is not a way around taking a whole project down.
118
+ const project = await getProjectForPage(page.urlPath || link.urlPath, page.project);
119
+ if (!(await isProjectEnabled(project))) {
120
+ reply.status(404);
121
+ return reply.type('text/html').send(await render404(link.urlPath));
122
+ }
123
+
124
+ // Rendered as anonymous on purpose: the recipient has no account, so
125
+ // menus and role-gated shortcodes must show them the least, not the
126
+ // most. The token satisfies this page's own gate and nothing else.
127
+ await touchPreviewLink(link.id);
128
+ const html = await renderPage(page, {
129
+ baseUrl: getBaseUrl(request),
130
+ user: null,
131
+ preview: {
132
+ status: page.status || 'draft',
133
+ urlPath: page.urlPath || link.urlPath,
134
+ shared: {expiresAt: link.expiresAt, label: link.label}
135
+ }
136
+ });
137
+ return reply.type('text/html').send(html);
138
+ });
139
+
79
140
  // Public pages catch-all
80
141
  fastify.get('/*', async (request, reply) => {
81
142
  const rawPath = request.params['*'];
@@ -103,12 +164,6 @@ export async function publicRoutes(fastify) {
103
164
  return reply.type('text/html').send(await render404(urlPath));
104
165
  }
105
166
 
106
- // Don't serve draft pages publicly
107
- if (page.status !== 'published') {
108
- reply.status(404);
109
- return reply.type('text/html').send(await render404(urlPath));
110
- }
111
-
112
167
  // Hard kill-switch: a disabled project takes its pages off the public
113
168
  // site entirely - 404 (no oracle), regardless of page visibility.
114
169
  const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
@@ -117,14 +172,21 @@ export async function publicRoutes(fastify) {
117
172
  return reply.type('text/html').send(await render404(urlPath));
118
173
  }
119
174
 
175
+ // Draft preview: a page that is not published stays invisible to the
176
+ // public, but is served in full to a signed-in user who could read it
177
+ // in the admin anyway. Everyone else gets the same 404 as before - the
178
+ // response must not reveal that an unpublished page exists there.
179
+ const isDraft = page.status !== 'published';
180
+
120
181
  // Enforce page visibility - role only resolved for gated pages,
121
182
  // so public pages share a single cache entry keyed `roleanon`.
122
183
  //
123
184
  // `visibility` may be a string ('public' | 'private' | role name) or
124
185
  // an array of role names - see checkVisibility() for full semantics.
125
186
  // Effectively-public values (missing, 'public', or an array containing
126
- // 'public') skip JWT verification entirely so anonymous traffic hits
127
- // the shared cache without auth cost.
187
+ // 'public') skip viewer resolution entirely so anonymous traffic hits
188
+ // the shared cache without auth cost - unless the page is a draft,
189
+ // which needs the viewer regardless of its visibility.
128
190
  // For per-role cache keying we use the primary role only - multi-role
129
191
  // users still see correctly-gated content (checkVisibility consults
130
192
  // additionalRoles too) but the cache key stays bounded to one entry
@@ -139,45 +201,74 @@ export async function publicRoutes(fastify) {
139
201
  || vis === 'public'
140
202
  || (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
141
203
 
142
- if (!isPublic) {
143
- try {
144
- const decoded = await request.jwtVerify();
145
- if (decoded.type === 'access') {
146
- userRole = decoded.role;
147
- userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
148
- }
149
- } catch { /* no token - treat as unauthenticated */ }
150
-
151
- if (!checkVisibility(userObj, vis)) {
152
- reply.status(403);
153
- return reply.type('text/html').send(accessDeniedHtml(urlPath));
154
- }
204
+ // A draft always needs the viewer resolved, whatever its visibility.
205
+ if (isDraft || !isPublic) {
206
+ userObj = await resolveViewer(request);
207
+ userRole = userObj ? userObj.role : null;
208
+ }
209
+
210
+ if (isDraft && !canPreviewDrafts(userObj)) {
211
+ reply.status(404);
212
+ return reply.type('text/html').send(await render404(urlPath));
213
+ }
214
+
215
+ if (!isPublic && !checkVisibility(userObj, vis)) {
216
+ reply.status(403);
217
+ return reply.type('text/html').send(accessDeniedHtml(urlPath));
155
218
  }
156
219
 
157
220
  const baseUrl = getBaseUrl(request);
221
+
222
+ // Re-parse with user context so the body's [menu] shortcode sees the
223
+ // visitor's role for visibility filtering. The first getPage() above
224
+ // was anonymous because we hadn't resolved the viewer yet.
225
+ const render = async () => {
226
+ const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
227
+ return renderPage(pageForRole, {
228
+ baseUrl,
229
+ user: userObj,
230
+ ...(isDraft && {preview: {status: page.status || 'draft', urlPath: page.urlPath || urlPath, user: userObj}})
231
+ });
232
+ };
233
+
234
+ // A draft render is private to one viewer and changes on every editor
235
+ // save. It must never enter the shared response cache - an entry
236
+ // written here would be served to the next anonymous visitor under
237
+ // the same key - and must never be indexed.
238
+ if (isDraft) {
239
+ reply.header('cache-control', 'no-store, private');
240
+ reply.header('x-robots-tag', 'noindex, nofollow');
241
+ return reply.type('text/html').send(await render());
242
+ }
243
+
158
244
  // mtime in the key: any change to the backing file - editor save,
159
245
  // script, git pull - yields a fresh entry even if no invalidation
160
246
  // hook fired. Stale-mtime entries age out via TTL/LRU.
161
247
  const pageMtime = await getPageMtime(page.urlPath || urlPath);
162
248
  const cacheKey = `page:${urlPath}:m${pageMtime}:role${userRole ?? 'anon'}:o${baseUrl}`;
163
249
  const cacheTags = [`page:${urlPath}`, ...(page.cacheTags || []), 'nav', 'site'];
164
- const html = await cache.wrap(
165
- cacheKey,
166
- async () => {
167
- // Re-parse with user context so the body's [menu] shortcode
168
- // sees the visitor's role for visibility filtering. The first
169
- // getPage() above was anonymous because we hadn't decoded the
170
- // JWT yet; this second pass uses the resolved userObj.
171
- const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
172
- return renderPage(pageForRole, {baseUrl, user: userObj});
173
- },
174
- {tags: cacheTags}
175
- );
250
+ const html = await cache.wrap(cacheKey, render, {tags: cacheTags});
176
251
  hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
177
252
  return reply.type('text/html').send(html);
178
253
  });
179
254
  }
180
255
 
256
+ /**
257
+ * May this viewer see unpublished pages on the public site?
258
+ *
259
+ * Gated on `pages.read` - the same permission that opens the Pages list in the
260
+ * admin. Anyone who can already read the draft's source there loses nothing by
261
+ * seeing it rendered, and nobody else gains anything.
262
+ *
263
+ * @param {object|null} user - Resolved viewer (`{role, additionalRoles}`) or null
264
+ * @returns {boolean}
265
+ */
266
+ function canPreviewDrafts(user) {
267
+ if (!user) return false;
268
+ const allowed = getPermissionsFor('pages', 'read');
269
+ return getEffectiveRoles(user).some(role => allowed.includes(role));
270
+ }
271
+
181
272
  /**
182
273
  * Render a 404 response - tries content/pages/404.md first, falls back to
183
274
  * a minimal inline page so the site theme is applied when possible.
@@ -76,6 +76,33 @@ export async function getPage(urlPath, opts = {}) {
76
76
  }
77
77
  }
78
78
 
79
+ /**
80
+ * Compose a page object from unsaved frontmatter and body, without touching
81
+ * disk.
82
+ *
83
+ * Deliberately runs the same `parseMarkdown` call as readPageFile() and
84
+ * returns the same shape, so the editor's full-page preview renders through
85
+ * the identical pipeline the public site would use on save. Anything that
86
+ * diverges here becomes a preview that lies.
87
+ *
88
+ * `urlPath` matters even for an unsaved page: `[menu location="…"]` and menu
89
+ * bindings resolve against it, so a preview of a page destined for
90
+ * /projects/x sees the menus that page will actually get.
91
+ *
92
+ * @param {string} urlPath
93
+ * @param {object} frontmatter
94
+ * @param {string} body
95
+ * @param {object} [opts]
96
+ * @param {object|null} [opts.user]
97
+ * @returns {Promise<object>} Same shape as getPage()
98
+ */
99
+ export async function buildPreviewPage(urlPath, frontmatter, body, opts = {}) {
100
+ const raw = serialiseMarkdown({...(frontmatter || {})}, body || '');
101
+ const {data, content, html, usedComponents, tags} =
102
+ await parseMarkdown(raw, {user: opts.user || null, urlPath});
103
+ return {...data, urlPath, content, html, usedComponents, cacheTags: tags};
104
+ }
105
+
79
106
  /**
80
107
  * Create a new page. Auto-creates parent directories.
81
108
  *
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Preview Links
3
+ *
4
+ * Revocable, expiring, single-page share links for unpublished content. They
5
+ * exist for the reviewer who has no account: a client, an external designer,
6
+ * a copywriter. The holder of the link sees exactly one page and nothing else.
7
+ *
8
+ * Why a server-side index rather than a bare signed token: a JWT alone cannot
9
+ * be withdrawn. Once a link is out it is out until it expires, and "I sent that
10
+ * to the wrong address" is precisely the moment you need it gone. Every token
11
+ * carries a `jti` that must still be present and unrevoked in the index, so
12
+ * revocation is immediate.
13
+ *
14
+ * The index lives at content/preview-links.json and is small by construction -
15
+ * expired records are pruned whenever it is written.
16
+ */
17
+ import fs from 'fs/promises';
18
+ import path from 'path';
19
+ import crypto from 'node:crypto';
20
+ import {config} from '../config.js';
21
+
22
+ const STORE_PATH = path.join(config.content.contentDir, 'preview-links.json');
23
+
24
+ /** Token type claim - a page-preview token must never satisfy an API guard. */
25
+ export const PREVIEW_TOKEN_TYPE = 'page-preview';
26
+
27
+ /** Expiry choices offered to the issuer, in seconds. */
28
+ export const EXPIRY_CHOICES = {
29
+ '1h': 3600,
30
+ '24h': 86_400,
31
+ '7d': 604_800,
32
+ '30d': 2_592_000
33
+ };
34
+
35
+ const DEFAULT_EXPIRY = EXPIRY_CHOICES['7d'];
36
+ const MAX_EXPIRY = EXPIRY_CHOICES['30d'];
37
+
38
+ /**
39
+ * Read the link index. Missing or corrupt file yields an empty index rather
40
+ * than throwing - a broken store must not take the public site down, it must
41
+ * only mean no link validates.
42
+ *
43
+ * @returns {Promise<object[]>}
44
+ */
45
+ async function readStore() {
46
+ try {
47
+ const raw = await fs.readFile(STORE_PATH, 'utf8');
48
+ const parsed = JSON.parse(raw);
49
+ return Array.isArray(parsed) ? parsed : [];
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Write the index, dropping records that expired more than a day ago.
57
+ *
58
+ * @param {object[]} links
59
+ * @returns {Promise<void>}
60
+ */
61
+ async function writeStore(links) {
62
+ const cutoff = Date.now() - 86_400_000;
63
+ const kept = links.filter(l => new Date(l.expiresAt).getTime() > cutoff);
64
+ await fs.mkdir(path.dirname(STORE_PATH), {recursive: true});
65
+ await fs.writeFile(STORE_PATH, JSON.stringify(kept, null, 2), 'utf8');
66
+ }
67
+
68
+ /**
69
+ * Normalise a requested lifetime to a whole number of seconds within bounds.
70
+ *
71
+ * @param {string|number} [requested] - A key of EXPIRY_CHOICES or seconds
72
+ * @returns {number}
73
+ */
74
+ export function resolveExpirySeconds(requested) {
75
+ if (typeof requested === 'string' && requested in EXPIRY_CHOICES) {
76
+ return EXPIRY_CHOICES[requested];
77
+ }
78
+ const n = Number(requested);
79
+ if (Number.isFinite(n) && n > 0) return Math.min(Math.floor(n), MAX_EXPIRY);
80
+ return DEFAULT_EXPIRY;
81
+ }
82
+
83
+ /**
84
+ * Mint a share link for one page.
85
+ *
86
+ * @param {object} params
87
+ * @param {string} params.urlPath - Page the link unlocks; nothing else
88
+ * @param {object} params.jwt - fastify.jwt
89
+ * @param {string} [params.label] - Free-text note for the issuer's own use
90
+ * @param {string} [params.createdBy] - Issuing user's id
91
+ * @param {string} [params.createdByName] - Issuing user's display name
92
+ * @param {string|number} [params.expiresIn]
93
+ * @returns {Promise<{id: string, token: string, urlPath: string, expiresAt: string, label: string}>}
94
+ */
95
+ export async function createPreviewLink({urlPath, jwt, label = '', createdBy = null, createdByName = '', expiresIn}) {
96
+ if (!urlPath || typeof urlPath !== 'string' || !urlPath.startsWith('/')) {
97
+ throw new Error('A urlPath beginning with / is required');
98
+ }
99
+ const seconds = resolveExpirySeconds(expiresIn);
100
+ const id = crypto.randomUUID();
101
+ const createdAt = new Date().toISOString();
102
+ const expiresAt = new Date(Date.now() + seconds * 1000).toISOString();
103
+
104
+ // The page is named in the token, so a holder cannot repoint it, and the
105
+ // index is consulted on every use, so it can be withdrawn.
106
+ const token = jwt.sign({type: PREVIEW_TOKEN_TYPE, jti: id, urlPath}, {expiresIn: seconds});
107
+
108
+ const links = await readStore();
109
+ links.push({
110
+ id, urlPath, label: String(label || '').slice(0, 200),
111
+ createdBy, createdByName: String(createdByName || '').slice(0, 120),
112
+ createdAt, expiresAt, revokedAt: null, lastUsedAt: null, useCount: 0
113
+ });
114
+ await writeStore(links);
115
+
116
+ return {id, token, urlPath, expiresAt, label};
117
+ }
118
+
119
+ /**
120
+ * List links, newest first. Optionally filtered to one page.
121
+ *
122
+ * @param {string} [urlPath]
123
+ * @returns {Promise<object[]>}
124
+ */
125
+ export async function listPreviewLinks(urlPath) {
126
+ const links = await readStore();
127
+ const filtered = urlPath ? links.filter(l => l.urlPath === urlPath) : links;
128
+ return filtered
129
+ .map(l => ({...l, expired: new Date(l.expiresAt).getTime() <= Date.now()}))
130
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
131
+ }
132
+
133
+ /**
134
+ * Revoke a link. Idempotent; returns false when the id is unknown.
135
+ *
136
+ * @param {string} id
137
+ * @returns {Promise<boolean>}
138
+ */
139
+ export async function revokePreviewLink(id) {
140
+ const links = await readStore();
141
+ const link = links.find(l => l.id === id);
142
+ if (!link) return false;
143
+ link.revokedAt = link.revokedAt || new Date().toISOString();
144
+ await writeStore(links);
145
+ return true;
146
+ }
147
+
148
+ /**
149
+ * Verify a share token and return the page it unlocks.
150
+ *
151
+ * Every check is a separate reason to refuse: a bad signature, the wrong token
152
+ * type (an access token must not double as a share link, nor the reverse), an
153
+ * expired `exp`, an unknown `jti`, a revoked record, an index expiry that has
154
+ * passed, or a token whose `urlPath` no longer matches the record it names.
155
+ *
156
+ * @param {string} token
157
+ * @param {object} jwt - fastify.jwt
158
+ * @returns {Promise<{urlPath: string, id: string, expiresAt: string, label: string}|null>}
159
+ */
160
+ export async function verifyPreviewToken(token, jwt) {
161
+ if (!token || typeof token !== 'string') return null;
162
+
163
+ let payload;
164
+ try {
165
+ payload = jwt.verify(token);
166
+ } catch {
167
+ return null;
168
+ }
169
+ if (payload?.type !== PREVIEW_TOKEN_TYPE || !payload.jti || !payload.urlPath) return null;
170
+
171
+ const links = await readStore();
172
+ const link = links.find(l => l.id === payload.jti);
173
+ if (!link) return null;
174
+ if (link.revokedAt) return null;
175
+ if (link.urlPath !== payload.urlPath) return null;
176
+ if (new Date(link.expiresAt).getTime() <= Date.now()) return null;
177
+
178
+ return {urlPath: link.urlPath, id: link.id, expiresAt: link.expiresAt, label: link.label || ''};
179
+ }
180
+
181
+ /**
182
+ * Record a use of a link. Best-effort - a failure to write the counter must
183
+ * never deny an otherwise valid preview.
184
+ *
185
+ * @param {string} id
186
+ * @returns {Promise<void>}
187
+ */
188
+ export async function touchPreviewLink(id) {
189
+ try {
190
+ const links = await readStore();
191
+ const link = links.find(l => l.id === id);
192
+ if (!link) return;
193
+ link.lastUsedAt = new Date().toISOString();
194
+ link.useCount = (link.useCount || 0) + 1;
195
+ await writeStore(links);
196
+ } catch { /* counters are not load-bearing */ }
197
+ }
198
+
199
+ /**
200
+ * Drop every link for a page - used when the page is deleted or renamed, so a
201
+ * link cannot outlive the thing it pointed at.
202
+ *
203
+ * @param {string} urlPath
204
+ * @returns {Promise<number>} how many were removed
205
+ */
206
+ export async function deletePreviewLinksForPage(urlPath) {
207
+ const links = await readStore();
208
+ const remaining = links.filter(l => l.urlPath !== urlPath);
209
+ const removed = links.length - remaining.length;
210
+ if (removed) await writeStore(remaining);
211
+ return removed;
212
+ }
213
+
214
+ /** Absolute path of the backing store - exported for tests. */
215
+ export const storePath = STORE_PATH;