domma-cms 1.6.3 → 1.6.4
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/package.json
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import {getPermissionsFor, getPermissionsForRole, getRoleHierarchy, getRoleLevel} from '../services/roles.js';
|
|
7
7
|
import {getEffectiveLevel, getEffectiveRoles} from '../services/userRoles.js';
|
|
8
|
+
import {getUserById} from '../services/users.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Verify JWT Bearer token. Populates request.user on success.
|
|
11
12
|
*
|
|
13
|
+
* The token does not carry the user's project access scope, so it is read from
|
|
14
|
+
* the user record on every request and set as `request.user.projects` - every
|
|
15
|
+
* `canSeeArtefact()` check depends on it, and without it a scoped user looked
|
|
16
|
+
* unrestricted. Reading it here, not signing it into the token, means a change
|
|
17
|
+
* to someone's access applies to their next request.
|
|
18
|
+
*
|
|
12
19
|
* @param {FastifyRequest} request
|
|
13
20
|
* @param {FastifyReply} reply
|
|
14
21
|
* @returns {Promise<void>}
|
|
@@ -30,6 +37,8 @@ export async function authenticate(request, reply) {
|
|
|
30
37
|
message: 'Invalid or missing authentication token'
|
|
31
38
|
});
|
|
32
39
|
}
|
|
40
|
+
const user = request.user?.id ? await getUserById(request.user.id) : null;
|
|
41
|
+
request.user.projects = Array.isArray(user?.projects) ? user.projects : [];
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
/**
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from '../../services/previewLinks.js';
|
|
24
24
|
import {getVersionCount} from '../../services/versions.js';
|
|
25
25
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
26
|
+
import {canSeeArtefact, canSeePage} from '../../services/projects.js';
|
|
26
27
|
import {getConfig, saveConfig} from '../../config.js';
|
|
27
28
|
import {relinkItems, relinkMenus} from '../../services/menus.js';
|
|
28
29
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
@@ -113,7 +114,8 @@ export async function pagesRoutes(fastify) {
|
|
|
113
114
|
|
|
114
115
|
fastify.get('/pages/preview-links', canRead, async (request) => {
|
|
115
116
|
const {urlPath} = request.query || {};
|
|
116
|
-
|
|
117
|
+
const links = await listPreviewLinks(urlPath || undefined);
|
|
118
|
+
return {links: await visibleTo(request.user, links, l => l.urlPath)};
|
|
117
119
|
});
|
|
118
120
|
|
|
119
121
|
fastify.post('/pages/preview-links', canUpdate, async (request, reply) => {
|
|
@@ -123,9 +125,11 @@ export async function pagesRoutes(fastify) {
|
|
|
123
125
|
}
|
|
124
126
|
// Refuse to mint for a page that does not exist - otherwise a typo
|
|
125
127
|
// produces a link that 404s for the recipient and looks like a bug.
|
|
126
|
-
|
|
128
|
+
const target = await getPage(urlPath);
|
|
129
|
+
if (!target) {
|
|
127
130
|
return reply.status(404).send({error: 'Page not found'});
|
|
128
131
|
}
|
|
132
|
+
if (!(await canSeePage(request.user, urlPath, target.project))) return denied(reply);
|
|
129
133
|
const link = await createPreviewLink({
|
|
130
134
|
urlPath,
|
|
131
135
|
jwt: fastify.jwt,
|
|
@@ -141,6 +145,8 @@ export async function pagesRoutes(fastify) {
|
|
|
141
145
|
});
|
|
142
146
|
|
|
143
147
|
fastify.delete('/pages/preview-links/:id', canUpdate, async (request, reply) => {
|
|
148
|
+
const link = (await listPreviewLinks()).find(l => l.id === request.params.id);
|
|
149
|
+
if (link && !(await canSeePage(request.user, link.urlPath))) return denied(reply);
|
|
144
150
|
const ok = await revokePreviewLink(request.params.id);
|
|
145
151
|
if (!ok) return reply.status(404).send({error: 'Link not found'});
|
|
146
152
|
return {success: true};
|
|
@@ -148,7 +154,7 @@ export async function pagesRoutes(fastify) {
|
|
|
148
154
|
|
|
149
155
|
// Aggregate unique tags from all pages - must be registered before the wildcard /pages/* route
|
|
150
156
|
fastify.get('/pages/tags', canRead, async (request, reply) => {
|
|
151
|
-
const pages = await listPages();
|
|
157
|
+
const pages = (await listPages()).filter(p => inScope(request.user, p));
|
|
152
158
|
const tagSet = new Set();
|
|
153
159
|
pages.forEach(p => {
|
|
154
160
|
if (Array.isArray(p.tags)) p.tags.forEach(t => { if (t) tagSet.add(t); });
|
|
@@ -157,11 +163,12 @@ export async function pagesRoutes(fastify) {
|
|
|
157
163
|
});
|
|
158
164
|
|
|
159
165
|
// The sidebar badge. Static, so it is matched before the /pages/* wildcard below.
|
|
160
|
-
fastify.get('/pages/_count', canRead, async () =>
|
|
166
|
+
fastify.get('/pages/_count', canRead, async (request) =>
|
|
167
|
+
pagesBadge((await listPages()).filter(p => inScope(request.user, p))));
|
|
161
168
|
|
|
162
169
|
// List all pages
|
|
163
170
|
fastify.get('/pages', canRead, async (request, reply) => {
|
|
164
|
-
const pages = await listPages();
|
|
171
|
+
const pages = (await listPages()).filter(p => inScope(request.user, p));
|
|
165
172
|
const counts = await Promise.all(pages.map(p => getVersionCount(p.urlPath)));
|
|
166
173
|
return pages.map((p, i) => ({
|
|
167
174
|
urlPath: p.urlPath,
|
|
@@ -191,6 +198,7 @@ export async function pagesRoutes(fastify) {
|
|
|
191
198
|
const urlPath = '/' + request.params['*'];
|
|
192
199
|
const page = await getPage(urlPath);
|
|
193
200
|
if (!page) return reply.status(404).send({ error: 'Page not found' });
|
|
201
|
+
if (!(await canSeePage(request.user, urlPath, page.project))) return denied(reply);
|
|
194
202
|
return page;
|
|
195
203
|
});
|
|
196
204
|
|
|
@@ -214,6 +222,7 @@ export async function pagesRoutes(fastify) {
|
|
|
214
222
|
});
|
|
215
223
|
}
|
|
216
224
|
|
|
225
|
+
if (!(await canSeePage(request.user, urlPath, frontmatter?.project))) return denied(reply);
|
|
217
226
|
const page = await createPage(urlPath, frontmatter || {}, body || '');
|
|
218
227
|
return reply.status(201).send(page);
|
|
219
228
|
});
|
|
@@ -225,6 +234,10 @@ export async function pagesRoutes(fastify) {
|
|
|
225
234
|
|
|
226
235
|
const existing = await getPage(urlPath);
|
|
227
236
|
if (!existing) return reply.status(404).send({ error: 'Page not found' });
|
|
237
|
+
if (!(await canSeePage(request.user, urlPath, existing.project))) return denied(reply);
|
|
238
|
+
// Nor may a save move the page into a project outside the user's scope.
|
|
239
|
+
const project = frontmatter && 'project' in frontmatter ? frontmatter.project : existing.project;
|
|
240
|
+
if (!(await canSeePage(request.user, newUrlPath || urlPath, project))) return denied(reply);
|
|
228
241
|
|
|
229
242
|
if (newUrlPath && newUrlPath !== urlPath) {
|
|
230
243
|
const conflict = await getPage(newUrlPath);
|
|
@@ -249,6 +262,7 @@ export async function pagesRoutes(fastify) {
|
|
|
249
262
|
const urlPath = '/' + request.params['*'];
|
|
250
263
|
const existing = await getPage(urlPath);
|
|
251
264
|
if (!existing) return reply.status(404).send({ error: 'Page not found' });
|
|
265
|
+
if (!(await canSeePage(request.user, urlPath, existing.project))) return denied(reply);
|
|
252
266
|
|
|
253
267
|
await deletePage(urlPath);
|
|
254
268
|
// A share link must not outlive the page it pointed at.
|
|
@@ -257,6 +271,45 @@ export async function pagesRoutes(fastify) {
|
|
|
257
271
|
});
|
|
258
272
|
}
|
|
259
273
|
|
|
274
|
+
/**
|
|
275
|
+
* A listed page's project is already resolved (listPages sets resolvedProject).
|
|
276
|
+
*
|
|
277
|
+
* @param {object} user
|
|
278
|
+
* @param {object} page
|
|
279
|
+
* @returns {boolean}
|
|
280
|
+
*/
|
|
281
|
+
function inScope(user, page) {
|
|
282
|
+
return canSeeArtefact(user, {meta: {project: page.resolvedProject}});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Keep the items whose page `user` may see.
|
|
287
|
+
*
|
|
288
|
+
* @param {object} user
|
|
289
|
+
* @param {object[]} items
|
|
290
|
+
* @param {function(object): string} urlOf
|
|
291
|
+
* @returns {Promise<object[]>}
|
|
292
|
+
*/
|
|
293
|
+
async function visibleTo(user, items, urlOf) {
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const item of items) {
|
|
296
|
+
const page = await getPage(urlOf(item));
|
|
297
|
+
if (await canSeePage(user, urlOf(item), page?.project)) out.push(item);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The refusal every page route gives for a page outside the user's projects -
|
|
304
|
+
* the same wording as the collection routes.
|
|
305
|
+
*
|
|
306
|
+
* @param {FastifyReply} reply
|
|
307
|
+
* @returns {FastifyReply}
|
|
308
|
+
*/
|
|
309
|
+
function denied(reply) {
|
|
310
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
311
|
+
}
|
|
312
|
+
|
|
260
313
|
/**
|
|
261
314
|
* A page moved: re-aim the menu links that pointed at it. Menus replaced
|
|
262
315
|
* config/navigation.json; a site that still has the legacy file gets it rewritten
|
|
@@ -50,6 +50,16 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
50
50
|
const canUpdate = {preHandler: [authenticate, requirePermission('projects', 'update')]};
|
|
51
51
|
const canDelete = {preHandler: [authenticate, requirePermission('projects', 'delete')]};
|
|
52
52
|
|
|
53
|
+
// A project outside the user's access scope is not there for them: the same
|
|
54
|
+
// 404 the list's absence implies, as remove-contents already answers.
|
|
55
|
+
const inScope = async (request, reply) => {
|
|
56
|
+
const visible = await listProjectsForUser(request.user);
|
|
57
|
+
if (!visible.some(p => p.slug === request.params.slug)) {
|
|
58
|
+
return reply.status(404).send({error: 'Project not found'});
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const scoped = (guard) => ({preHandler: [...guard.preHandler, inScope]});
|
|
62
|
+
|
|
53
63
|
fastify.get('/projects', canRead, async (request) => {
|
|
54
64
|
const list = await listProjectsForUser(request.user);
|
|
55
65
|
if (request.query?.counts !== '1') return list;
|
|
@@ -64,7 +74,7 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
64
74
|
// Registered before /projects/:slug; `_` is outside the slug alphabet, so no project can shadow it.
|
|
65
75
|
fastify.get('/projects/_count', canRead, async (request) => projectsBadge(await listProjectsForUser(request.user)));
|
|
66
76
|
|
|
67
|
-
fastify.get('/projects/:slug', canRead, async (request, reply) => {
|
|
77
|
+
fastify.get('/projects/:slug', scoped(canRead), async (request, reply) => {
|
|
68
78
|
const project = await getProject(request.params.slug);
|
|
69
79
|
if (!project) return reply.status(404).send({error: 'Project not found'});
|
|
70
80
|
return project;
|
|
@@ -82,7 +92,7 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
82
92
|
}
|
|
83
93
|
});
|
|
84
94
|
|
|
85
|
-
fastify.put('/projects/:slug', canUpdate, async (request, reply) => {
|
|
95
|
+
fastify.put('/projects/:slug', scoped(canUpdate), async (request, reply) => {
|
|
86
96
|
try {
|
|
87
97
|
const project = await updateProject(request.params.slug, request.body || {});
|
|
88
98
|
await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
|
|
@@ -93,7 +103,7 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
93
103
|
}
|
|
94
104
|
});
|
|
95
105
|
|
|
96
|
-
fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
|
|
106
|
+
fastify.delete('/projects/:slug', scoped(canDelete), async (request, reply) => {
|
|
97
107
|
if (request.params.slug === CORE_PROJECT_SLUG) {
|
|
98
108
|
return reply.status(403).send({error: 'Cannot delete the core project'});
|
|
99
109
|
}
|
|
@@ -140,14 +150,14 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
140
150
|
}
|
|
141
151
|
});
|
|
142
152
|
|
|
143
|
-
fastify.get('/projects/:slug/artefacts', canRead, async (request, reply) => {
|
|
153
|
+
fastify.get('/projects/:slug/artefacts', scoped(canRead), async (request, reply) => {
|
|
144
154
|
if (!(await getProject(request.params.slug))) {
|
|
145
155
|
return reply.status(404).send({error: 'Project not found'});
|
|
146
156
|
}
|
|
147
157
|
return getArtefactsForProject(request.params.slug);
|
|
148
158
|
});
|
|
149
159
|
|
|
150
|
-
fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
|
|
160
|
+
fastify.post('/projects/:slug/untag-all', scoped(canDelete), async (request, reply) => {
|
|
151
161
|
if (request.params.slug === CORE_PROJECT_SLUG) {
|
|
152
162
|
return reply.status(403).send({error: 'Cannot untag the core project - artefacts would immediately resolve back to it'});
|
|
153
163
|
}
|
|
@@ -18,11 +18,28 @@ import {
|
|
|
18
18
|
restoreVersion
|
|
19
19
|
} from '../../services/versions.js';
|
|
20
20
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
21
|
+
import {getPage} from '../../services/content.js';
|
|
22
|
+
import {canSeePage} from '../../services/projects.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A page's versions belong to its project: refuse them to a user outside it,
|
|
26
|
+
* as the page routes do. Every route here ends in the page's path (`*`).
|
|
27
|
+
*
|
|
28
|
+
* @param {FastifyRequest} request
|
|
29
|
+
* @param {FastifyReply} reply
|
|
30
|
+
*/
|
|
31
|
+
async function pageInScope(request, reply) {
|
|
32
|
+
const urlPath = '/' + request.params['*'];
|
|
33
|
+
const page = await getPage(urlPath);
|
|
34
|
+
if (!(await canSeePage(request.user, urlPath, page?.project))) {
|
|
35
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
21
38
|
|
|
22
39
|
export async function versionsRoutes(fastify) {
|
|
23
|
-
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
24
|
-
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
|
|
25
|
-
const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
|
|
40
|
+
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read'), pageInScope]};
|
|
41
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update'), pageInScope]};
|
|
42
|
+
const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete'), pageInScope]};
|
|
26
43
|
|
|
27
44
|
// List versions for a page
|
|
28
45
|
fastify.get('/versions/list/*', canRead, async (request, reply) => {
|
|
@@ -405,6 +405,21 @@ export async function getProjectForPage(urlPath, explicitProject) {
|
|
|
405
405
|
return best ?? coreFallback;
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Can `user` see the page at `urlPath`? A page's project comes from its front
|
|
410
|
+
* matter or its address (getProjectForPage), then the same rule as every other
|
|
411
|
+
* artefact applies.
|
|
412
|
+
*
|
|
413
|
+
* @param {object} user
|
|
414
|
+
* @param {string} urlPath
|
|
415
|
+
* @param {string|null|undefined} explicitProject - the page's front matter `project`
|
|
416
|
+
* @returns {Promise<boolean>}
|
|
417
|
+
*/
|
|
418
|
+
export async function canSeePage(user, urlPath, explicitProject) {
|
|
419
|
+
const project = await getProjectForPage(urlPath, explicitProject);
|
|
420
|
+
return canSeeArtefact(user, {meta: {project}});
|
|
421
|
+
}
|
|
422
|
+
|
|
408
423
|
/** The per-type buckets every project's artefacts are grouped into. */
|
|
409
424
|
export function emptyGroups() {
|
|
410
425
|
return {
|