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.
@@ -85,6 +85,9 @@ async function buildOverlayMenus(menuCtx, user) {
85
85
  * Falls back to `site.baseUrl` from config, then to no canonical at all.
86
86
  * @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
87
87
  * or null for anonymous. Used to filter menu items gated by `visibility`.
88
+ * @param {object} [opts.preview] - Set when rendering an unpublished page for a
89
+ * permitted viewer: `{status, urlPath, user}`. Draws the draft banner. Absent
90
+ * for every normal public render.
88
91
  * @returns {Promise<string>}
89
92
  */
90
93
  export async function renderPage(page, opts = {}) {
@@ -247,6 +250,10 @@ export async function renderPage(page, opts = {}) {
247
250
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
248
251
  headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
249
252
  bodyEndInject: [
253
+ // Draft banner - first, so it is the topmost fixed element and a
254
+ // floating overlay menu cannot bury the only affordance telling
255
+ // the viewer this page is not live.
256
+ opts.preview ? buildDraftBanner(opts.preview) : '',
250
257
  // Overlay menus - bound to the `overlay` slot and pinned by their own
251
258
  // float config, so they belong at the end of the body, clear of the
252
259
  // content flow.
@@ -428,6 +435,129 @@ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage})
428
435
  return tags.join('\n ');
429
436
  }
430
437
 
438
+ /**
439
+ * Build the draft banner shown over an unpublished page.
440
+ *
441
+ * Self-contained on purpose - its CSS and JS are inline rather than in
442
+ * public/css/site.css, because the updater replaces public/ wholesale and a
443
+ * site running a stale minified stylesheet would render the banner unstyled,
444
+ * i.e. as an invisible warning. It is also why nothing here depends on a
445
+ * Domma component: the banner has to work on a page whose CSS is broken,
446
+ * since verifying broken CSS is exactly what it is for.
447
+ *
448
+ * The Publish button authenticates with the Bearer token from `S` storage,
449
+ * never with the session cookie - keeping every state-changing call
450
+ * explicitly credentialled and the cookie free of write authority.
451
+ *
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
458
+ * @returns {string}
459
+ */
460
+ function buildDraftBanner(preview) {
461
+ const urlPath = preview.urlPath || '/';
462
+ const status = preview.status || 'draft';
463
+ const user = preview.user || {};
464
+ const shared = preview.shared || null;
465
+ const who = escapeHtml(user.name || user.email || 'an authorised user');
466
+ const role = escapeHtml(user.role || '');
467
+ const editHref = '/admin/#/pages/edit' + escapeHtml(urlPath);
468
+ const pathAttr = escapeHtml(urlPath);
469
+ const label = escapeHtml(shared ? 'Preview' : status.charAt(0).toUpperCase() + status.slice(1));
470
+
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>
489
+ (function(){
490
+ var bar = document.getElementById('dm-draft-banner');
491
+ if (!bar) return;
492
+ var path = bar.getAttribute('data-url-path') || '/';
493
+ bar.querySelector('[data-dmdb-close]').addEventListener('click', function(){ bar.hidden = true; });
494
+ bar.querySelector('[data-dmdb-publish]').addEventListener('click', function(){
495
+ var btn = this;
496
+ var token = '';
497
+ try { token = (window.S && S.get('auth_token')) || ''; } catch (e) { token = ''; }
498
+ if (!token) { window.location.href = '/admin/'; return; }
499
+ if (!window.confirm('Publish this page? It becomes visible to everyone.')) return;
500
+ btn.disabled = true;
501
+ btn.textContent = 'Publishing\u2026';
502
+ fetch('/api/pages' + path, {
503
+ method: 'PUT',
504
+ headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token},
505
+ body: JSON.stringify({frontmatter: {status: 'published'}})
506
+ }).then(function(res){
507
+ if (!res.ok) throw new Error('HTTP ' + res.status);
508
+ window.location.reload();
509
+ }).catch(function(){
510
+ btn.disabled = false;
511
+ btn.textContent = 'Publish';
512
+ window.alert('Could not publish. Try again from the admin editor.');
513
+ });
514
+ });
515
+ })();
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}
544
+ </div>`;
545
+ }
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
+
431
561
  function escapeHtml(str) {
432
562
  return String(str)
433
563
  .replace(/&/g, '&amp;')
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Viewer Session
3
+ *
4
+ * Resolves the human behind a *public page navigation*.
5
+ *
6
+ * The API authenticates with `Authorization: Bearer <jwt>` and the client keeps
7
+ * that token in `S` storage - fine for `fetch`, useless for a browser typing a
8
+ * URL into the address bar, which sends no such header. Draft previews and
9
+ * role-gated pages both need to know who is looking during a plain navigation,
10
+ * so login additionally drops the access token into a session cookie and this
11
+ * module reads it back.
12
+ *
13
+ * SCOPE - read this before reusing anything here:
14
+ * The cookie is consulted ONLY by the public page renderer (GET, no side
15
+ * effects). `authenticate()` in middleware/auth.js stays Bearer-only, so no
16
+ * state-changing route is reachable with ambient credentials and the CSRF
17
+ * surface stays at zero. Do not wire `resolveViewer()` into a write path.
18
+ */
19
+
20
+ /** Name of the session cookie carrying the access token. */
21
+ export const SESSION_COOKIE = 'dm_session';
22
+
23
+ /**
24
+ * Parse a Cookie request header into a plain object.
25
+ *
26
+ * @param {string} [header]
27
+ * @returns {Record<string, string>}
28
+ */
29
+ function parseCookies(header) {
30
+ const out = {};
31
+ if (!header) return out;
32
+ for (const part of header.split(';')) {
33
+ const eq = part.indexOf('=');
34
+ if (eq < 0) continue;
35
+ const key = part.slice(0, eq).trim();
36
+ if (!key) continue;
37
+ try {
38
+ out[key] = decodeURIComponent(part.slice(eq + 1).trim());
39
+ } catch {
40
+ out[key] = part.slice(eq + 1).trim();
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Build the Set-Cookie value that carries an access token for page navigation.
48
+ *
49
+ * HttpOnly because nothing client-side needs to read it (the SPA keeps its own
50
+ * copy in `S`), SameSite=Lax so a link from an email or chat still arrives
51
+ * authenticated, and Secure only over https so localhost development works.
52
+ *
53
+ * @param {string} token - Signed access token
54
+ * @param {object} [opts]
55
+ * @param {boolean} [opts.secure] - Emit the Secure attribute
56
+ * @param {number} [opts.maxAgeMs]- Cookie lifetime; defaults to one hour
57
+ * @returns {string}
58
+ */
59
+ export function buildSessionCookie(token, {secure = false, maxAgeMs = 3_600_000} = {}) {
60
+ const attrs = [
61
+ `${SESSION_COOKIE}=${encodeURIComponent(token)}`,
62
+ 'Path=/',
63
+ 'HttpOnly',
64
+ 'SameSite=Lax',
65
+ `Max-Age=${Math.max(0, Math.floor(maxAgeMs / 1000))}`
66
+ ];
67
+ if (secure) attrs.push('Secure');
68
+ return attrs.join('; ');
69
+ }
70
+
71
+ /**
72
+ * Build the Set-Cookie value that removes the session cookie.
73
+ *
74
+ * @param {object} [opts]
75
+ * @param {boolean} [opts.secure]
76
+ * @returns {string}
77
+ */
78
+ export function buildClearedSessionCookie({secure = false} = {}) {
79
+ const attrs = [`${SESSION_COOKIE}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
80
+ if (secure) attrs.push('Secure');
81
+ return attrs.join('; ');
82
+ }
83
+
84
+ /**
85
+ * Read the raw session token from the request cookie, if present.
86
+ *
87
+ * @param {import('fastify').FastifyRequest} request
88
+ * @returns {string} '' when absent
89
+ */
90
+ export function readSessionToken(request) {
91
+ return parseCookies(request.headers?.cookie)[SESSION_COOKIE] || '';
92
+ }
93
+
94
+ /**
95
+ * Resolve the viewer of a public page from either credential channel.
96
+ *
97
+ * Tries the Bearer header first (so API-style and test clients keep working
98
+ * unchanged), then the session cookie. Anything that is not a valid, unexpired
99
+ * *access* token is ignored - a refresh token presented here resolves to
100
+ * anonymous rather than to its subject.
101
+ *
102
+ * @param {import('fastify').FastifyRequest} request
103
+ * @returns {Promise<{id?: string, name: string, email: string, role: string, additionalRoles: string[]}|null>}
104
+ */
105
+ export async function resolveViewer(request) {
106
+ const header = request.headers?.authorization || '';
107
+ const bearer = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
108
+ const jwt = request.server?.jwt;
109
+ if (!jwt) return null;
110
+
111
+ for (const raw of [bearer, readSessionToken(request)]) {
112
+ if (!raw) continue;
113
+ try {
114
+ const decoded = jwt.verify(raw);
115
+ if (decoded?.type !== 'access' || !decoded.role) continue;
116
+ return {
117
+ id: decoded.id,
118
+ name: decoded.name || '',
119
+ email: decoded.email || '',
120
+ role: decoded.role,
121
+ additionalRoles: Array.isArray(decoded.additionalRoles) ? decoded.additionalRoles : []
122
+ };
123
+ } catch {
124
+ // Malformed or expired - fall through to the next channel.
125
+ }
126
+ }
127
+ return null;
128
+ }