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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.3",
3
+ "version": "1.6.4",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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
- return {links: await listPreviewLinks(urlPath || undefined)};
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
- if (!(await getPage(urlPath))) {
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 () => pagesBadge(await listPages()));
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 {