domma-cms 0.116.7 → 0.117.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/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/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/package.json +1 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +63 -7
- package/plugins/_lib/admin/mail/settings-section.js +32 -0
- 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/pagePublishing.js +85 -0
- package/server/services/permissionRegistry.js +1 -1
- package/server/services/search.js +31 -12
|
@@ -61,7 +61,7 @@ import {getConfig, saveConfig} from '../../config.js';
|
|
|
61
61
|
import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
|
|
62
62
|
import {ensureFormForCollection} from '../../services/forms.js';
|
|
63
63
|
import {hooks} from '../../services/hooks.js';
|
|
64
|
-
import {
|
|
64
|
+
import {authenticateApiToken, scopeAllows} from '../../services/apiTokens.js';
|
|
65
65
|
import {isProjectEnabled, resolveArtefactProject} from '../../services/projects.js';
|
|
66
66
|
import {maskConnections, restoreConnections} from '../../services/connectionSecrets.js';
|
|
67
67
|
|
|
@@ -206,23 +206,16 @@ export async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
206
206
|
|
|
207
207
|
// Token mode - project-scoped API token, strict: a token is the ONLY
|
|
208
208
|
// accepted credential here (a JWT never satisfies token mode, and a
|
|
209
|
-
// token never satisfies a role mode).
|
|
209
|
+
// token never satisfies a role mode). The check itself is shared with the
|
|
210
|
+
// Content API - authenticateApiToken() in services/apiTokens.js.
|
|
210
211
|
if (access.access === 'token') {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (!
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
if (token.project !== resolveArtefactProject(schema)) {
|
|
220
|
-
return reply.status(403).send({ error: "Token is not valid for this collection's project" });
|
|
221
|
-
}
|
|
222
|
-
if (!scopeAllows(token.scopes, schema.slug, operation)) {
|
|
223
|
-
return reply.status(403).send({ error: 'Token scope does not permit this operation' });
|
|
224
|
-
}
|
|
225
|
-
request.apiToken = token;
|
|
212
|
+
const auth = await authenticateApiToken(request, {
|
|
213
|
+
project: resolveArtefactProject(schema),
|
|
214
|
+
allows: (scopes) => scopeAllows(scopes, schema.slug, operation),
|
|
215
|
+
projectError: "Token is not valid for this collection's project"
|
|
216
|
+
});
|
|
217
|
+
if (!auth.ok) return reply.status(auth.status).send({error: auth.error});
|
|
218
|
+
request.apiToken = auth.token;
|
|
226
219
|
return;
|
|
227
220
|
}
|
|
228
221
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content API - admin routes (System › Content API)
|
|
3
|
+
*
|
|
4
|
+
* GET /api/content-api/settings - settings.read: config/content-api.json, normalised
|
|
5
|
+
* PUT /api/content-api/settings - settings.update: save it (a role the site does not have is refused)
|
|
6
|
+
* GET /api/content-api/_count - settings.read: the sidebar badge (contentApiBadge)
|
|
7
|
+
* GET /api/content-api/preview?path=/about&format=html|markdown|both&as=anon
|
|
8
|
+
* - settings.read: the screen's Try it. Exactly what an
|
|
9
|
+
* anonymous caller of /api/content/v1/pages<path> would
|
|
10
|
+
* get - through the same buildPageResponse() - whether
|
|
11
|
+
* or not the API is switched on yet, so a site can look
|
|
12
|
+
* before it publishes. A format the site does not offer
|
|
13
|
+
* still renders, with `offered: false`.
|
|
14
|
+
*
|
|
15
|
+
* The permission is `settings`, the same as the screen's sidebar entry, so whoever
|
|
16
|
+
* sees the entry can read its badge.
|
|
17
|
+
*/
|
|
18
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
19
|
+
import {listPages} from '../../services/content.js';
|
|
20
|
+
import {searchExclusions} from '../../services/search.js';
|
|
21
|
+
import {contentApiBadge, getContentApiSettings, saveContentApiSettings} from '../../services/contentApi.js';
|
|
22
|
+
import {getRoleLevel} from '../../services/roles.js';
|
|
23
|
+
import {buildPageResponse} from './content-public.js';
|
|
24
|
+
import {getBaseUrl} from '../public.js';
|
|
25
|
+
|
|
26
|
+
const FORMAT_VALUES = ['html', 'markdown', 'both'];
|
|
27
|
+
const ANONYMOUS = Object.freeze({mode: 'public', viewer: null, token: null});
|
|
28
|
+
|
|
29
|
+
export async function contentApiRoutes(fastify) {
|
|
30
|
+
const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
|
|
31
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('settings', 'update')]};
|
|
32
|
+
|
|
33
|
+
fastify.get('/content-api/settings', canRead, async () => getContentApiSettings());
|
|
34
|
+
|
|
35
|
+
fastify.put('/content-api/settings', canUpdate, async (request, reply) => {
|
|
36
|
+
const patch = request.body && typeof request.body === 'object' ? request.body : {};
|
|
37
|
+
// A role name that is not a role here would fail closed to level 0 at
|
|
38
|
+
// request time - safe, but a silent surprise. Say so at save instead.
|
|
39
|
+
const access = typeof patch.access === 'string' ? patch.access.trim() : '';
|
|
40
|
+
if (access && access !== 'public' && access !== 'token' && !Number.isFinite(getRoleLevel(access))) {
|
|
41
|
+
return reply.status(400).send({error: `"${access}" is not a role on this site`});
|
|
42
|
+
}
|
|
43
|
+
return {ok: true, settings: saveContentApiSettings(patch)};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
fastify.get('/content-api/_count', canRead, async () => {
|
|
47
|
+
const pages = await listPages();
|
|
48
|
+
return contentApiBadge(getContentApiSettings(), pages, await searchExclusions(pages));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
fastify.get('/content-api/preview', canRead, async (request, reply) => {
|
|
52
|
+
const q = request.query || {};
|
|
53
|
+
const path = String(q.path || '').trim();
|
|
54
|
+
if (!path.startsWith('/')) return reply.status(400).send({error: 'Give a page path starting with /'});
|
|
55
|
+
// Only the anonymous view for now: it is what public and token callers get.
|
|
56
|
+
if (q.as != null && q.as !== 'anon') return reply.status(400).send({error: 'Preview runs as an anonymous caller only (as=anon)'});
|
|
57
|
+
const format = q.format == null || q.format === '' ? 'html' : String(q.format);
|
|
58
|
+
if (!FORMAT_VALUES.includes(format)) return reply.status(400).send({error: `Unknown format "${format}"`});
|
|
59
|
+
|
|
60
|
+
const settings = getContentApiSettings();
|
|
61
|
+
const wanted = format === 'both' ? ['html', 'markdown'] : [format];
|
|
62
|
+
const res = await buildPageResponse(path, {access: ANONYMOUS, format});
|
|
63
|
+
return {
|
|
64
|
+
status: res.status,
|
|
65
|
+
body: res.body,
|
|
66
|
+
offered: wanted.every(f => settings.formats.includes(f)),
|
|
67
|
+
url: `${getBaseUrl(request)}/api/content/v1/pages${path}`
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content API - /api/content/v1
|
|
3
|
+
*
|
|
4
|
+
* Read-only headless access to what the site publishes:
|
|
5
|
+
*
|
|
6
|
+
* GET /api/content/v1/pages - paged page summaries
|
|
7
|
+
* GET /api/content/v1/pages/* - one page (?format=html|markdown|both)
|
|
8
|
+
* GET /api/content/v1/menus/:location - the menu bound to a location, filtered for the caller
|
|
9
|
+
* GET /api/content/v1/site - the site settings public pages already show
|
|
10
|
+
* GET /api/content/v1/search?q= - site search
|
|
11
|
+
*
|
|
12
|
+
* Its own prefix on purpose: /api/v1/:slug matches any collection slug, and
|
|
13
|
+
* slugify() happily produces `pages` and `menus`, so living there would
|
|
14
|
+
* shadow real collections.
|
|
15
|
+
*
|
|
16
|
+
* Access is config/content-api.json `access`:
|
|
17
|
+
* - 'public' - anyone
|
|
18
|
+
* - 'token' - a project API token, strict (never a JWT), bound to the
|
|
19
|
+
* page's project, narrowed by `{content}` scopes
|
|
20
|
+
* - a role - a Bearer access JWT at that role's level or more senior; an
|
|
21
|
+
* unknown role admits level 0 only. Never the dm_session
|
|
22
|
+
* cookie: that is read only by the page renderer, so no
|
|
23
|
+
* API answers to ambient credentials.
|
|
24
|
+
* Public and token callers are served as an anonymous visitor.
|
|
25
|
+
*
|
|
26
|
+
* Every page decision is resolvePublishedPage() - the function the public
|
|
27
|
+
* site uses - so the two can never disagree.
|
|
28
|
+
*/
|
|
29
|
+
import {getContentApiSettings, pageDetail, pageSummary, publicSiteSettings} from '../../services/contentApi.js';
|
|
30
|
+
import {canPreviewDrafts, resolvePublishedPage} from '../../services/pagePublishing.js';
|
|
31
|
+
import {getPage, getPageMtime, listPages} from '../../services/content.js';
|
|
32
|
+
import {getSearchSettings, isPubliclyListed, searchExclusions, searchPages} from '../../services/search.js';
|
|
33
|
+
import {resolveLocation} from '../../services/menus.js';
|
|
34
|
+
import {authenticateApiToken, contentScopeAllows} from '../../services/apiTokens.js';
|
|
35
|
+
import {getRoleLevel} from '../../services/roles.js';
|
|
36
|
+
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
37
|
+
import {getConfig} from '../../config.js';
|
|
38
|
+
|
|
39
|
+
const NOT_FOUND = {error: 'Not found'};
|
|
40
|
+
const DEFAULT_LIMIT = 50;
|
|
41
|
+
const FORMAT_VALUES = ['html', 'markdown', 'both'];
|
|
42
|
+
|
|
43
|
+
/** Menu slots the API never serves: the admin's own navigation, and page overlays. */
|
|
44
|
+
const PRIVATE_SLOTS = new Set(['admin-sidebar', 'overlay']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Gate one request. Resolves to `{mode, viewer, token, settings}`, or sends
|
|
48
|
+
* the refusal and resolves to null.
|
|
49
|
+
*
|
|
50
|
+
* Token mode binds no project here: pages carry their own, so the routes
|
|
51
|
+
* check `inProject()` per page. Menus and site settings are site-wide and
|
|
52
|
+
* already shown on every public page, so any valid token may read them.
|
|
53
|
+
*
|
|
54
|
+
* @param {'pages'|'menus'|'site'|'search'} resource
|
|
55
|
+
* @returns {Promise<object|null>}
|
|
56
|
+
*/
|
|
57
|
+
export async function checkContentAccess(resource, request, reply) {
|
|
58
|
+
const settings = getContentApiSettings();
|
|
59
|
+
if (!settings.enabled || !settings.resources[resource]?.enabled) {
|
|
60
|
+
reply.status(404).send(NOT_FOUND);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (settings.access === 'public') return {mode: 'public', viewer: null, token: null, settings};
|
|
65
|
+
|
|
66
|
+
if (settings.access === 'token') {
|
|
67
|
+
const auth = await authenticateApiToken(request, {allows: (scopes) => contentScopeAllows(scopes, resource)});
|
|
68
|
+
if (!auth.ok) {
|
|
69
|
+
reply.status(auth.status).send({error: auth.error});
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return {mode: 'token', viewer: null, token: auth.token, settings};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Role mode. jwtVerify reads the Authorization header only; a refresh
|
|
76
|
+
// token is refused like any other non-access token.
|
|
77
|
+
let decoded;
|
|
78
|
+
try {
|
|
79
|
+
decoded = await request.jwtVerify();
|
|
80
|
+
} catch {
|
|
81
|
+
decoded = null;
|
|
82
|
+
}
|
|
83
|
+
if (!decoded || decoded.type !== 'access') {
|
|
84
|
+
reply.status(401).send({error: 'Unauthorised'});
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
// A role name that is not a role on this site fails closed to level 0,
|
|
88
|
+
// as checkPublicAccess() and checkVisibility() do.
|
|
89
|
+
const named = getRoleLevel(settings.access);
|
|
90
|
+
const required = Number.isFinite(named) ? named : 0;
|
|
91
|
+
if (getEffectiveLevel(decoded) > required) {
|
|
92
|
+
reply.status(403).send({error: 'Insufficient permissions'});
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {mode: 'role', viewer: decoded, token: null, settings};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A token sees only its own project's pages; other modes see every project. */
|
|
99
|
+
const inProject = (access, project) => access.mode !== 'token' || access.token.project === project;
|
|
100
|
+
|
|
101
|
+
/** Frontmatter `api: false` keeps a page off the API; it still renders on the site. */
|
|
102
|
+
const apiOff = (page) => page?.api === false;
|
|
103
|
+
|
|
104
|
+
function cacheHeaders(reply, access, {draft = false} = {}) {
|
|
105
|
+
if (draft || access.mode === 'role') reply.header('cache-control', 'no-store');
|
|
106
|
+
else if (access.mode === 'token') reply.header('cache-control', 'private, no-cache');
|
|
107
|
+
else reply.header('cache-control', 'public, max-age=60');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Which body formats a request asked for, or an error when the site does not
|
|
112
|
+
* offer one of them.
|
|
113
|
+
*
|
|
114
|
+
* @returns {{format: string} | {error: string}}
|
|
115
|
+
*/
|
|
116
|
+
function pickFormat(raw, settings) {
|
|
117
|
+
const format = raw == null || raw === '' ? 'html' : String(raw);
|
|
118
|
+
if (!FORMAT_VALUES.includes(format)) return {error: `Unknown format "${format}" - use html, markdown or both`};
|
|
119
|
+
const wanted = format === 'both' ? ['html', 'markdown'] : [format];
|
|
120
|
+
if (!wanted.every(f => settings.formats.includes(f))) return {error: `Format "${format}" is not available on this site`};
|
|
121
|
+
return {format};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One page as the API serves it - the decision and the payload, without
|
|
126
|
+
* caching or replies. The admin's Try it console (routes/api/content-api.js)
|
|
127
|
+
* calls this too, so it shows exactly what a caller would get.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} urlPath
|
|
130
|
+
* @param {{access: object, format: string, allowDrafts?: boolean}} opts
|
|
131
|
+
* @returns {Promise<{status: 200|403|404, body: object, page?: object, isDraft?: boolean}>}
|
|
132
|
+
*/
|
|
133
|
+
export async function buildPageResponse(urlPath, {access, format, allowDrafts = false}) {
|
|
134
|
+
const verdict = await resolvePublishedPage(urlPath, access.viewer, {allowDrafts});
|
|
135
|
+
if (verdict.status === 'forbidden') return {status: 403, body: {error: 'Forbidden'}};
|
|
136
|
+
if (verdict.status !== 'ok' || apiOff(verdict.page) || !inProject(access, verdict.project)) {
|
|
137
|
+
return {status: 404, body: NOT_FOUND};
|
|
138
|
+
}
|
|
139
|
+
// The verdict's page was parsed anonymously. A signed-in caller gets the
|
|
140
|
+
// body rendered for them, as the site does - [menu] and row-scoped [view].
|
|
141
|
+
const page = access.viewer
|
|
142
|
+
? (await getPage(verdict.page.urlPath, {user: access.viewer})) || verdict.page
|
|
143
|
+
: verdict.page;
|
|
144
|
+
return {status: 200, body: pageDetail(page, {format}), page, isDraft: verdict.isDraft};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const SORTS = {
|
|
148
|
+
title: (a, b) => String(a.title || '').localeCompare(String(b.title || '')),
|
|
149
|
+
updatedAt: (a, b) => String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')),
|
|
150
|
+
createdAt: (a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || ''))
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const intParam = (raw, fallback) => {
|
|
154
|
+
const n = parseInt(raw, 10);
|
|
155
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Allowlisted menu items: what a navbar needs to draw them, nothing of their gating. */
|
|
159
|
+
function menuItems(items = []) {
|
|
160
|
+
return items.map(item => {
|
|
161
|
+
const out = {};
|
|
162
|
+
for (const k of ['type', 'text', 'url', 'icon', 'target', 'size', 'badge']) {
|
|
163
|
+
if (item[k] !== undefined) out[k] = item[k];
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(item.items) && item.items.length) out.items = menuItems(item.items);
|
|
166
|
+
return out;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function contentPublicRoutes(fastify) {
|
|
171
|
+
// This prefix is exempt from the global /api no-store hook, so anything a
|
|
172
|
+
// route did not mark itself - every refusal - is no-store here. A 404 with
|
|
173
|
+
// no Cache-Control is heuristically cacheable, and would outlive the
|
|
174
|
+
// admin switching the API on. (Scoped: a hook in this plugin reaches only
|
|
175
|
+
// these routes.)
|
|
176
|
+
fastify.addHook('onSend', async (request, reply) => {
|
|
177
|
+
if (!reply.getHeader('cache-control')) reply.header('cache-control', 'no-store');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// -------------------------------------------------------------------------
|
|
181
|
+
// Pages
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
fastify.get('/content/v1/pages', async (request, reply) => {
|
|
185
|
+
const access = await checkContentAccess('pages', request, reply);
|
|
186
|
+
if (!access) return;
|
|
187
|
+
if (!access.settings.listable) return reply.status(404).send(NOT_FOUND);
|
|
188
|
+
|
|
189
|
+
const q = request.query || {};
|
|
190
|
+
const limit = Math.min(access.settings.maxLimit, intParam(q.limit, DEFAULT_LIMIT));
|
|
191
|
+
const pageNo = intParam(q.page, 1);
|
|
192
|
+
|
|
193
|
+
const all = await listPages();
|
|
194
|
+
const exclusions = await searchExclusions(all);
|
|
195
|
+
let rows = all.filter(p => isPubliclyListed(p, access.viewer, exclusions)
|
|
196
|
+
&& !apiOff(p)
|
|
197
|
+
&& inProject(access, p.resolvedProject));
|
|
198
|
+
if (q.prefix) rows = rows.filter(p => String(p.urlPath || '').startsWith(String(q.prefix)));
|
|
199
|
+
if (q.tag) rows = rows.filter(p => Array.isArray(p.tags) && p.tags.includes(String(q.tag)));
|
|
200
|
+
if (q.category) rows = rows.filter(p => p.category === String(q.category));
|
|
201
|
+
if (q.project) rows = rows.filter(p => p.resolvedProject === String(q.project));
|
|
202
|
+
// Default order is listPages' own (sortOrder, then title).
|
|
203
|
+
if (SORTS[q.sort]) rows = [...rows].sort(SORTS[q.sort]);
|
|
204
|
+
if (q.order === 'desc') rows = [...rows].reverse();
|
|
205
|
+
|
|
206
|
+
cacheHeaders(reply, access);
|
|
207
|
+
const start = (pageNo - 1) * limit;
|
|
208
|
+
return {pages: rows.slice(start, start + limit).map(pageSummary), total: rows.length, page: pageNo, limit};
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
fastify.get('/content/v1/pages/*', async (request, reply) => {
|
|
212
|
+
const access = await checkContentAccess('pages', request, reply);
|
|
213
|
+
if (!access) return;
|
|
214
|
+
const picked = pickFormat(request.query?.format, access.settings);
|
|
215
|
+
if (picked.error) return reply.status(400).send({error: picked.error});
|
|
216
|
+
const {format} = picked;
|
|
217
|
+
|
|
218
|
+
const urlPath = '/' + (request.params['*'] || '');
|
|
219
|
+
// Drafts only for a role-mode caller who could read them in the admin.
|
|
220
|
+
const allowDrafts = access.mode === 'role' && request.query?.preview === '1' && canPreviewDrafts(access.viewer);
|
|
221
|
+
|
|
222
|
+
// A signed-in or draft render is that caller's alone and never cached.
|
|
223
|
+
if (access.viewer) {
|
|
224
|
+
const res = await buildPageResponse(urlPath, {access, format, allowDrafts});
|
|
225
|
+
cacheHeaders(reply, access, {draft: res.isDraft});
|
|
226
|
+
return reply.status(res.status).send(res.body);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Anonymous callers (public and token). No response cache: the access
|
|
230
|
+
// decision has to parse the page anyway - exactly as the site's does -
|
|
231
|
+
// and the JSON is built straight from that parse. HTTP caching does the
|
|
232
|
+
// rest; the mtime ETag changes on any edit to the file, however made.
|
|
233
|
+
const res = await buildPageResponse(urlPath, {access, format});
|
|
234
|
+
if (res.status !== 200) return reply.status(res.status).send(res.body);
|
|
235
|
+
const mtime = await getPageMtime(res.page.urlPath || urlPath);
|
|
236
|
+
cacheHeaders(reply, access);
|
|
237
|
+
reply.header('etag', `W/"${mtime}-${format}"`);
|
|
238
|
+
return res.body;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// -------------------------------------------------------------------------
|
|
242
|
+
// Menus, site settings, search
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
fastify.get('/content/v1/menus/:location', async (request, reply) => {
|
|
246
|
+
const access = await checkContentAccess('menus', request, reply);
|
|
247
|
+
if (!access) return;
|
|
248
|
+
const location = String(request.params.location || '');
|
|
249
|
+
if (PRIVATE_SLOTS.has(location) || location.startsWith('admin')) return reply.status(404).send(NOT_FOUND);
|
|
250
|
+
|
|
251
|
+
// ?page= lets page-bound menus resolve as they would on that page.
|
|
252
|
+
const urlPath = request.query?.page ? String(request.query.page) : undefined;
|
|
253
|
+
const menu = await resolveLocation(location, access.viewer, urlPath ? {urlPath} : {});
|
|
254
|
+
if (!menu) return reply.status(404).send(NOT_FOUND);
|
|
255
|
+
|
|
256
|
+
cacheHeaders(reply, access);
|
|
257
|
+
return {location, slug: menu.slug, name: menu.name, items: menuItems(menu.items)};
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
fastify.get('/content/v1/site', async (request, reply) => {
|
|
261
|
+
const access = await checkContentAccess('site', request, reply);
|
|
262
|
+
if (!access) return;
|
|
263
|
+
let site = {};
|
|
264
|
+
try { site = getConfig('site') || {}; } catch { /* no site.json - nothing to show */ }
|
|
265
|
+
cacheHeaders(reply, access);
|
|
266
|
+
return publicSiteSettings(site);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
fastify.get('/content/v1/search', async (request, reply) => {
|
|
270
|
+
const access = await checkContentAccess('search', request, reply);
|
|
271
|
+
if (!access) return;
|
|
272
|
+
const settings = getSearchSettings();
|
|
273
|
+
// Search switched off for the site is off for the API too.
|
|
274
|
+
if (!settings.enabled) return reply.status(404).send(NOT_FOUND);
|
|
275
|
+
|
|
276
|
+
const q = String(request.query?.q || '').trim().slice(0, 200);
|
|
277
|
+
if (q.length < settings.minQueryLength) {
|
|
278
|
+
return reply.status(400).send({error: `Query must be at least ${settings.minQueryLength} characters`});
|
|
279
|
+
}
|
|
280
|
+
const maxResults = Math.min(settings.maxResults, intParam(request.query?.limit, settings.maxResults));
|
|
281
|
+
|
|
282
|
+
const all = await listPages();
|
|
283
|
+
const pages = all.filter(p => !apiOff(p) && inProject(access, p.resolvedProject));
|
|
284
|
+
const results = searchPages(pages, q, {
|
|
285
|
+
maxResults,
|
|
286
|
+
viewer: access.viewer,
|
|
287
|
+
exclusions: await searchExclusions(all),
|
|
288
|
+
onError: (err, page) => request.log.warn({err, page: page.urlPath}, '[content-api] search skipped an unreadable page')
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Search results are per viewer in role mode; never shared there.
|
|
292
|
+
cacheHeaders(reply, access);
|
|
293
|
+
return {
|
|
294
|
+
results: results.map(r => ({
|
|
295
|
+
urlPath: r.url, title: r.title, description: r.description, tags: r.tags, snippet: r.snippet, score: r.score
|
|
296
|
+
}))
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}
|
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).
|