domma-cms 0.49.2 → 0.50.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 +7 -0
- package/package.json +1 -1
- package/server/routes/api/auth.js +26 -1
- package/server/routes/public.js +69 -34
- package/server/services/renderer.js +92 -0
- package/server/services/viewerSession.js +128 -0
package/CLAUDE.md
CHANGED
|
@@ -156,6 +156,8 @@ Menus live in `config/menus/<slug>.json`. The slot → menu map lives in `config
|
|
|
156
156
|
|
|
157
157
|
**Floating panel surface.** `float` also takes `radius`, `shadow` (`none|sm|md|lg|xl` → `--dm-shadow-*`), `accent` (preset key or hex → border tint + leading-edge stripe via `--dm-menu-accent`) and `opacity` (20-100 → `--dm-menu-panel-opacity`, blurred translucent surface). `floatToCss` exists twice - `server/services/menus.js` and `public/js/menu-decor.mjs` - and `tests/services/menus-float.test.js` asserts the copies agree.
|
|
158
158
|
|
|
159
|
+
**Menus render on TWO paths - test both.** `buildMenuNav`/`buildFloatChrome` (server) draw the `[menu]` shortcode and overlay panels; the **public navbar is built in the browser** by Domma from `window.__CMS_NAV__` and never touches that code, so anything added to the server markup is simply absent there. `applyFloat` in `menu-decor.mjs` is the only channel to the client path - it stamps `data-float-*` attributes and `menu-float.js` builds the chrome from them. Fixed in 0.49.1 after `collapsible`/`movable` shipped dead on a floating navbar. Navbar CSS also needs `#site-navbar`-level specificity: `#site-navbar.dm-nav-vertical .navbar-container` outranks any class-only rule.
|
|
160
|
+
|
|
159
161
|
**Floating panel behaviour.** `float` also takes `collapsible`, `collapsed`, `movable`, `resizable` and `title`. Any of the first two or `movable` makes `buildMenuNav` draw a chrome bar (grip + title + hamburger) via `buildFloatChrome`; `public/js/menu-float.js` (loaded from `page.html`, and called by `site.js` for the floating navbar) wires collapse/drag/resize. **Visitor state beats config**: position, size and collapsed state live in the visitor's own `S` storage under `dm_menu_float_<slug>` and are restored over `float` on load; double-clicking the bar clears them. A panel only stops tracking its `anchor` once actually moved or resized (`unanchor()` converts right/bottom/transform pinning to left/top on first interaction), so untouched panels stay responsive. `floatBehaviour` is the third function duplicated across `menus.js` / `menu-decor.mjs`, parity-tested alongside `floatToCss`. Below 768px site.css returns panels to the flow, so only collapse applies there.
|
|
160
162
|
|
|
161
163
|
## Admin sidebar (menu-data-driven)
|
|
@@ -212,6 +214,11 @@ holds `compareValues`/`sortEntries` - the same comparison ladder backs both the
|
|
|
212
214
|
than 32 characters. Set it in `.env`.
|
|
213
215
|
10. **Page visibility enforcement**: `server/routes/public.js` checks `page.visibility` against the visitor's role
|
|
214
216
|
level - unauthenticated visitors cannot access private or role-restricted pages.
|
|
217
|
+
**Draft preview**: `status: draft` still 404s the public, but a viewer holding `pages.read` gets the real page plus
|
|
218
|
+
a banner (`buildDraftBanner` in `renderer.js`, passed via `renderPage(page, {preview})`). Draft renders bypass the
|
|
219
|
+
response cache and carry `no-store` + `noindex` - never route one through `cache.wrap`. Browser navigations
|
|
220
|
+
authenticate via the `dm_session` cookie (`server/services/viewerSession.js`), which is read **only** by the public
|
|
221
|
+
page renderer; `authenticate()` stays Bearer-only, so no write route accepts ambient credentials.
|
|
215
222
|
11. **404 page**: create `content/pages/404.md` to customise the not-found response; it is auto-served on missing routes.
|
|
216
223
|
|
|
217
224
|
## Docs
|
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
validatePassword
|
|
27
27
|
} from '../../services/users.js';
|
|
28
28
|
import {getProfile, updateProfile} from '../../services/userProfiles.js';
|
|
29
|
+
import {buildClearedSessionCookie, buildSessionCookie} from '../../services/viewerSession.js';
|
|
29
30
|
import {createTransport, sendEmail} from '../../services/email.js';
|
|
30
31
|
|
|
31
32
|
const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
|
|
@@ -111,6 +112,7 @@ export async function authRoutes(fastify) {
|
|
|
111
112
|
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
|
|
112
113
|
hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
|
|
113
114
|
const { token, refreshToken } = signTokens(fastify, safeUser);
|
|
115
|
+
setSessionCookie(request, reply, token);
|
|
114
116
|
return { token, refreshToken, user: safeUser };
|
|
115
117
|
});
|
|
116
118
|
|
|
@@ -238,9 +240,10 @@ export async function authRoutes(fastify) {
|
|
|
238
240
|
});
|
|
239
241
|
|
|
240
242
|
// POST /api/auth/logout - blacklists the refresh token (fire-and-forget safe: no auth required)
|
|
241
|
-
fastify.post('/auth/logout', async (request) => {
|
|
243
|
+
fastify.post('/auth/logout', async (request, reply) => {
|
|
242
244
|
const {refreshToken} = request.body || {};
|
|
243
245
|
if (refreshToken) blacklistedRefreshTokens.add(refreshToken);
|
|
246
|
+
reply.header('set-cookie', buildClearedSessionCookie({secure: request.protocol === 'https'}));
|
|
244
247
|
return {ok: true};
|
|
245
248
|
});
|
|
246
249
|
|
|
@@ -273,10 +276,32 @@ export async function authRoutes(fastify) {
|
|
|
273
276
|
|
|
274
277
|
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
|
|
275
278
|
const token = fastify.jwt.sign({ ...safeUser, type: 'access' }, { expiresIn: accessTokenExpiry });
|
|
279
|
+
setSessionCookie(request, reply, token);
|
|
276
280
|
return { token };
|
|
277
281
|
});
|
|
278
282
|
}
|
|
279
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Attach the access token to the response as a session cookie.
|
|
286
|
+
*
|
|
287
|
+
* The SPA already stores the token in `S` for its Bearer calls; the cookie
|
|
288
|
+
* exists purely so a *browser navigation* to a public page can be attributed
|
|
289
|
+
* to a signed-in user (draft previews, role-gated pages). It is read only by
|
|
290
|
+
* the public page renderer - never by an API guard - so it grants no ambient
|
|
291
|
+
* authority over any write route. See services/viewerSession.js.
|
|
292
|
+
*
|
|
293
|
+
* @param {import('fastify').FastifyRequest} request
|
|
294
|
+
* @param {import('fastify').FastifyReply} reply
|
|
295
|
+
* @param {string} token
|
|
296
|
+
* @returns {void}
|
|
297
|
+
*/
|
|
298
|
+
function setSessionCookie(request, reply, token) {
|
|
299
|
+
reply.header('set-cookie', buildSessionCookie(token, {
|
|
300
|
+
secure: request.protocol === 'https',
|
|
301
|
+
maxAgeMs: parseDuration(accessTokenExpiry)
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
|
|
280
305
|
/**
|
|
281
306
|
* Parse a duration string into milliseconds.
|
|
282
307
|
* Supports "Nm" (minutes), "Nh" (hours), "Nd" (days).
|
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,9 @@ 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 {getPermissionsFor} from '../services/roles.js';
|
|
16
|
+
import {getEffectiveRoles} from '../services/userRoles.js';
|
|
12
17
|
import {hooks} from '../services/hooks.js';
|
|
13
18
|
import {getConfig} from '../config.js';
|
|
14
19
|
import * as cache from '../services/cache/index.js';
|
|
@@ -103,12 +108,6 @@ export async function publicRoutes(fastify) {
|
|
|
103
108
|
return reply.type('text/html').send(await render404(urlPath));
|
|
104
109
|
}
|
|
105
110
|
|
|
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
111
|
// Hard kill-switch: a disabled project takes its pages off the public
|
|
113
112
|
// site entirely - 404 (no oracle), regardless of page visibility.
|
|
114
113
|
const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
|
|
@@ -117,14 +116,21 @@ export async function publicRoutes(fastify) {
|
|
|
117
116
|
return reply.type('text/html').send(await render404(urlPath));
|
|
118
117
|
}
|
|
119
118
|
|
|
119
|
+
// Draft preview: a page that is not published stays invisible to the
|
|
120
|
+
// public, but is served in full to a signed-in user who could read it
|
|
121
|
+
// in the admin anyway. Everyone else gets the same 404 as before - the
|
|
122
|
+
// response must not reveal that an unpublished page exists there.
|
|
123
|
+
const isDraft = page.status !== 'published';
|
|
124
|
+
|
|
120
125
|
// Enforce page visibility - role only resolved for gated pages,
|
|
121
126
|
// so public pages share a single cache entry keyed `roleanon`.
|
|
122
127
|
//
|
|
123
128
|
// `visibility` may be a string ('public' | 'private' | role name) or
|
|
124
129
|
// an array of role names - see checkVisibility() for full semantics.
|
|
125
130
|
// Effectively-public values (missing, 'public', or an array containing
|
|
126
|
-
// 'public') skip
|
|
127
|
-
// the shared cache without auth cost
|
|
131
|
+
// 'public') skip viewer resolution entirely so anonymous traffic hits
|
|
132
|
+
// the shared cache without auth cost - unless the page is a draft,
|
|
133
|
+
// which needs the viewer regardless of its visibility.
|
|
128
134
|
// For per-role cache keying we use the primary role only - multi-role
|
|
129
135
|
// users still see correctly-gated content (checkVisibility consults
|
|
130
136
|
// additionalRoles too) but the cache key stays bounded to one entry
|
|
@@ -139,45 +145,74 @@ export async function publicRoutes(fastify) {
|
|
|
139
145
|
|| vis === 'public'
|
|
140
146
|
|| (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
|
|
141
147
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
// A draft always needs the viewer resolved, whatever its visibility.
|
|
149
|
+
if (isDraft || !isPublic) {
|
|
150
|
+
userObj = await resolveViewer(request);
|
|
151
|
+
userRole = userObj ? userObj.role : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (isDraft && !canPreviewDrafts(userObj)) {
|
|
155
|
+
reply.status(404);
|
|
156
|
+
return reply.type('text/html').send(await render404(urlPath));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!isPublic && !checkVisibility(userObj, vis)) {
|
|
160
|
+
reply.status(403);
|
|
161
|
+
return reply.type('text/html').send(accessDeniedHtml(urlPath));
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
const baseUrl = getBaseUrl(request);
|
|
165
|
+
|
|
166
|
+
// Re-parse with user context so the body's [menu] shortcode sees the
|
|
167
|
+
// visitor's role for visibility filtering. The first getPage() above
|
|
168
|
+
// was anonymous because we hadn't resolved the viewer yet.
|
|
169
|
+
const render = async () => {
|
|
170
|
+
const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
|
|
171
|
+
return renderPage(pageForRole, {
|
|
172
|
+
baseUrl,
|
|
173
|
+
user: userObj,
|
|
174
|
+
...(isDraft && {preview: {status: page.status || 'draft', urlPath: page.urlPath || urlPath, user: userObj}})
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// A draft render is private to one viewer and changes on every editor
|
|
179
|
+
// save. It must never enter the shared response cache - an entry
|
|
180
|
+
// written here would be served to the next anonymous visitor under
|
|
181
|
+
// the same key - and must never be indexed.
|
|
182
|
+
if (isDraft) {
|
|
183
|
+
reply.header('cache-control', 'no-store, private');
|
|
184
|
+
reply.header('x-robots-tag', 'noindex, nofollow');
|
|
185
|
+
return reply.type('text/html').send(await render());
|
|
186
|
+
}
|
|
187
|
+
|
|
158
188
|
// mtime in the key: any change to the backing file - editor save,
|
|
159
189
|
// script, git pull - yields a fresh entry even if no invalidation
|
|
160
190
|
// hook fired. Stale-mtime entries age out via TTL/LRU.
|
|
161
191
|
const pageMtime = await getPageMtime(page.urlPath || urlPath);
|
|
162
192
|
const cacheKey = `page:${urlPath}:m${pageMtime}:role${userRole ?? 'anon'}:o${baseUrl}`;
|
|
163
193
|
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
|
-
);
|
|
194
|
+
const html = await cache.wrap(cacheKey, render, {tags: cacheTags});
|
|
176
195
|
hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
|
|
177
196
|
return reply.type('text/html').send(html);
|
|
178
197
|
});
|
|
179
198
|
}
|
|
180
199
|
|
|
200
|
+
/**
|
|
201
|
+
* May this viewer see unpublished pages on the public site?
|
|
202
|
+
*
|
|
203
|
+
* Gated on `pages.read` - the same permission that opens the Pages list in the
|
|
204
|
+
* admin. Anyone who can already read the draft's source there loses nothing by
|
|
205
|
+
* seeing it rendered, and nobody else gains anything.
|
|
206
|
+
*
|
|
207
|
+
* @param {object|null} user - Resolved viewer (`{role, additionalRoles}`) or null
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
function canPreviewDrafts(user) {
|
|
211
|
+
if (!user) return false;
|
|
212
|
+
const allowed = getPermissionsFor('pages', 'read');
|
|
213
|
+
return getEffectiveRoles(user).some(role => allowed.includes(role));
|
|
214
|
+
}
|
|
215
|
+
|
|
181
216
|
/**
|
|
182
217
|
* Render a 404 response - tries content/pages/404.md first, falls back to
|
|
183
218
|
* a minimal inline page so the site theme is applied when possible.
|
|
@@ -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,91 @@ 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
|
+
* @param {{status?: string, urlPath?: string, user?: object|null}} preview
|
|
453
|
+
* @returns {string}
|
|
454
|
+
*/
|
|
455
|
+
function buildDraftBanner(preview) {
|
|
456
|
+
const urlPath = preview.urlPath || '/';
|
|
457
|
+
const status = preview.status || 'draft';
|
|
458
|
+
const user = preview.user || {};
|
|
459
|
+
const who = escapeHtml(user.name || user.email || 'an authorised user');
|
|
460
|
+
const role = escapeHtml(user.role || '');
|
|
461
|
+
const editHref = '/admin/#/pages/edit' + escapeHtml(urlPath);
|
|
462
|
+
const pathAttr = escapeHtml(urlPath);
|
|
463
|
+
const label = escapeHtml(status.charAt(0).toUpperCase() + status.slice(1));
|
|
464
|
+
|
|
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">×</button>
|
|
490
|
+
</span>
|
|
491
|
+
<script>
|
|
492
|
+
(function(){
|
|
493
|
+
var bar = document.getElementById('dm-draft-banner');
|
|
494
|
+
if (!bar) return;
|
|
495
|
+
var path = bar.getAttribute('data-url-path') || '/';
|
|
496
|
+
bar.querySelector('[data-dmdb-close]').addEventListener('click', function(){ bar.hidden = true; });
|
|
497
|
+
bar.querySelector('[data-dmdb-publish]').addEventListener('click', function(){
|
|
498
|
+
var btn = this;
|
|
499
|
+
var token = '';
|
|
500
|
+
try { token = (window.S && S.get('auth_token')) || ''; } catch (e) { token = ''; }
|
|
501
|
+
if (!token) { window.location.href = '/admin/'; return; }
|
|
502
|
+
if (!window.confirm('Publish this page? It becomes visible to everyone.')) return;
|
|
503
|
+
btn.disabled = true;
|
|
504
|
+
btn.textContent = 'Publishing…';
|
|
505
|
+
fetch('/api/pages' + path, {
|
|
506
|
+
method: 'PUT',
|
|
507
|
+
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token},
|
|
508
|
+
body: JSON.stringify({frontmatter: {status: 'published'}})
|
|
509
|
+
}).then(function(res){
|
|
510
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
511
|
+
window.location.reload();
|
|
512
|
+
}).catch(function(){
|
|
513
|
+
btn.disabled = false;
|
|
514
|
+
btn.textContent = 'Publish';
|
|
515
|
+
window.alert('Could not publish. Try again from the admin editor.');
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
})();
|
|
519
|
+
</script>
|
|
520
|
+
</div>`;
|
|
521
|
+
}
|
|
522
|
+
|
|
431
523
|
function escapeHtml(str) {
|
|
432
524
|
return String(str)
|
|
433
525
|
.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
|
+
}
|