domma-cms 1.6.2 → 1.6.3

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.2",
3
+ "version": "1.6.3",
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",
@@ -345,6 +345,15 @@ export async function analyticsRoutes(fastify, opts = {}) {
345
345
 
346
346
  await migrateLegacy();
347
347
 
348
+ // Serialise read/modify/write operations so parallel beacons cannot lose
349
+ // counts or race over the same atomic-write temporary file.
350
+ let writeQueue = Promise.resolve();
351
+ const withAnalyticsWrite = (operation) => {
352
+ const result = writeQueue.then(operation);
353
+ writeQueue = result.catch(() => {});
354
+ return result;
355
+ };
356
+
348
357
  fastify.decorate('analytics', {
349
358
  async getStats({ range = '7d', from, to } = {}) {
350
359
  const dates = rangeToDates(range, from, to);
@@ -408,33 +417,35 @@ export async function analyticsRoutes(fastify, opts = {}) {
408
417
  }
409
418
  }
410
419
 
411
- const today = todayKey();
412
- const [lifetime, daily, journeys] = await Promise.all([
413
- readJson(LIFETIME_FILE, {}),
414
- readJson(DAILY_FILE, {}),
415
- readJson(JOURNEYS_FILE, {})
416
- ]);
420
+ return withAnalyticsWrite(async () => {
421
+ const today = todayKey();
422
+ const [lifetime, daily, journeys] = await Promise.all([
423
+ readJson(LIFETIME_FILE, {}),
424
+ readJson(DAILY_FILE, {}),
425
+ readJson(JOURNEYS_FILE, {})
426
+ ]);
417
427
 
418
- lifetime[normalised] = (lifetime[normalised] || 0) + 1;
419
- if (!daily[today]) daily[today] = {};
420
- daily[today][normalised] = (daily[today][normalised] || 0) + 1;
421
-
422
- // Journey event - only if sid is a string of reasonable shape, and under cap
423
- const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
424
- const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
425
- if (safeSid) {
426
- if (!journeys[today]) journeys[today] = [];
427
- if (shouldRecordJourney(journeys[today])) {
428
- journeys[today].push({ sid: safeSid, t: Date.now(), url: normalised, ref: safeRef });
428
+ lifetime[normalised] = (lifetime[normalised] || 0) + 1;
429
+ if (!daily[today]) daily[today] = {};
430
+ daily[today][normalised] = (daily[today][normalised] || 0) + 1;
431
+
432
+ // Journey event - only if sid is a string of reasonable shape, and under cap
433
+ const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
434
+ const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
435
+ if (safeSid) {
436
+ if (!journeys[today]) journeys[today] = [];
437
+ if (shouldRecordJourney(journeys[today])) {
438
+ journeys[today].push({ sid: safeSid, t: Date.now(), url: normalised, ref: safeRef });
439
+ }
429
440
  }
430
- }
431
441
 
432
- await Promise.all([
433
- writeJson(LIFETIME_FILE, lifetime),
434
- writeJson(DAILY_FILE, daily),
435
- writeJson(JOURNEYS_FILE, journeys)
436
- ]);
437
- return { ok: true };
442
+ await Promise.all([
443
+ writeJson(LIFETIME_FILE, lifetime),
444
+ writeJson(DAILY_FILE, daily),
445
+ writeJson(JOURNEYS_FILE, journeys)
446
+ ]);
447
+ return { ok: true };
448
+ });
438
449
  });
439
450
 
440
451
  fastify.get('/stats', { preHandler: [authenticate, requireAdmin] }, async (request) => {
@@ -544,17 +555,19 @@ export async function analyticsRoutes(fastify, opts = {}) {
544
555
  });
545
556
 
546
557
  fastify.delete('/stats', { preHandler: [authenticate, requireAdmin] }, async () => {
547
- await Promise.all([
548
- writeJson(LIFETIME_FILE, {}),
549
- writeJson(DAILY_FILE, {}),
550
- writeJson(JOURNEYS_FILE, {})
551
- ]);
552
- try {
553
- await fs.unlink(LEGACY_FILE);
554
- } catch {
555
- // ignore
556
- }
557
- return { ok: true };
558
+ return withAnalyticsWrite(async () => {
559
+ await Promise.all([
560
+ writeJson(LIFETIME_FILE, {}),
561
+ writeJson(DAILY_FILE, {}),
562
+ writeJson(JOURNEYS_FILE, {})
563
+ ]);
564
+ try {
565
+ await fs.unlink(LEGACY_FILE);
566
+ } catch {
567
+ // ignore
568
+ }
569
+ return { ok: true };
570
+ });
558
571
  });
559
572
  }
560
573
 
@@ -1690,6 +1690,8 @@ export function scrubCodeRegions(markdown) {
1690
1690
 
1691
1691
  // HTML <pre><code>...</code></pre> blocks (rendered by earlier marked.parse calls inside accordion/carousel etc.)
1692
1692
  let scrubbed = markdown.replace(/<pre\b[^>]*>[\s\S]*?<\/pre>/gi, placeholder);
1693
+ // Collection bodies may already be HTML: inline examples remain literal too.
1694
+ scrubbed = scrubbed.replace(/<code\b[^>]*>[\s\S]*?<\/code>/gi, placeholder);
1693
1695
  // Fenced code blocks (``` or ~~~, with optional language tag)
1694
1696
  scrubbed = scrubbed.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[ \t]*$/gm, placeholder);
1695
1697
  // Inline code spans (single or multiple backticks, non-greedy)
@@ -4,8 +4,8 @@ import {isGenuinelyLocal} from './network.js';
4
4
  export function isBrowserAsset(request) {
5
5
  if (!['GET', 'HEAD'].includes(request.method)) return false;
6
6
  const path = request.url.split('?')[0];
7
- return /^\/(?:admin|plugins|dist|public)\//.test(path)
8
- && /\.(?:m?js|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|html)$/i.test(path);
7
+ return /^\/(?:admin|plugins|dist|public|media)\//.test(path)
8
+ && /\.(?:m?js|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|html|pdf|mp4|webm|mp3|wav|ogg)$/i.test(path);
9
9
  }
10
10
 
11
11
  export function rateLimitOptions({publicMax = 500, authenticatedMax = 3000} = {}) {
@@ -28,7 +28,9 @@ export function rateLimitOptions({publicMax = 500, authenticatedMax = 3000} = {}
28
28
  }
29
29
  } catch { /* invalid tokens retain the public limit */ }
30
30
  }
31
- return request.ip;
31
+ // Page crawls and their background API calls have separate budgets.
32
+ const path = request.url.split('?')[0];
33
+ return path.startsWith('/api/') ? request.ip : `page:${request.ip}`;
32
34
  },
33
35
  max: request => identities.has(request) ? authenticatedMax : publicMax,
34
36
  allowList: request => isGenuinelyLocal(request)