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.
- package/CLAUDE.md +13 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/page-editor.html +26 -1
- package/admin/js/views/page-editor.js +25 -25
- package/package.json +1 -1
- package/server/routes/api/auth.js +26 -1
- package/server/routes/api/pages.js +85 -1
- package/server/routes/public.js +125 -34
- package/server/services/content.js +27 -0
- package/server/services/previewLinks.js +215 -0
- package/server/services/renderer.js +130 -0
- package/server/services/viewerSession.js +128 -0
|
@@ -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
|
}
|
package/server/routes/public.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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;
|