domma-cms 0.50.0 → 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.
@@ -12,6 +12,7 @@ import {renderPage} from '../services/renderer.js';
12
12
  import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
13
13
  import {checkVisibility} from '../middleware/auth.js';
14
14
  import {resolveViewer} from '../services/viewerSession.js';
15
+ import {touchPreviewLink, verifyPreviewToken} from '../services/previewLinks.js';
15
16
  import {getPermissionsFor} from '../services/roles.js';
16
17
  import {getEffectiveRoles} from '../services/userRoles.js';
17
18
  import {hooks} from '../services/hooks.js';
@@ -81,6 +82,61 @@ export async function publicRoutes(fastify) {
81
82
  return reply.type('text/plain').send(buildRobotsTxt(getBaseUrl(request)));
82
83
  });
83
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
+
84
140
  // Public pages catch-all
85
141
  fastify.get('/*', async (request, reply) => {
86
142
  const rawPath = request.params['*'];
@@ -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;
@@ -449,46 +449,43 @@ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage})
449
449
  * never with the session cookie - keeping every state-changing call
450
450
  * explicitly credentialled and the cookie free of write authority.
451
451
  *
452
- * @param {{status?: string, urlPath?: string, user?: object|null}} preview
452
+ * A share-link view (`preview.shared`) gets a different bar: its reader has no
453
+ * account, so Edit and Publish would only be doors they cannot open. They get
454
+ * the expiry instead, which is the thing they actually need to know.
455
+ *
456
+ * @param {{status?: string, urlPath?: string, user?: object|null,
457
+ * shared?: {expiresAt?: string, label?: string}}} preview
453
458
  * @returns {string}
454
459
  */
455
460
  function buildDraftBanner(preview) {
456
461
  const urlPath = preview.urlPath || '/';
457
462
  const status = preview.status || 'draft';
458
463
  const user = preview.user || {};
464
+ const shared = preview.shared || null;
459
465
  const who = escapeHtml(user.name || user.email || 'an authorised user');
460
466
  const role = escapeHtml(user.role || '');
461
467
  const editHref = '/admin/#/pages/edit' + escapeHtml(urlPath);
462
468
  const pathAttr = escapeHtml(urlPath);
463
- const label = escapeHtml(status.charAt(0).toUpperCase() + status.slice(1));
469
+ const label = escapeHtml(shared ? 'Preview' : status.charAt(0).toUpperCase() + status.slice(1));
464
470
 
465
- return `<div id="dm-draft-banner" role="status" aria-live="polite" data-url-path="${pathAttr}">
466
- <style>
467
- #dm-draft-banner{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;
468
- padding:.6rem .9rem;font:500 .8125rem/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;color:#1c1601;
469
- background:repeating-linear-gradient(135deg,#ffcf33,#ffcf33 14px,#f5b800 14px,#f5b800 28px);box-shadow:0 -2px 12px rgba(0,0,0,.28)}
470
- #dm-draft-banner[hidden]{display:none}
471
- #dm-draft-banner .dmdb-pill{flex:none;padding:.2rem .55rem;border-radius:999px;background:#1c1601;color:#ffcf33;
472
- font-weight:700;font-size:.6875rem;letter-spacing:.08em;text-transform:uppercase}
473
- #dm-draft-banner .dmdb-text{flex:1 1 16rem;min-width:0}
474
- #dm-draft-banner .dmdb-actions{flex:none;display:flex;align-items:center;gap:.4rem}
475
- #dm-draft-banner button,#dm-draft-banner a.dmdb-btn{font:inherit;cursor:pointer;border:1px solid rgba(28,22,1,.45);
476
- border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#1c1601;text-decoration:none}
477
- #dm-draft-banner button:hover,#dm-draft-banner a.dmdb-btn:hover{background:#fff}
478
- #dm-draft-banner button[disabled]{opacity:.55;cursor:default}
479
- #dm-draft-banner .dmdb-publish{background:#1c1601;color:#ffcf33;border-color:#1c1601}
480
- #dm-draft-banner .dmdb-publish:hover{background:#000;color:#ffe27a}
481
- #dm-draft-banner .dmdb-close{padding:.2rem .5rem;line-height:1;font-size:1rem;background:transparent;border-color:transparent}
482
- @media print{#dm-draft-banner{display:none}}
483
- </style>
484
- <span class="dmdb-pill">${label}</span>
485
- <span class="dmdb-text">Not published - the public gets a 404 here. You can see it because you are signed in as <strong>${who}</strong>${role ? ` (${role})` : ''}.</span>
486
- <span class="dmdb-actions">
487
- <a class="dmdb-btn" href="${editHref}">Edit page</a>
488
- <button type="button" class="dmdb-publish" data-dmdb-publish>Publish</button>
489
- <button type="button" class="dmdb-close" data-dmdb-close aria-label="Hide draft banner" title="Hide">&times;</button>
490
- </span>
491
- <script>
471
+ const message = shared
472
+ ? `Shared preview of an unpublished page.${formatExpiry(shared.expiresAt)} It is not visible to the public.`
473
+ : `Not published - the public gets a 404 here. You can see it because you are signed in as <strong>${who}</strong>${role ? ` (${role})` : ''}.`;
474
+
475
+ const actions = shared
476
+ ? ''
477
+ : `<a class="dmdb-btn" href="${editHref}">Edit page</a>
478
+ <button type="button" class="dmdb-publish" data-dmdb-publish>Publish</button>`;
479
+
480
+ const script = shared
481
+ ? `<script>
482
+ (function(){
483
+ var bar = document.getElementById('dm-draft-banner');
484
+ if (!bar) return;
485
+ bar.querySelector('[data-dmdb-close]').addEventListener('click', function(){ bar.hidden = true; });
486
+ })();
487
+ </script>`
488
+ : `<script>
492
489
  (function(){
493
490
  var bar = document.getElementById('dm-draft-banner');
494
491
  if (!bar) return;
@@ -501,7 +498,7 @@ border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#
501
498
  if (!token) { window.location.href = '/admin/'; return; }
502
499
  if (!window.confirm('Publish this page? It becomes visible to everyone.')) return;
503
500
  btn.disabled = true;
504
- btn.textContent = 'Publishing…';
501
+ btn.textContent = 'Publishing\u2026';
505
502
  fetch('/api/pages' + path, {
506
503
  method: 'PUT',
507
504
  headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token},
@@ -516,10 +513,51 @@ border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#
516
513
  });
517
514
  });
518
515
  })();
519
- </script>
516
+ </script>`;
517
+
518
+ return `<div id="dm-draft-banner" role="status" aria-live="polite" data-url-path="${pathAttr}">
519
+ <style>
520
+ #dm-draft-banner{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;
521
+ padding:.6rem .9rem;font:500 .8125rem/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;color:#1c1601;
522
+ background:repeating-linear-gradient(135deg,#ffcf33,#ffcf33 14px,#f5b800 14px,#f5b800 28px);box-shadow:0 -2px 12px rgba(0,0,0,.28)}
523
+ #dm-draft-banner[hidden]{display:none}
524
+ #dm-draft-banner .dmdb-pill{flex:none;padding:.2rem .55rem;border-radius:999px;background:#1c1601;color:#ffcf33;
525
+ font-weight:700;font-size:.6875rem;letter-spacing:.08em;text-transform:uppercase}
526
+ #dm-draft-banner .dmdb-text{flex:1 1 16rem;min-width:0}
527
+ #dm-draft-banner .dmdb-actions{flex:none;display:flex;align-items:center;gap:.4rem}
528
+ #dm-draft-banner button,#dm-draft-banner a.dmdb-btn{font:inherit;cursor:pointer;border:1px solid rgba(28,22,1,.45);
529
+ border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#1c1601;text-decoration:none}
530
+ #dm-draft-banner button:hover,#dm-draft-banner a.dmdb-btn:hover{background:#fff}
531
+ #dm-draft-banner button[disabled]{opacity:.55;cursor:default}
532
+ #dm-draft-banner .dmdb-publish{background:#1c1601;color:#ffcf33;border-color:#1c1601}
533
+ #dm-draft-banner .dmdb-publish:hover{background:#000;color:#ffe27a}
534
+ #dm-draft-banner .dmdb-close{padding:.2rem .5rem;line-height:1;font-size:1rem;background:transparent;border-color:transparent}
535
+ @media print{#dm-draft-banner{display:none}}
536
+ </style>
537
+ <span class="dmdb-pill">${label}</span>
538
+ <span class="dmdb-text">${message}</span>
539
+ <span class="dmdb-actions">
540
+ ${actions}
541
+ <button type="button" class="dmdb-close" data-dmdb-close aria-label="Hide preview banner" title="Hide">&times;</button>
542
+ </span>
543
+ ${script}
520
544
  </div>`;
521
545
  }
522
546
 
547
+ /**
548
+ * Render a share link's expiry as a sentence fragment, or '' when unknown.
549
+ *
550
+ * @param {string} [iso]
551
+ * @returns {string}
552
+ */
553
+ function formatExpiry(iso) {
554
+ if (!iso) return '';
555
+ const when = new Date(iso);
556
+ if (Number.isNaN(when.getTime())) return '';
557
+ const opts = {day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'};
558
+ return ` This link expires ${escapeHtml(when.toLocaleString('en-GB', opts))}.`;
559
+ }
560
+
523
561
  function escapeHtml(str) {
524
562
  return String(str)
525
563
  .replace(/&/g, '&amp;')