domma-cms 0.50.0 → 0.52.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 +6 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/page-editor.html +35 -3
- package/admin/js/views/page-editor.js +26 -26
- package/package.json +1 -1
- package/plugins/analytics/public/inject-body.html +5 -0
- package/server/routes/api/pages.js +86 -1
- package/server/routes/public.js +56 -0
- package/server/services/content.js +27 -0
- package/server/services/previewLinks.js +215 -0
- package/server/services/renderer.js +79 -31
- package/server/templates/page.html +7 -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,74 @@ 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
|
+
noTrack: true
|
|
64
|
+
});
|
|
65
|
+
return {html};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// --- Share links --------------------------------------------------------
|
|
69
|
+
// Minting is gated on `pages.update`, not `pages.read`: handing an
|
|
70
|
+
// unauthenticated stranger a URL that renders unpublished content is an act
|
|
71
|
+
// of publishing, however narrow, and should sit with the people who could
|
|
72
|
+
// publish the page outright.
|
|
73
|
+
|
|
74
|
+
fastify.get('/pages/preview-links', canRead, async (request) => {
|
|
75
|
+
const {urlPath} = request.query || {};
|
|
76
|
+
return {links: await listPreviewLinks(urlPath || undefined)};
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
fastify.post('/pages/preview-links', canUpdate, async (request, reply) => {
|
|
80
|
+
const {urlPath, expiresIn, label} = request.body || {};
|
|
81
|
+
if (!urlPath || typeof urlPath !== 'string' || !urlPath.startsWith('/')) {
|
|
82
|
+
return reply.status(400).send({error: 'urlPath must begin with /'});
|
|
83
|
+
}
|
|
84
|
+
// Refuse to mint for a page that does not exist - otherwise a typo
|
|
85
|
+
// produces a link that 404s for the recipient and looks like a bug.
|
|
86
|
+
if (!(await getPage(urlPath))) {
|
|
87
|
+
return reply.status(404).send({error: 'Page not found'});
|
|
88
|
+
}
|
|
89
|
+
const link = await createPreviewLink({
|
|
90
|
+
urlPath,
|
|
91
|
+
jwt: fastify.jwt,
|
|
92
|
+
label,
|
|
93
|
+
expiresIn,
|
|
94
|
+
createdBy: request.user?.id || null,
|
|
95
|
+
createdByName: request.user?.name || ''
|
|
96
|
+
});
|
|
97
|
+
return reply.status(201).send({
|
|
98
|
+
...link,
|
|
99
|
+
url: `${request.protocol}://${request.host}/_preview?token=${encodeURIComponent(link.token)}`
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
fastify.delete('/pages/preview-links/:id', canUpdate, async (request, reply) => {
|
|
104
|
+
const ok = await revokePreviewLink(request.params.id);
|
|
105
|
+
if (!ok) return reply.status(404).send({error: 'Link not found'});
|
|
106
|
+
return {success: true};
|
|
107
|
+
});
|
|
108
|
+
|
|
29
109
|
// Aggregate unique tags from all pages - must be registered before the wildcard /pages/* route
|
|
30
110
|
fastify.get('/pages/tags', canRead, async (request, reply) => {
|
|
31
111
|
const pages = await listPages();
|
|
@@ -105,6 +185,9 @@ export async function pagesRoutes(fastify) {
|
|
|
105
185
|
|
|
106
186
|
await renamePage(urlPath, newUrlPath);
|
|
107
187
|
await rewriteNavLinks(urlPath, newUrlPath);
|
|
188
|
+
// Links name the old path in their signed payload, so they cannot
|
|
189
|
+
// follow the move - drop them rather than leave them 404ing.
|
|
190
|
+
await deletePreviewLinksForPage(urlPath);
|
|
108
191
|
|
|
109
192
|
const page = await updatePage(newUrlPath, frontmatter || {}, body, {author: request.user.username});
|
|
110
193
|
return page;
|
|
@@ -121,6 +204,8 @@ export async function pagesRoutes(fastify) {
|
|
|
121
204
|
if (!existing) return reply.status(404).send({ error: 'Page not found' });
|
|
122
205
|
|
|
123
206
|
await deletePage(urlPath);
|
|
207
|
+
// A share link must not outlive the page it pointed at.
|
|
208
|
+
await deletePreviewLinksForPage(urlPath);
|
|
124
209
|
return { success: true };
|
|
125
210
|
});
|
|
126
211
|
}
|
package/server/routes/public.js
CHANGED
|
@@ -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;
|
|
@@ -88,6 +88,9 @@ async function buildOverlayMenus(menuCtx, user) {
|
|
|
88
88
|
* @param {object} [opts.preview] - Set when rendering an unpublished page for a
|
|
89
89
|
* permitted viewer: `{status, urlPath, user}`. Draws the draft banner. Absent
|
|
90
90
|
* for every normal public render.
|
|
91
|
+
* @param {boolean} [opts.noTrack] - Mark the render as a preview for client-side
|
|
92
|
+
* analytics without drawing a banner. Implied by `opts.preview`; set directly
|
|
93
|
+
* by the editor's full-page preview, which has no banner.
|
|
91
94
|
* @returns {Promise<string>}
|
|
92
95
|
*/
|
|
93
96
|
export async function renderPage(page, opts = {}) {
|
|
@@ -210,7 +213,14 @@ export async function renderPage(page, opts = {}) {
|
|
|
210
213
|
};
|
|
211
214
|
const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
|
|
212
215
|
|
|
216
|
+
// A preview is not a visit. Without this the editor's preview iframe posts a
|
|
217
|
+
// page view on every re-render - recorded under the srcdoc document's path,
|
|
218
|
+
// which is not a page at all - and draft or share-link previews inflate the
|
|
219
|
+
// counts for something nobody has published.
|
|
220
|
+
const noTrack = !!opts.preview || opts.noTrack === true;
|
|
221
|
+
|
|
213
222
|
const vars = {
|
|
223
|
+
previewFlagScript: noTrack ? '<script>window.__CMS_PREVIEW__ = true;</script>' : '',
|
|
214
224
|
seoTitle,
|
|
215
225
|
seoDescription,
|
|
216
226
|
ogImage,
|
|
@@ -449,46 +459,43 @@ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage})
|
|
|
449
459
|
* never with the session cookie - keeping every state-changing call
|
|
450
460
|
* explicitly credentialled and the cookie free of write authority.
|
|
451
461
|
*
|
|
452
|
-
*
|
|
462
|
+
* A share-link view (`preview.shared`) gets a different bar: its reader has no
|
|
463
|
+
* account, so Edit and Publish would only be doors they cannot open. They get
|
|
464
|
+
* the expiry instead, which is the thing they actually need to know.
|
|
465
|
+
*
|
|
466
|
+
* @param {{status?: string, urlPath?: string, user?: object|null,
|
|
467
|
+
* shared?: {expiresAt?: string, label?: string}}} preview
|
|
453
468
|
* @returns {string}
|
|
454
469
|
*/
|
|
455
470
|
function buildDraftBanner(preview) {
|
|
456
471
|
const urlPath = preview.urlPath || '/';
|
|
457
472
|
const status = preview.status || 'draft';
|
|
458
473
|
const user = preview.user || {};
|
|
474
|
+
const shared = preview.shared || null;
|
|
459
475
|
const who = escapeHtml(user.name || user.email || 'an authorised user');
|
|
460
476
|
const role = escapeHtml(user.role || '');
|
|
461
477
|
const editHref = '/admin/#/pages/edit' + escapeHtml(urlPath);
|
|
462
478
|
const pathAttr = escapeHtml(urlPath);
|
|
463
|
-
const label = escapeHtml(status.charAt(0).toUpperCase() + status.slice(1));
|
|
479
|
+
const label = escapeHtml(shared ? 'Preview' : status.charAt(0).toUpperCase() + status.slice(1));
|
|
464
480
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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>
|
|
481
|
+
const message = shared
|
|
482
|
+
? `Shared preview of an unpublished page.${formatExpiry(shared.expiresAt)} It is not visible to the public.`
|
|
483
|
+
: `Not published - the public gets a 404 here. You can see it because you are signed in as <strong>${who}</strong>${role ? ` (${role})` : ''}.`;
|
|
484
|
+
|
|
485
|
+
const actions = shared
|
|
486
|
+
? ''
|
|
487
|
+
: `<a class="dmdb-btn" href="${editHref}">Edit page</a>
|
|
488
|
+
<button type="button" class="dmdb-publish" data-dmdb-publish>Publish</button>`;
|
|
489
|
+
|
|
490
|
+
const script = shared
|
|
491
|
+
? `<script>
|
|
492
|
+
(function(){
|
|
493
|
+
var bar = document.getElementById('dm-draft-banner');
|
|
494
|
+
if (!bar) return;
|
|
495
|
+
bar.querySelector('[data-dmdb-close]').addEventListener('click', function(){ bar.hidden = true; });
|
|
496
|
+
})();
|
|
497
|
+
</script>`
|
|
498
|
+
: `<script>
|
|
492
499
|
(function(){
|
|
493
500
|
var bar = document.getElementById('dm-draft-banner');
|
|
494
501
|
if (!bar) return;
|
|
@@ -501,7 +508,7 @@ border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#
|
|
|
501
508
|
if (!token) { window.location.href = '/admin/'; return; }
|
|
502
509
|
if (!window.confirm('Publish this page? It becomes visible to everyone.')) return;
|
|
503
510
|
btn.disabled = true;
|
|
504
|
-
btn.textContent = 'Publishing
|
|
511
|
+
btn.textContent = 'Publishing\u2026';
|
|
505
512
|
fetch('/api/pages' + path, {
|
|
506
513
|
method: 'PUT',
|
|
507
514
|
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token},
|
|
@@ -516,10 +523,51 @@ border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#
|
|
|
516
523
|
});
|
|
517
524
|
});
|
|
518
525
|
})();
|
|
519
|
-
</script
|
|
526
|
+
</script>`;
|
|
527
|
+
|
|
528
|
+
return `<div id="dm-draft-banner" role="status" aria-live="polite" data-url-path="${pathAttr}">
|
|
529
|
+
<style>
|
|
530
|
+
#dm-draft-banner{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;
|
|
531
|
+
padding:.6rem .9rem;font:500 .8125rem/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;color:#1c1601;
|
|
532
|
+
background:repeating-linear-gradient(135deg,#ffcf33,#ffcf33 14px,#f5b800 14px,#f5b800 28px);box-shadow:0 -2px 12px rgba(0,0,0,.28)}
|
|
533
|
+
#dm-draft-banner[hidden]{display:none}
|
|
534
|
+
#dm-draft-banner .dmdb-pill{flex:none;padding:.2rem .55rem;border-radius:999px;background:#1c1601;color:#ffcf33;
|
|
535
|
+
font-weight:700;font-size:.6875rem;letter-spacing:.08em;text-transform:uppercase}
|
|
536
|
+
#dm-draft-banner .dmdb-text{flex:1 1 16rem;min-width:0}
|
|
537
|
+
#dm-draft-banner .dmdb-actions{flex:none;display:flex;align-items:center;gap:.4rem}
|
|
538
|
+
#dm-draft-banner button,#dm-draft-banner a.dmdb-btn{font:inherit;cursor:pointer;border:1px solid rgba(28,22,1,.45);
|
|
539
|
+
border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#1c1601;text-decoration:none}
|
|
540
|
+
#dm-draft-banner button:hover,#dm-draft-banner a.dmdb-btn:hover{background:#fff}
|
|
541
|
+
#dm-draft-banner button[disabled]{opacity:.55;cursor:default}
|
|
542
|
+
#dm-draft-banner .dmdb-publish{background:#1c1601;color:#ffcf33;border-color:#1c1601}
|
|
543
|
+
#dm-draft-banner .dmdb-publish:hover{background:#000;color:#ffe27a}
|
|
544
|
+
#dm-draft-banner .dmdb-close{padding:.2rem .5rem;line-height:1;font-size:1rem;background:transparent;border-color:transparent}
|
|
545
|
+
@media print{#dm-draft-banner{display:none}}
|
|
546
|
+
</style>
|
|
547
|
+
<span class="dmdb-pill">${label}</span>
|
|
548
|
+
<span class="dmdb-text">${message}</span>
|
|
549
|
+
<span class="dmdb-actions">
|
|
550
|
+
${actions}
|
|
551
|
+
<button type="button" class="dmdb-close" data-dmdb-close aria-label="Hide preview banner" title="Hide">×</button>
|
|
552
|
+
</span>
|
|
553
|
+
${script}
|
|
520
554
|
</div>`;
|
|
521
555
|
}
|
|
522
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Render a share link's expiry as a sentence fragment, or '' when unknown.
|
|
559
|
+
*
|
|
560
|
+
* @param {string} [iso]
|
|
561
|
+
* @returns {string}
|
|
562
|
+
*/
|
|
563
|
+
function formatExpiry(iso) {
|
|
564
|
+
if (!iso) return '';
|
|
565
|
+
const when = new Date(iso);
|
|
566
|
+
if (Number.isNaN(when.getTime())) return '';
|
|
567
|
+
const opts = {day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'};
|
|
568
|
+
return ` This link expires ${escapeHtml(when.toLocaleString('en-GB', opts))}.`;
|
|
569
|
+
}
|
|
570
|
+
|
|
523
571
|
function escapeHtml(str) {
|
|
524
572
|
return String(str)
|
|
525
573
|
.replace(/&/g, '&')
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
<meta name="description" content="{{seoDescription}}">
|
|
8
8
|
{{seoTags}}
|
|
9
9
|
|
|
10
|
+
<!-- Set on preview renders (editor preview, draft preview, share link).
|
|
11
|
+
Plugins that record page views or fire outbound beacons must check it:
|
|
12
|
+
a preview is not a visit, and previews render at URLs that are not
|
|
13
|
+
real pages. Emitted before every other injection so both head and
|
|
14
|
+
body-end scripts can read it. -->
|
|
15
|
+
{{previewFlagScript}}
|
|
16
|
+
|
|
10
17
|
<!-- Fonts -->
|
|
11
18
|
{{#if fontLink}}{{fontLink}}{{/if}}
|
|
12
19
|
|