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
|
@@ -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">×</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, '&')
|
|
@@ -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
|
+
}
|