domma-cms 1.16.0 → 1.16.2

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.
@@ -5,7 +5,7 @@
5
5
  * GET /api/menus/_count - the sidebar badge (the underscore can never be a slug)
6
6
  * GET /api/menus/:slug - full menu
7
7
  * POST /api/menus - create
8
- * PUT /api/menus/:slug - replace
8
+ * PUT /api/menus/:slug - replace (409 if `revision` is given and the menu has changed since)
9
9
  * DELETE /api/menus/:slug - delete (409 if mapped to a slot)
10
10
  * POST /api/menus/:slug/duplicate - copy as <slug>-copy
11
11
  *
@@ -26,6 +26,7 @@ import {
26
26
  getLocations,
27
27
  getMenu,
28
28
  listMenus,
29
+ menuRevision,
29
30
  updateMenu
30
31
  } from '../../services/menus.js';
31
32
  import * as cache from '../../services/cache/index.js';
@@ -110,7 +111,8 @@ export async function menusRoutes(fastify, opts = {}) {
110
111
  if (!canSeeArtefact(request.user, menu)) {
111
112
  return reply.status(403).send({error: 'Access denied for this project'});
112
113
  }
113
- return menu;
114
+ // The fingerprint the editor sends back on save (see menuRevision).
115
+ return {...menu, revision: menuRevision(menu)};
114
116
  });
115
117
 
116
118
  fastify.post('/menus', canCreate, async (request, reply) => {
@@ -126,10 +128,12 @@ export async function menusRoutes(fastify, opts = {}) {
126
128
 
127
129
  fastify.put('/menus/:slug', canUpdate, async (request, reply) => {
128
130
  try {
129
- const menu = await updateMenu(request.params.slug, request.body || {});
131
+ const {revision, ...body} = request.body || {};
132
+ const menu = await updateMenu(request.params.slug, body, {baseRevision: revision});
130
133
  await cache.invalidateTags([`menu:${menu.slug}`, 'nav']);
131
- return menu;
134
+ return {...menu, revision: menuRevision(menu)};
132
135
  } catch (err) {
136
+ if (err.code === 'MENU_STALE') return reply.status(409).send({error: err.message, stale: true});
133
137
  const status = /not found/.test(err.message) ? 404 : 400;
134
138
  return reply.status(status).send({error: err.message});
135
139
  }
@@ -8,6 +8,7 @@
8
8
  * concept from the map) is held in process memory and seeded by core +
9
9
  * plugin hooks at startup.
10
10
  */
11
+ import {createHash} from 'crypto';
11
12
  import fs from 'fs/promises';
12
13
  import path from 'path';
13
14
  import {fileURLToPath} from 'url';
@@ -645,18 +646,42 @@ export async function createMenu(input) {
645
646
  return menu;
646
647
  }
647
648
 
649
+ /**
650
+ * A short fingerprint of a menu as stored. The editor sends back the one it
651
+ * loaded, so a save over a menu changed since - in another tab, by someone
652
+ * else, or by hand on the server - is caught instead of quietly undone. It
653
+ * hashes the whole file, not meta.updatedAt, because a hand edit leaves that
654
+ * date alone.
655
+ *
656
+ * @param {object} menu
657
+ * @returns {string}
658
+ */
659
+ export function menuRevision(menu) {
660
+ return createHash('sha256').update(JSON.stringify(menu)).digest('hex').slice(0, 16);
661
+ }
662
+
648
663
  /**
649
664
  * Replace an existing menu. Preserves meta.createdAt; updates meta.updatedAt.
650
665
  * The slug in the path wins over any slug in the body.
651
666
  *
667
+ * `opts.baseRevision` is the revision the caller loaded (menuRevision); when
668
+ * given and the menu has changed since, nothing is written and the error
669
+ * carries `code: 'MENU_STALE'`. Leave it out to overwrite regardless.
670
+ *
652
671
  * @param {string} slug
653
672
  * @param {object} input
673
+ * @param {{baseRevision?: string}} [opts]
654
674
  * @returns {Promise<object>} the persisted menu
655
- * @throws {Error} if the menu does not exist
675
+ * @throws {Error} if the menu does not exist, or has changed since baseRevision
656
676
  */
657
- export async function updateMenu(slug, input) {
677
+ export async function updateMenu(slug, input, opts = {}) {
658
678
  const existing = await getMenu(slug);
659
679
  if (!existing) throw new Error(`Menu "${slug}" not found`);
680
+ if (opts.baseRevision && opts.baseRevision !== menuRevision(existing)) {
681
+ const err = new Error(`Menu "${slug}" has changed since it was opened`);
682
+ err.code = 'MENU_STALE';
683
+ throw err;
684
+ }
660
685
  const binding = normaliseBinding(input.binding);
661
686
  const errors = validateMenu({...input, slug, binding});
662
687
  if (errors.length) {