domma-cms 0.116.8 → 0.118.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 -4
- package/admin/js/app.js +2 -2
- package/admin/js/lib/api-tokens-arrange.js +3 -3
- package/admin/js/lib/content-api-arrange.js +1 -0
- package/admin/js/lib/menu-editor-arrange.js +1 -1
- package/admin/js/lib/menu-rows.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +1 -1
- package/admin/js/templates/api-reference.html +2 -1
- package/admin/js/templates/api-tokens.html +3 -3
- package/admin/js/templates/content-api.html +208 -0
- package/admin/js/templates/docs/api-content.html +183 -0
- package/admin/js/templates/docs/api-external.html +2 -1
- package/admin/js/templates/docs/usage-editions.html +1 -1
- package/admin/js/templates/menu-editor.html +6 -0
- package/admin/js/views/api-tokens.js +6 -6
- package/admin/js/views/content-api.js +7 -0
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +14 -14
- package/package.json +1 -1
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/site.js +1 -1
- package/server/routes/api/collections.js +10 -17
- package/server/routes/api/content-api.js +70 -0
- package/server/routes/api/content-public.js +299 -0
- package/server/routes/public.js +17 -68
- package/server/server.js +11 -4
- package/server/services/apiTokens.js +61 -5
- package/server/services/content.js +4 -4
- package/server/services/contentApi.js +248 -0
- package/server/services/menuRender.js +9 -3
- package/server/services/menus.js +4 -1
- package/server/services/pagePublishing.js +85 -0
- package/server/services/permissionRegistry.js +1 -1
- package/server/services/renderer.js +12 -2
- package/server/services/search.js +31 -12
- package/server/templates/page.html +1 -1
package/server/routes/public.js
CHANGED
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
import {getPage, getPageMtime} from '../services/content.js';
|
|
10
10
|
import {getProjectForPage, isProjectEnabled} from '../services/projects.js';
|
|
11
11
|
import {renderPage} from '../services/renderer.js';
|
|
12
|
+
import {resolvePublishedPage} from '../services/pagePublishing.js';
|
|
12
13
|
import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
|
|
13
|
-
import {checkVisibility} from '../middleware/auth.js';
|
|
14
14
|
import {resolveViewer} from '../services/viewerSession.js';
|
|
15
15
|
import {touchPreviewLink, verifyPreviewToken} from '../services/previewLinks.js';
|
|
16
|
-
import {getPermissionsFor} from '../services/roles.js';
|
|
17
16
|
import {getEffectiveRoles} from '../services/userRoles.js';
|
|
18
17
|
import {hooks} from '../services/hooks.js';
|
|
19
18
|
import {getConfig} from '../config.js';
|
|
@@ -184,70 +183,36 @@ export async function publicRoutes(fastify) {
|
|
|
184
183
|
|
|
185
184
|
const urlPath = '/' + (rawPath || '');
|
|
186
185
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// Try fetching index of a directory path
|
|
194
|
-
if (!page && !urlPath.endsWith('/index')) {
|
|
195
|
-
page = await getPage(urlPath.replace(/\/$/, '') || '/');
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (!page) {
|
|
199
|
-
reply.status(404);
|
|
200
|
-
return sendHtml(request, reply, await render404(urlPath));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Hard kill-switch: a disabled project takes its pages off the public
|
|
204
|
-
// site entirely - 404 (no oracle), regardless of page visibility.
|
|
205
|
-
const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
|
|
206
|
-
if (!(await isProjectEnabled(pageProject))) {
|
|
207
|
-
reply.status(404);
|
|
208
|
-
return sendHtml(request, reply, await render404(urlPath));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Draft preview: a page that is not published stays invisible to the
|
|
212
|
-
// public, but is served in full to a signed-in user who could read it
|
|
213
|
-
// in the admin anyway. Everyone else gets the same 404 as before - the
|
|
214
|
-
// response must not reveal that an unpublished page exists there.
|
|
215
|
-
const isDraft = page.status !== 'published';
|
|
216
|
-
|
|
217
|
-
// Enforce page visibility. The viewer is resolved on EVERY request,
|
|
218
|
-
// not only for gated pages: the navbar, footers, overlays and [menu]
|
|
219
|
-
// shortcodes on a public page filter their items by role too, and a
|
|
220
|
-
// signed-in visitor rendered as anonymous never sees the items meant
|
|
221
|
-
// for them. Resolving is a cookie read plus a JWT verify, and returns
|
|
222
|
-
// null at once for anonymous traffic, which still shares one entry.
|
|
186
|
+
// The viewer is resolved on EVERY request, not only for gated pages:
|
|
187
|
+
// the navbar, footers, overlays and [menu] shortcodes on a public page
|
|
188
|
+
// filter their items by role too, and a signed-in visitor rendered as
|
|
189
|
+
// anonymous never sees the items meant for them. Resolving is a cookie
|
|
190
|
+
// read plus a JWT verify, and returns null at once for anonymous
|
|
191
|
+
// traffic, which still shares one entry.
|
|
223
192
|
//
|
|
224
|
-
// `visibility` may be a string ('public' | 'private' | role name) or
|
|
225
|
-
// an array of role names - see checkVisibility() for full semantics.
|
|
226
|
-
// Effectively-public values (missing, 'public', or an array containing
|
|
227
|
-
// 'public') skip viewer resolution entirely so anonymous traffic hits
|
|
228
|
-
// the shared cache without auth cost - unless the page is a draft,
|
|
229
|
-
// which needs the viewer regardless of its visibility.
|
|
230
193
|
// The cache key carries every role the viewer holds (sorted), since
|
|
231
194
|
// exact-role visibility (`=candidate`) makes an additional role change
|
|
232
195
|
// what renders. Real sites hold a handful of role combinations, so
|
|
233
196
|
// the entry count stays bounded by what users actually have.
|
|
234
|
-
const vis = page.visibility;
|
|
235
|
-
const isPublic = !vis
|
|
236
|
-
|| vis === 'public'
|
|
237
|
-
|| (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
|
|
238
|
-
|
|
239
197
|
const userObj = await resolveViewer(request);
|
|
240
198
|
const userRole = userObj ? [...getEffectiveRoles(userObj)].sort().join('+') : null;
|
|
241
199
|
|
|
242
|
-
|
|
200
|
+
// The access decision - the directory-index fallback, the project
|
|
201
|
+
// kill-switch, drafts and visibility - is resolvePublishedPage(), shared
|
|
202
|
+
// with the Content API so the two can never disagree. Drafts are allowed
|
|
203
|
+
// here: a viewer holding pages.read gets the real page plus a banner;
|
|
204
|
+
// everyone else gets the same 404 as a missing page, so the response
|
|
205
|
+
// never reveals that an unpublished page exists there.
|
|
206
|
+
const verdict = await resolvePublishedPage(urlPath, userObj, {allowDrafts: true});
|
|
207
|
+
if (verdict.status === 'not-found') {
|
|
243
208
|
reply.status(404);
|
|
244
209
|
return sendHtml(request, reply, await render404(urlPath));
|
|
245
210
|
}
|
|
246
|
-
|
|
247
|
-
if (!isPublic && !checkVisibility(userObj, vis)) {
|
|
211
|
+
if (verdict.status === 'forbidden') {
|
|
248
212
|
reply.status(403);
|
|
249
213
|
return reply.type('text/html; charset=utf-8').send(accessDeniedHtml(urlPath));
|
|
250
214
|
}
|
|
215
|
+
const {page, isDraft} = verdict;
|
|
251
216
|
|
|
252
217
|
const baseUrl = getBaseUrl(request);
|
|
253
218
|
|
|
@@ -294,22 +259,6 @@ export async function publicRoutes(fastify) {
|
|
|
294
259
|
});
|
|
295
260
|
}
|
|
296
261
|
|
|
297
|
-
/**
|
|
298
|
-
* May this viewer see unpublished pages on the public site?
|
|
299
|
-
*
|
|
300
|
-
* Gated on `pages.read` - the same permission that opens the Pages list in the
|
|
301
|
-
* admin. Anyone who can already read the draft's source there loses nothing by
|
|
302
|
-
* seeing it rendered, and nobody else gains anything.
|
|
303
|
-
*
|
|
304
|
-
* @param {object|null} user - Resolved viewer (`{role, additionalRoles}`) or null
|
|
305
|
-
* @returns {boolean}
|
|
306
|
-
*/
|
|
307
|
-
function canPreviewDrafts(user) {
|
|
308
|
-
if (!user) return false;
|
|
309
|
-
const allowed = getPermissionsFor('pages', 'read');
|
|
310
|
-
return getEffectiveRoles(user).some(role => allowed.includes(role));
|
|
311
|
-
}
|
|
312
|
-
|
|
313
262
|
/**
|
|
314
263
|
* Render a 404 response - tries content/pages/404.md first, falls back to
|
|
315
264
|
* a minimal inline page so the site theme is applied when possible.
|
package/server/server.js
CHANGED
|
@@ -460,14 +460,15 @@ app.setErrorHandler((error, request, reply) => {
|
|
|
460
460
|
// ---------------------------------------------------------------------------
|
|
461
461
|
// The admin SPA must always see the live state of the site - a stale list is
|
|
462
462
|
// indistinguishable from a missing artefact, and the user is left arguing with
|
|
463
|
-
// their own CMS. Everything under /api is no-store EXCEPT the
|
|
463
|
+
// their own CMS. Everything under /api is no-store EXCEPT the three externally
|
|
464
464
|
// consumable surfaces, which have their own caching contracts:
|
|
465
|
-
// /api/v1/*
|
|
466
|
-
// /api/x/*
|
|
465
|
+
// /api/v1/* - collection access by API token
|
|
466
|
+
// /api/x/* - API Builder endpoints
|
|
467
|
+
// /api/content/v1/* - the Content API (public / private / no-store per access mode)
|
|
467
468
|
app.addHook('onSend', async (request, reply) => {
|
|
468
469
|
const url = request.raw.url || '';
|
|
469
470
|
if (!url.startsWith('/api/')) return;
|
|
470
|
-
if (url.startsWith('/api/v1/') || url.startsWith('/api/x/')) return;
|
|
471
|
+
if (url.startsWith('/api/v1/') || url.startsWith('/api/x/') || url.startsWith('/api/content/v1/')) return;
|
|
471
472
|
reply.header('Cache-Control', 'no-store, must-revalidate');
|
|
472
473
|
reply.header('Pragma', 'no-cache');
|
|
473
474
|
reply.header('Expires', '0');
|
|
@@ -497,6 +498,8 @@ const {rolesRoutes} = await import('./routes/api/roles.js');
|
|
|
497
498
|
const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
|
|
498
499
|
const {apiEndpointsRoutes} = await import('./routes/api/api-endpoints.js');
|
|
499
500
|
const {endpointsPublicRoutes} = await import('./routes/api/endpoints-public.js');
|
|
501
|
+
const {contentPublicRoutes} = await import('./routes/api/content-public.js');
|
|
502
|
+
const {contentApiRoutes} = await import('./routes/api/content-api.js');
|
|
500
503
|
const {sidebarRoutes} = await import('./routes/api/sidebar.js');
|
|
501
504
|
const {documentationRoutes} = await import('./routes/api/documentation.js');
|
|
502
505
|
const { mediaRoutes } = await import('./routes/api/media.js');
|
|
@@ -536,6 +539,8 @@ await app.register(rolesRoutes, {prefix: '/api'});
|
|
|
536
539
|
await app.register(apiTokensRoutes, {prefix: '/api'});
|
|
537
540
|
await app.register(apiEndpointsRoutes, {prefix: '/api'});
|
|
538
541
|
await app.register(endpointsPublicRoutes, {prefix: '/api'});
|
|
542
|
+
await app.register(contentPublicRoutes, {prefix: '/api'});
|
|
543
|
+
await app.register(contentApiRoutes, {prefix: '/api'});
|
|
539
544
|
await app.register(sidebarRoutes, {prefix: '/api'});
|
|
540
545
|
await app.register(documentationRoutes, {prefix: '/api'});
|
|
541
546
|
await app.register(mediaRoutes, { prefix: '/api' });
|
|
@@ -657,6 +662,7 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts
|
|
|
657
662
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
|
|
658
663
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
|
|
659
664
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
|
|
665
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Content API', url: '#/content-api', icon: 'file-text', permission: 'settings', countUrl: '/api/content-api/_count', inventory: true}});
|
|
660
666
|
// Documentation pages added after the sidebar menu was seeded (0.93.1). The menu is
|
|
661
667
|
// seeded once, so new pages reach existing sites only by registering here. The two
|
|
662
668
|
// sub-folders have no key in older menus; findFolder() falls back to their name.
|
|
@@ -666,6 +672,7 @@ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions
|
|
|
666
672
|
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Forms', url: '#/docs/api/forms', icon: 'layout'}});
|
|
667
673
|
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Scaffold', url: '#/docs/api/scaffold', icon: 'package'}});
|
|
668
674
|
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'External API & Tokens', url: '#/docs/api/external', icon: 'key'}});
|
|
675
|
+
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Content API', url: '#/docs/api/content', icon: 'file-text'}});
|
|
669
676
|
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'API Builder', url: '#/docs/api/builder', icon: 'code'}});
|
|
670
677
|
|
|
671
678
|
// Core's notification sources, before plugins add theirs (0.80).
|
|
@@ -17,6 +17,12 @@ export const API_TOKENS_COLLECTION_SLUG = 'api-tokens';
|
|
|
17
17
|
|
|
18
18
|
const TOKEN_RE = /^dcms_[a-f0-9]{64}$/;
|
|
19
19
|
const VERBS = ['create', 'read', 'update', 'delete'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a `{content}` scope may name - the Content API's resources. Content
|
|
23
|
+
* scopes are read-only (the API has no writes yet), so they carry no verbs.
|
|
24
|
+
*/
|
|
25
|
+
export const CONTENT_RESOURCES = Object.freeze(['pages', 'menus', 'site', 'search']);
|
|
20
26
|
const LAST_USED_THROTTLE_MS = 60_000;
|
|
21
27
|
|
|
22
28
|
/** In-memory hash → entry cache; null = needs rebuild. */
|
|
@@ -75,16 +81,23 @@ function sanitise(entry) {
|
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
/**
|
|
78
|
-
* Validate a scopes declaration
|
|
79
|
-
*
|
|
84
|
+
* Validate a scopes declaration. Each scope is EITHER a collection scope
|
|
85
|
+
* `{collection, verbs?}` OR a content scope `{content}` (a Content API
|
|
86
|
+
* resource). Empty array = the token covers everything in its project.
|
|
80
87
|
*
|
|
81
88
|
* @param {*} scopes
|
|
82
89
|
* @returns {string|null} Error message, or null when valid
|
|
83
90
|
*/
|
|
84
|
-
function validateScopes(scopes) {
|
|
91
|
+
export function validateScopes(scopes) {
|
|
85
92
|
if (scopes == null) return null;
|
|
86
93
|
if (!Array.isArray(scopes)) return 'scopes must be an array';
|
|
87
94
|
for (const s of scopes) {
|
|
95
|
+
if (s && typeof s === 'object' && s.content !== undefined) {
|
|
96
|
+
if (s.collection !== undefined) return 'a scope names either a collection or a content resource, not both';
|
|
97
|
+
if (!CONTENT_RESOURCES.includes(s.content)) return `unknown content resource "${s.content}"`;
|
|
98
|
+
if (s.verbs != null) return 'content scopes are read-only and take no verbs';
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
88
101
|
if (!s || typeof s !== 'object' || typeof s.collection !== 'string' || !s.collection.trim()) {
|
|
89
102
|
return 'each scope needs a collection slug';
|
|
90
103
|
}
|
|
@@ -101,18 +114,61 @@ function validateScopes(scopes) {
|
|
|
101
114
|
* Check whether a token's scopes permit an operation on a collection.
|
|
102
115
|
* Empty/missing scopes → everything in the token's project is allowed.
|
|
103
116
|
*
|
|
104
|
-
* @param {Array<{collection
|
|
117
|
+
* @param {Array<{collection?: string, verbs?: string[], content?: string}>} scopes
|
|
105
118
|
* @param {string} collectionSlug
|
|
106
119
|
* @param {string} verb - create | read | update | delete
|
|
107
120
|
* @returns {boolean}
|
|
108
121
|
*/
|
|
109
122
|
export function scopeAllows(scopes, collectionSlug, verb) {
|
|
110
123
|
if (!Array.isArray(scopes) || scopes.length === 0) return true;
|
|
111
|
-
|
|
124
|
+
// Content scopes never match: a collection named `pages` is still a collection.
|
|
125
|
+
const scope = scopes.find(s => s.collection !== undefined && s.collection === collectionSlug);
|
|
112
126
|
if (!scope) return false;
|
|
113
127
|
return !Array.isArray(scope.verbs) || scope.verbs.length === 0 || scope.verbs.includes(verb);
|
|
114
128
|
}
|
|
115
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Does a token's scope list permit a Content API resource? Empty = everything
|
|
132
|
+
* in the project. A list that names no content resource permits none - a token
|
|
133
|
+
* scoped to collections is never widened by the Content API existing.
|
|
134
|
+
*
|
|
135
|
+
* @param {object[]} scopes
|
|
136
|
+
* @param {string} resource - one of CONTENT_RESOURCES
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
export function contentScopeAllows(scopes, resource) {
|
|
140
|
+
if (!Array.isArray(scopes) || scopes.length === 0) return true;
|
|
141
|
+
return scopes.some(s => s.content === resource);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const BEARER_RE = /^Bearer (dcms_[a-f0-9]{64})$/;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The strict token check, shared by every externally consumable gate - the
|
|
148
|
+
* collections API (and the API Builder endpoints that reuse it) and the
|
|
149
|
+
* Content API. A token is the ONLY credential accepted: callers decide when
|
|
150
|
+
* token mode applies, and this never looks at a JWT.
|
|
151
|
+
*
|
|
152
|
+
* `project` binds the token to the resource's project. Omit it when the
|
|
153
|
+
* caller binds per resource instead (a Content API page list spans projects
|
|
154
|
+
* and is filtered to the token's). `allows` receives the token's scopes.
|
|
155
|
+
*
|
|
156
|
+
* @param {{headers?: object}} request
|
|
157
|
+
* @param {{project?: string, allows: (scopes: object[]) => boolean, projectError?: string}} opts
|
|
158
|
+
* @returns {Promise<{ok: true, token: object} | {ok: false, status: 401|403, error: string}>}
|
|
159
|
+
*/
|
|
160
|
+
export async function authenticateApiToken(request, {project, allows, projectError}) {
|
|
161
|
+
const match = (request.headers?.authorization || '').match(BEARER_RE);
|
|
162
|
+
if (!match) return {ok: false, status: 401, error: 'API token required'};
|
|
163
|
+
const token = await validateToken(match[1]);
|
|
164
|
+
if (!token) return {ok: false, status: 401, error: 'Invalid, disabled or expired API token'};
|
|
165
|
+
if (project !== undefined && token.project !== project) {
|
|
166
|
+
return {ok: false, status: 403, error: projectError || "Token is not valid for this resource's project"};
|
|
167
|
+
}
|
|
168
|
+
if (!allows(token.scopes)) return {ok: false, status: 403, error: 'Token scope does not permit this operation'};
|
|
169
|
+
return {ok: true, token};
|
|
170
|
+
}
|
|
171
|
+
|
|
116
172
|
/**
|
|
117
173
|
* Create a new token. The plaintext is returned ONCE here and never again.
|
|
118
174
|
*
|
|
@@ -167,7 +167,7 @@ export async function createPage(urlPath, frontmatter, body) {
|
|
|
167
167
|
|
|
168
168
|
await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
|
|
169
169
|
const page = await readPageFile(filePath);
|
|
170
|
-
await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
|
|
170
|
+
await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
|
|
171
171
|
hooks.emit('content:pageCreated', {page, urlPath});
|
|
172
172
|
return page;
|
|
173
173
|
}
|
|
@@ -222,7 +222,7 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
|
|
|
222
222
|
|
|
223
223
|
await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
|
|
224
224
|
const page = await readPageFile(filePath);
|
|
225
|
-
await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
|
|
225
|
+
await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
|
|
226
226
|
hooks.emit('content:pageUpdated', {page, urlPath});
|
|
227
227
|
return page;
|
|
228
228
|
}
|
|
@@ -240,7 +240,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
|
|
|
240
240
|
const newFile = urlPathToFilePath(newUrlPath);
|
|
241
241
|
await fs.mkdir(path.dirname(newFile), { recursive: true });
|
|
242
242
|
await fs.rename(oldFile, newFile);
|
|
243
|
-
await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`, 'sitemap']);
|
|
243
|
+
await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`, 'sitemap', 'pages:list']);
|
|
244
244
|
try {
|
|
245
245
|
await renameVersionDir(oldUrlPath, newUrlPath);
|
|
246
246
|
} catch {
|
|
@@ -259,7 +259,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
|
|
|
259
259
|
export async function deletePage(urlPath) {
|
|
260
260
|
const filePath = await resolveExistingFilePath(urlPath);
|
|
261
261
|
await fs.unlink(filePath);
|
|
262
|
-
await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
|
|
262
|
+
await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
|
|
263
263
|
hooks.emit('content:pageDeleted', {urlPath});
|
|
264
264
|
try {
|
|
265
265
|
await deleteAllVersions(urlPath);
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content API - settings and payloads.
|
|
3
|
+
*
|
|
4
|
+
* Settings live in config/content-api.json (optional; absence = the defaults,
|
|
5
|
+
* which are OFF - an upgrade must not expose a site that never chose to be).
|
|
6
|
+
*
|
|
7
|
+
* Every payload is built from an ALLOWLIST. Frontmatter is open-ended (authors
|
|
8
|
+
* and plugins add keys freely) and site.json holds SMTP credentials, so a
|
|
9
|
+
* spread-then-delete is one forgotten key away from a leak.
|
|
10
|
+
*/
|
|
11
|
+
import {getConfig, saveConfig} from '../config.js';
|
|
12
|
+
import {CONTENT_RESOURCES} from './apiTokens.js';
|
|
13
|
+
import {isPubliclyListed} from './search.js';
|
|
14
|
+
import {plural} from './sidebarBadges.js';
|
|
15
|
+
|
|
16
|
+
export const FORMATS = Object.freeze(['html', 'markdown']);
|
|
17
|
+
|
|
18
|
+
export const CONTENT_API_DEFAULTS = Object.freeze({
|
|
19
|
+
enabled: false,
|
|
20
|
+
access: 'token',
|
|
21
|
+
resources: Object.freeze(Object.fromEntries(CONTENT_RESOURCES.map(k => [k, Object.freeze({enabled: true})]))),
|
|
22
|
+
formats: Object.freeze(['html']),
|
|
23
|
+
listable: true,
|
|
24
|
+
maxLimit: 200
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const MAX_LIMIT = 200;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} [raw] - config/content-api.json as stored, or a patch merged onto it
|
|
31
|
+
* @returns {object} A fresh, complete settings object
|
|
32
|
+
*/
|
|
33
|
+
export function normaliseContentApiSettings(raw) {
|
|
34
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
35
|
+
const access = typeof r.access === 'string' && r.access.trim() ? r.access.trim() : CONTENT_API_DEFAULTS.access;
|
|
36
|
+
const formats = Array.isArray(r.formats) ? FORMATS.filter(f => r.formats.includes(f)) : [];
|
|
37
|
+
const limit = Math.trunc(Number(r.maxLimit));
|
|
38
|
+
return {
|
|
39
|
+
enabled: r.enabled === true,
|
|
40
|
+
access,
|
|
41
|
+
resources: Object.fromEntries(CONTENT_RESOURCES.map(k => [k, {enabled: r.resources?.[k]?.enabled !== false}])),
|
|
42
|
+
formats: formats.length ? formats : [...CONTENT_API_DEFAULTS.formats],
|
|
43
|
+
listable: r.listable !== false,
|
|
44
|
+
maxLimit: Number.isFinite(limit) ? Math.min(MAX_LIMIT, Math.max(1, limit)) : CONTENT_API_DEFAULTS.maxLimit
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Current settings, defaults filled in. A site with no file gets the (off) defaults. */
|
|
49
|
+
export function getContentApiSettings() {
|
|
50
|
+
try {
|
|
51
|
+
return normaliseContentApiSettings(getConfig('content-api'));
|
|
52
|
+
} catch {
|
|
53
|
+
return normaliseContentApiSettings({});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Save settings, returning what was actually stored.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} patch - Partial or complete settings
|
|
61
|
+
* @returns {object}
|
|
62
|
+
*/
|
|
63
|
+
export function saveContentApiSettings(patch) {
|
|
64
|
+
const next = normaliseContentApiSettings({...getContentApiSettings(), ...(patch || {})});
|
|
65
|
+
saveConfig('content-api', next);
|
|
66
|
+
return next;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Payloads
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
const SUMMARY_KEYS = ['urlPath', 'title', 'description', 'tags', 'category', 'layout', 'updatedAt'];
|
|
74
|
+
const DETAIL_KEYS = [...SUMMARY_KEYS, 'createdAt'];
|
|
75
|
+
const SEO_KEYS = ['title', 'description', 'image', 'canonical', 'noindex'];
|
|
76
|
+
|
|
77
|
+
const pick = (obj, keys) => Object.fromEntries(keys.filter(k => obj?.[k] !== undefined).map(k => [k, obj[k]]));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The Domma behaviour a page's HTML relies on: the markup public/js/site.js
|
|
81
|
+
* (and public/js/effects.js) initialise. A client rendering `html` without Domma gets these inert, so
|
|
82
|
+
* the API names them. `usedComponents` does not cover them - it lists only
|
|
83
|
+
* custom `[component]`s, and a page of tabs reports none.
|
|
84
|
+
*
|
|
85
|
+
* Matched on real attributes only: markup shown in a code block is escaped
|
|
86
|
+
* (`<div class="tabs`) and never matches. Keep in step with site.js.
|
|
87
|
+
*/
|
|
88
|
+
const DOMMA_MARKERS = [
|
|
89
|
+
['tabs', {cls: 'tabs'}],
|
|
90
|
+
['accordion', {cls: 'accordion'}],
|
|
91
|
+
['carousel', {cls: 'carousel'}],
|
|
92
|
+
['countdown', {cls: 'dm-countdown'}],
|
|
93
|
+
['progression', {cls: 'dm-progression'}],
|
|
94
|
+
['slideover', {cls: 'dm-so-trigger'}],
|
|
95
|
+
['cta', {cls: 'dm-cta-trigger'}],
|
|
96
|
+
['collapsible', {attr: 'data-collapsible'}],
|
|
97
|
+
['tooltip', {attr: 'data-tooltip'}],
|
|
98
|
+
['reveal', {attr: 'data-reveal'}],
|
|
99
|
+
['collection-table', {attr: 'data-collection-table'}],
|
|
100
|
+
['form', {attr: 'data-form-inline'}],
|
|
101
|
+
['collection', {cls: 'dm-collection-hydrate'}],
|
|
102
|
+
['page-config', {cls: 'dm-page-config'}],
|
|
103
|
+
// Every effect (scribe, reveal, celebrate, counter, …) is a dm-fx-<name> class.
|
|
104
|
+
['effects', {prefix: 'dm-fx-'}]
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} html
|
|
109
|
+
* @returns {string[]} Marker names found, in DOMMA_MARKERS order
|
|
110
|
+
*/
|
|
111
|
+
export function domma(html) {
|
|
112
|
+
if (typeof html !== 'string' || !html) return [];
|
|
113
|
+
const classes = new Set();
|
|
114
|
+
for (const m of html.matchAll(/<[^>]*?\sclass="([^"]*)"/g)) {
|
|
115
|
+
for (const c of m[1].split(/\s+/)) if (c) classes.add(c);
|
|
116
|
+
}
|
|
117
|
+
const hasAttr = (name) => new RegExp(`<[^>]*\\s${name}(?=[\\s=/>])`).test(html);
|
|
118
|
+
return DOMMA_MARKERS
|
|
119
|
+
.filter(([, m]) => {
|
|
120
|
+
if (m.prefix) return [...classes].some(c => c.startsWith(m.prefix));
|
|
121
|
+
return m.cls ? classes.has(m.cls) : hasAttr(m.attr);
|
|
122
|
+
})
|
|
123
|
+
.map(([name]) => name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A page in a list: enough to link to it, never its body - the list must stay
|
|
128
|
+
* cheap enough to run over every page without rendering any.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} page - a listPages() row
|
|
131
|
+
* @returns {object}
|
|
132
|
+
*/
|
|
133
|
+
export function pageSummary(page) {
|
|
134
|
+
return pick(page, SUMMARY_KEYS);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One page. `html` is the body only (page.html), not the full document with
|
|
139
|
+
* navbar and footer; `markdown` is the source, shortcodes and all.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} page - a getPage() result the caller is allowed to see
|
|
142
|
+
* @param {{format: 'html'|'markdown'|'both'}} opts
|
|
143
|
+
* @returns {object}
|
|
144
|
+
*/
|
|
145
|
+
export function pageDetail(page, {format}) {
|
|
146
|
+
const out = pick(page, DETAIL_KEYS);
|
|
147
|
+
if (page.seo && typeof page.seo === 'object') out.seo = pick(page.seo, SEO_KEYS);
|
|
148
|
+
if (format === 'html' || format === 'both') out.html = page.html || '';
|
|
149
|
+
if (format === 'markdown' || format === 'both') out.markdown = page.content || '';
|
|
150
|
+
const custom = Array.isArray(page.usedComponents) ? page.usedComponents : [];
|
|
151
|
+
const components = [...new Set([...domma(page.html), ...custom])];
|
|
152
|
+
out.requires = {domma: components.length > 0, components};
|
|
153
|
+
out.perViewer = Boolean(page.perViewer);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Site settings safe for any caller: exactly what every public page already
|
|
159
|
+
* shows - the title, navbar brand, footer (hidden links stay hidden, as in
|
|
160
|
+
* renderer.js), social links, the SEO defaults in its meta tags, and the theme
|
|
161
|
+
* class. `tagline`, `url`, `smtp` and the admin settings never reach a public
|
|
162
|
+
* page, so they never reach this either.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} [site] - config/site.json
|
|
165
|
+
* @returns {object}
|
|
166
|
+
*/
|
|
167
|
+
export function publicSiteSettings(site = {}) {
|
|
168
|
+
const out = pick(site, ['title', 'social', 'theme']);
|
|
169
|
+
if (site.brand && typeof site.brand === 'object') out.brand = pick(site.brand, ['text', 'logo', 'url']);
|
|
170
|
+
if (site.footer && typeof site.footer === 'object') {
|
|
171
|
+
out.footer = {...site.footer, links: (site.footer.links || []).filter(l => !l?.hidden)};
|
|
172
|
+
}
|
|
173
|
+
if (site.seo && typeof site.seo === 'object') out.seo = pick(site.seo, ['defaultTitle', 'titleSeparator', 'defaultDescription']);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// The admin screen: coverage, and the sidebar badge
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* How many pages the API can serve an anonymous caller - the same rule as its
|
|
183
|
+
* page list (isPubliclyListed, then `api: false`). A signed-in role-mode caller
|
|
184
|
+
* may get more: the gated pages they can open.
|
|
185
|
+
*
|
|
186
|
+
* @param {object[]} pages - listPages()
|
|
187
|
+
* @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
|
|
188
|
+
* @returns {{served: number, keptOff: number, total: number}}
|
|
189
|
+
*/
|
|
190
|
+
export function contentApiCoverage(pages = [], exclusions = null) {
|
|
191
|
+
let served = 0;
|
|
192
|
+
let keptOff = 0;
|
|
193
|
+
for (const p of pages) {
|
|
194
|
+
if (!isPubliclyListed(p, null, exclusions)) continue;
|
|
195
|
+
if (p.api === false) keptOff++;
|
|
196
|
+
else served++;
|
|
197
|
+
}
|
|
198
|
+
return {served, keptOff, total: pages.length};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** How a settings `access` value reads to a person. */
|
|
202
|
+
export function accessLabel(access) {
|
|
203
|
+
if (access === 'public') return 'public';
|
|
204
|
+
if (access === 'token') return 'API token';
|
|
205
|
+
return `role: ${access} and above`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const FORMAT_LABEL = {html: 'HTML', markdown: 'Markdown'};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The sidebar badge on System › Content API (its countUrl, GET
|
|
212
|
+
* /api/content-api/_count): the pages served, and how the API is set up in
|
|
213
|
+
* the popover. An inventory, not a signal - plain text throughout.
|
|
214
|
+
*
|
|
215
|
+
* @param {object} settings
|
|
216
|
+
* @param {object[]} pages
|
|
217
|
+
* @param {object|null} [exclusions]
|
|
218
|
+
*/
|
|
219
|
+
export function contentApiBadge(settings, pages = [], exclusions = null) {
|
|
220
|
+
const s = normaliseContentApiSettings(settings);
|
|
221
|
+
const c = contentApiCoverage(pages, exclusions);
|
|
222
|
+
const served = plural(c.served, 'page');
|
|
223
|
+
const access = accessLabel(s.access);
|
|
224
|
+
const resources = CONTENT_RESOURCES.filter(k => s.resources[k].enabled);
|
|
225
|
+
return {
|
|
226
|
+
count: c.served,
|
|
227
|
+
label: s.enabled
|
|
228
|
+
? `Content API is on (${access}) - ${served} served`
|
|
229
|
+
: `Content API is off - ${served} would be served`,
|
|
230
|
+
tone: 'info',
|
|
231
|
+
details: {
|
|
232
|
+
title: 'Content API',
|
|
233
|
+
rows: [
|
|
234
|
+
{label: 'Status', value: s.enabled ? 'On' : 'Off'},
|
|
235
|
+
{label: 'Access', value: access.charAt(0).toUpperCase() + access.slice(1)},
|
|
236
|
+
{label: 'Resources', value: resources.length ? resources.join(', ') : 'none'},
|
|
237
|
+
{label: 'Formats', value: s.formats.map(f => FORMAT_LABEL[f]).join(', ')},
|
|
238
|
+
{label: 'Page list', value: s.listable ? 'On' : 'Off - single pages only'},
|
|
239
|
+
{label: 'Pages served', value: String(c.served)},
|
|
240
|
+
{label: 'Kept off (api: false)', value: String(c.keptOff)}
|
|
241
|
+
],
|
|
242
|
+
items: [],
|
|
243
|
+
empty: s.enabled
|
|
244
|
+
? 'Callers get the published pages an anonymous visitor could open, except pages marked api: false.'
|
|
245
|
+
: 'The Content API is switched off - every /api/content/v1 address answers 404.'
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -14,7 +14,7 @@ import {colourToCss, floatToCss, floatBehaviour, spacerSize} from './menus.js';
|
|
|
14
14
|
// Highlight helpers live once, in the browser module, and are imported here -
|
|
15
15
|
// the same shared-module arrangement collection-export.mjs has with
|
|
16
16
|
// collections.js. Nothing in them touches the DOM.
|
|
17
|
-
import {highlightAttrs, itemHighlightAttrs, isCurrentItem, menuAlign} from '../../public/js/menu-decor.mjs';
|
|
17
|
+
import {highlightAttrs, itemHighlightAttrs, isCurrentItem, menuAlign, cssClasses} from '../../public/js/menu-decor.mjs';
|
|
18
18
|
|
|
19
19
|
/** Attribute-safe escaping - mirrors escapeAttr in markdown.js. */
|
|
20
20
|
function esc(str) {
|
|
@@ -56,6 +56,10 @@ export function renderMenuItemsAsUl(items, currentPath = '') {
|
|
|
56
56
|
// needs an explicit allowlist entry.
|
|
57
57
|
const current = isCurrentItem(it.url, currentPath);
|
|
58
58
|
const classAttr = current ? ' class="is-active" aria-current="page"' : '';
|
|
59
|
+
// The designer's own classes go on the <li>, so they can lay the item
|
|
60
|
+
// out (push it right, size it) as well as style its link.
|
|
61
|
+
const liClasses = cssClasses(it.class);
|
|
62
|
+
const liAttr = liClasses.length ? ` class="${esc(liClasses.join(' '))}"` : '';
|
|
59
63
|
|
|
60
64
|
// Per-item highlight override. `data-menu-item-*` rather than the
|
|
61
65
|
// menu's `data-menu-*` so the CSS can let the item win without the two
|
|
@@ -86,7 +90,7 @@ export function renderMenuItemsAsUl(items, currentPath = '') {
|
|
|
86
90
|
badge = `<span class="dm-menu-badge"${badgeStyle}>${esc(String(it.badge.text))}</span>`;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
return `<li><a href="${href}"${classAttr}${styleAttr}${ihlAttrs}>${text}${badge}</a>${child}</li>`;
|
|
93
|
+
return `<li${liAttr}><a href="${href}"${classAttr}${styleAttr}${ihlAttrs}>${text}${badge}</a>${child}</li>`;
|
|
90
94
|
}).join('');
|
|
91
95
|
return `<ul>${lis}</ul>`;
|
|
92
96
|
}
|
|
@@ -106,7 +110,9 @@ export function renderMenuItemsAsUl(items, currentPath = '') {
|
|
|
106
110
|
* @returns {string}
|
|
107
111
|
*/
|
|
108
112
|
export function buildMenuNav(menu, items, opts = {}) {
|
|
109
|
-
|
|
113
|
+
// The menu's own designer classes, then this placement's `[menu class=…]`.
|
|
114
|
+
const menuClasses = cssClasses(menu.class).join(' ');
|
|
115
|
+
const klass = [menuClasses, opts.class].filter(Boolean).map(c => ` ${esc(c)}`).join('');
|
|
110
116
|
const variantClass = opts.variant ? ` dm-menu--variant-${esc(opts.variant)}` : '';
|
|
111
117
|
|
|
112
118
|
// A menu that declares no orientation emits no class at all and keeps the
|
package/server/services/menus.js
CHANGED
|
@@ -19,7 +19,7 @@ import * as cache from './cache/index.js';
|
|
|
19
19
|
import {
|
|
20
20
|
HIGHLIGHT_HOVER, HIGHLIGHT_ACTIVE, MENU_ALIGN,
|
|
21
21
|
LEGACY_STYLE_HOVER, LEGACY_STYLE_ACTIVE, legacyStyleHighlight,
|
|
22
|
-
PRESET_COLOUR_VARS, PRESET_COLOUR_FALLBACKS, colourToCss
|
|
22
|
+
PRESET_COLOUR_VARS, PRESET_COLOUR_FALLBACKS, colourToCss, cssClassProblems
|
|
23
23
|
} from '../../public/js/menu-decor.mjs';
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -722,6 +722,8 @@ export function validateMenu(menu) {
|
|
|
722
722
|
}
|
|
723
723
|
}
|
|
724
724
|
}
|
|
725
|
+
// Designer classes on the menu's outer element - see cssClasses in menu-decor.mjs.
|
|
726
|
+
for (const p of cssClassProblems(menu.class)) errors.push(`class: ${p}`);
|
|
725
727
|
if (menu.align != null && menu.align !== '' && !MENU_ALIGN.includes(menu.align)) {
|
|
726
728
|
errors.push(`align must be one of: ${MENU_ALIGN.join(', ')}`);
|
|
727
729
|
}
|
|
@@ -815,6 +817,7 @@ export function validateMenu(menu) {
|
|
|
815
817
|
if (item.colour != null && !isValidColour(item.colour)) {
|
|
816
818
|
errors.push(`Item ${path}: colour must be a preset key or #rrggbb`);
|
|
817
819
|
}
|
|
820
|
+
for (const p of cssClassProblems(item.class)) errors.push(`Item ${path}: class: ${p}`);
|
|
818
821
|
// Per-item highlight override. Same shape and same value sets as
|
|
819
822
|
// the menu-level `highlight` block validated above - an item that
|
|
820
823
|
// omits a half falls through to the menu's choice for that half.
|