domma-cms 1.6.35 → 1.6.37

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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/compose-window.js +4 -0
  3. package/plugins/_lib/admin/mail/mail.css +1 -1
  4. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  5. package/plugins/_lib/call/ice.js +5 -2
  6. package/plugins/_lib/call/rooms.js +9 -3
  7. package/plugins/_lib/call/routes.js +7 -4
  8. package/plugins/_lib/mail/compose.js +7 -2
  9. package/plugins/_lib/mail/defaults.js +12 -0
  10. package/plugins/_lib/mail/hostGuard.js +86 -0
  11. package/plugins/_lib/mail/message-action.js +56 -0
  12. package/plugins/_lib/mail/readRoutes.js +20 -1
  13. package/plugins/_lib/mail/scheduler.js +2 -0
  14. package/plugins/_lib/mail/send.js +12 -3
  15. package/plugins/_lib/mail/store.js +16 -6
  16. package/plugins/_lib/outreach/index.js +1 -1
  17. package/plugins/_lib/outreach/ledger.js +10 -3
  18. package/plugins/_lib/outreach/sending.js +9 -3
  19. package/plugins/_lib/outreach/suppression.js +14 -3
  20. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  21. package/plugins/_lib/public/call/call.js +3 -1
  22. package/plugins/blog/CLAUDE.md +11 -1
  23. package/plugins/blog/docs/guide.md +1 -1
  24. package/plugins/blog/lib/page.js +32 -0
  25. package/plugins/blog/plugin.js +58 -16
  26. package/plugins/blog/plugin.json +2 -2
  27. package/plugins/blog/plugin.public.js +5 -4
  28. package/plugins/blog/tests/public.test.js +30 -1
  29. package/plugins/booking/CLAUDE.md +4 -1
  30. package/plugins/booking/docs/guide.md +1 -1
  31. package/plugins/booking/plugin.js +16 -21
  32. package/plugins/booking/plugin.json +2 -2
  33. package/plugins/booking/tests/api.test.js +26 -1
  34. package/plugins/feedback/CLAUDE.md +7 -2
  35. package/plugins/feedback/admin/views/feedback.js +1 -1
  36. package/plugins/feedback/lib/receiver.js +1 -1
  37. package/plugins/feedback/plugin.json +3 -3
  38. package/plugins/free-tier.lock.json +42 -41
  39. package/plugins/mail-reader/CLAUDE.md +6 -0
  40. package/plugins/mail-reader/docs/guide.md +1 -0
  41. package/plugins/mail-reader/plugin.json +2 -2
  42. package/plugins/shopping-cart/CLAUDE.md +17 -1
  43. package/plugins/shopping-cart/docs/guide.md +1 -1
  44. package/plugins/shopping-cart/lib/accounts.js +8 -3
  45. package/plugins/shopping-cart/lib/payments.js +7 -4
  46. package/plugins/shopping-cart/lib/shop.js +8 -3
  47. package/plugins/shopping-cart/plugin.js +77 -41
  48. package/plugins/shopping-cart/plugin.json +2 -2
  49. package/plugins/shopping-cart/public/account.js +2 -2
  50. package/plugins/shopping-cart/tests/accounts.test.js +10 -1
  51. package/plugins/shopping-cart/tests/api.test.js +23 -0
  52. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  53. package/plugins/support/CLAUDE.md +6 -0
  54. package/plugins/support/admin/views/support.js +1 -1
  55. package/plugins/support/docs/guide.md +90 -0
  56. package/plugins/support/lib/receiver.js +1 -1
  57. package/plugins/support/plugin.json +3 -3
  58. package/server/routes/api/analytics.js +32 -0
  59. package/server/routes/api/collections.js +148 -15
  60. package/server/routes/outreach-public.js +54 -10
  61. package/server/server.js +5 -1
  62. package/server/services/authChecks.js +8 -0
  63. package/server/services/collectionAccess.js +191 -0
  64. package/server/services/collections.js +8 -2
  65. package/server/services/request-log.js +70 -0
@@ -12,4 +12,4 @@ export {createLedger} from './ledger.js';
12
12
  export {createSuppression, normaliseEmail} from './suppression.js';
13
13
  export {escapeHtml, escapeAttr, outreachBaseUrl, renderInvite, sendBatch} from './sending.js';
14
14
  export {dayOf, codeState, closedReason} from './expiry.js';
15
- export {UNSUBSCRIBE_PATH, mintSecret, signEmail, unsubscribeUrl, verifyUnsubscribe} from './unsubscribe.js';
15
+ export {UNSUBSCRIBE_PATH, mintSecret, signEmail, unsubscribeHeaders, unsubscribeUrl, verifyUnsubscribe} from './unsubscribe.js';
@@ -25,8 +25,15 @@ export function createLedger(col, {slug = 'outreach-invites', refField = 'ref',
25
25
  return entries;
26
26
  }
27
27
 
28
- async function invitesFor(refId) {
29
- return (await allInvites()).filter(e => e.data[refField] === refId);
28
+ /**
29
+ * Rows for one campaign/survey/journey. The filter does the narrowing in
30
+ * the adapter (a Mongo query, or the file adapter's in-memory match) so a
31
+ * per-hit lookup no longer reads every invite on the site; the in-memory
32
+ * filter stays because a test's fake store ignores `opts.filter`.
33
+ */
34
+ async function invitesFor(refId, extra = {}) {
35
+ const {entries} = await col.listEntries(slug, {limit: 100000, filter: {[refField]: refId, ...extra}});
36
+ return entries.filter(e => e.data[refField] === refId);
30
37
  }
31
38
 
32
39
  /** Issue pending invites for contacts that don't already have one. Returns [{id, contactId, token}]. */
@@ -83,7 +90,7 @@ export function createLedger(col, {slug = 'outreach-invites', refField = 'ref',
83
90
  /** Find an invite by submitted code (any format). Null if unknown. */
84
91
  async function findByCode(refId, code) {
85
92
  const hash = hashToken(normaliseCode(code));
86
- const invites = await invitesFor(refId);
93
+ const invites = await invitesFor(refId, {tokenHash: hash});
87
94
  return invites.find(e => e.data.tokenHash === hash) ?? null;
88
95
  }
89
96
 
@@ -97,11 +97,15 @@ export function renderInvite(template, contact, link, opts = {}) {
97
97
  *
98
98
  * @param {Array<{invite: {id: string}, contact: {name?: string, email: string}, token: string}>} recipients
99
99
  * @param {{subject?: string, body?: string}} template
100
- * @param {object} deps {sendEmail, transport, from, fromName, linkFor(recipient), suppressed?: Set<string>, render?: opts for renderInvite}
100
+ * `headersFor(recipient)` returns extra message headers - the List-Unsubscribe
101
+ * pair (unsubscribe.js unsubscribeHeaders) when the message carries an
102
+ * unsubscribe link - so a mail client can offer its own opt-out button.
103
+ *
104
+ * @param {object} deps {sendEmail, transport, from, fromName, linkFor(recipient), suppressed?: Set<string>, render?: opts for renderInvite, headersFor?: (recipient) => object}
101
105
  * @returns {Promise<Array<{inviteId: string, ok: boolean, suppressed?: true}>>}
102
106
  */
103
107
  export async function sendBatch(recipients, template, deps) {
104
- const {sendEmail, transport, from, fromName, linkFor, suppressed = null, render = {}} = deps;
108
+ const {sendEmail, transport, from, fromName, linkFor, suppressed = null, render = {}, headersFor = null} = deps;
105
109
  const results = [];
106
110
  for (const r of recipients) {
107
111
  if (suppressed && suppressed.has(normaliseEmail(r.contact.email))) {
@@ -110,8 +114,10 @@ export async function sendBatch(recipients, template, deps) {
110
114
  }
111
115
  const link = linkFor(r);
112
116
  const msg = renderInvite(template, r.contact, link, render);
117
+ const headers = typeof headersFor === 'function' ? headersFor(r) : null;
113
118
  try {
114
- await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text});
119
+ await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text,
120
+ ...(headers && Object.keys(headers).length ? {headers} : {})});
115
121
  results.push({inviteId: r.invite.id, ok: true});
116
122
  } catch {
117
123
  results.push({inviteId: r.invite.id, ok: false});
@@ -30,17 +30,28 @@ export function createSuppression(col, {slug = 'outreach-unsubscribes'} = {}) {
30
30
  return new Set((await rows()).map(e => normaliseEmail(e.data.email)).filter(Boolean));
31
31
  }
32
32
 
33
+ /**
34
+ * The rows for one address, narrowed by the adapter: `_contains` is
35
+ * case-insensitive on both adapters (older rows may not be lower-cased),
36
+ * and the exact comparison here keeps a fake store that ignores the
37
+ * filter honest. One address, one small read - not the whole list.
38
+ */
39
+ async function rowsFor(want) {
40
+ const {entries} = await col.listEntries(slug, {limit: 100000, filter: {email_contains: want}}).catch(() => ({entries: []}));
41
+ return entries.filter(e => normaliseEmail(e.data.email) === want);
42
+ }
43
+
33
44
  async function isSuppressed(email) {
34
45
  const want = normaliseEmail(email);
35
46
  if (!want) return false;
36
- return (await suppressedSet()).has(want);
47
+ return (await rowsFor(want)).length > 0;
37
48
  }
38
49
 
39
50
  /** Add an address (idempotent). `source` says which Tool took the opt-out. */
40
51
  async function add(email, source = 'outreach') {
41
52
  const want = normaliseEmail(email);
42
53
  if (!want) return null;
43
- const existing = (await rows()).find(e => normaliseEmail(e.data.email) === want);
54
+ const existing = (await rowsFor(want))[0];
44
55
  if (existing) return existing;
45
56
  return col.createEntry(slug, {email: want, at: new Date().toISOString(), source}, {source});
46
57
  }
@@ -49,7 +60,7 @@ export function createSuppression(col, {slug = 'outreach-unsubscribes'} = {}) {
49
60
  async function remove(email) {
50
61
  if (typeof col.deleteEntry !== 'function') return false;
51
62
  const want = normaliseEmail(email);
52
- const hits = (await rows()).filter(e => normaliseEmail(e.data.email) === want);
63
+ const hits = want ? await rowsFor(want) : [];
53
64
  for (const e of hits) await col.deleteEntry(slug, e.id).catch(() => {});
54
65
  return hits.length > 0;
55
66
  }
@@ -31,6 +31,19 @@ export function unsubscribeUrl(baseUrl, secret, email) {
31
31
  return `${baseUrl}${UNSUBSCRIBE_PATH}?e=${e}&k=${signEmail(secret, email)}`;
32
32
  }
33
33
 
34
+ /**
35
+ * The RFC 8058 headers for a message that carries `url`: mail clients show
36
+ * their own Unsubscribe button and POST `List-Unsubscribe=One-Click` to the
37
+ * URL, which the engine's route treats as the confirmed opt-out. Gmail and
38
+ * Yahoo require them of bulk senders.
39
+ *
40
+ * @param {string} url - unsubscribeUrl()
41
+ * @returns {{'List-Unsubscribe': string, 'List-Unsubscribe-Post': string}}
42
+ */
43
+ export function unsubscribeHeaders(url) {
44
+ return {'List-Unsubscribe': `<${url}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'};
45
+ }
46
+
34
47
  /** The verified address from ?e=&k=, or null. Constant-time on the signature. */
35
48
  export function verifyUnsubscribe(secret, e, k) {
36
49
  let email = '';
@@ -569,6 +569,8 @@ function mount(el) {
569
569
  switch (e.type) {
570
570
  case 'knocking': stage.value = 'waiting'; break;
571
571
  case 'admitted':
572
+ // The ICE servers arrive with admission (not the join reply) - before any peer is connected.
573
+ if (Array.isArray(e.iceServers)) iceServers = e.iceServers;
572
574
  stage.value = 'in';
573
575
  recording.value = e.recording === true;
574
576
  (e.chat || []).forEach(item => addItem(item, {live: false}));
@@ -868,7 +870,7 @@ function mount(el) {
868
870
  if (r.status === 425 && body.opensAt) { error.value = `This call opens at ${new Date(body.opensAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}. Come back then.`; return; }
869
871
  if (!r.ok) { error.value = body.error || 'Could not join the call.'; return; }
870
872
  me = {id: body.id, secret: body.secret, who: body.who || body.id};
871
- iceServers = body.iceServers || [];
873
+ iceServers = body.iceServers || []; // an older engine's join reply; the admitted event replaces it
872
874
  host.value = body.host === true;
873
875
  if (body.name) name.value = body.name;
874
876
  max.value = body.max || 6;
@@ -20,7 +20,7 @@ Public-facing blog for the CMS. Supports posts with categories, tags, and option
20
20
  - `blocks/*.html|css` - the shipped layouts, copied into content/blocks/ once
21
21
  - `public/blog.js`, `public/blog.css` - the browser half (listing, right-click menu, post extras)
22
22
  - `public/samples/*.svg` - sample covers
23
- - `tests/layouts.test.js`, `tests/public.test.js`, `tests/nav-link.test.js`
23
+ - `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`
24
24
  - `admin/views/blog.js` - post list (KPIs + status/category/search filters)
25
25
  - `admin/views/post-editor.js` - create / edit a post
26
26
  - `admin/views/categories.js`, `admin/views/comments.js`, `admin/views/settings.js`
@@ -319,3 +319,13 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
319
319
  - `usePermissionLookup()` points the checks at a lookup: the plugin passes core's `getPermissionsFor` (or
320
320
  `options.getPermissionsFor` in tests).
321
321
 
322
+ ## 1.9.6 (2026-10-13 Tool sweep)
323
+
324
+ - **One slug, one post.** `POST /posts` and `PUT /posts/:id` answer 409 when another post (draft or not; a Pro-era post
325
+ in another blog does not count) holds the slug - `slugHolder()` in lib/page.js. The public post lookup is
326
+ `publicPost()`: the LIVE post with that slug, not whichever is newest. Before, an author's draft saved with a live
327
+ post's slug sat first in the newest-first list and the live address answered 404.
328
+ - **POST /comments** has its own `config.rateLimit` (5/min/IP), caps every field (`commentFields()`, exported and
329
+ tested), needs a plausible email, and refuses a post that does not exist or is not visible (404) - each of those
330
+ used to raise a staff notification.
331
+ - `ASSET_V` / the preview `assetV` are 1.9.6 (they were 1.6.x while the plugin was 1.9.5 - keep them with the version).
@@ -39,7 +39,7 @@ Writers without the Publish permission see only their own posts.
39
39
  | Field | What it does |
40
40
  |---|---|
41
41
  | Title | Required. |
42
- | Slug | The last part of the post's address, for example `my-first-post`. With **Auto** ticked it follows the title. Changing it on a published post breaks links to the old address. |
42
+ | Slug | The last part of the post's address, for example `my-first-post`. With **Auto** ticked it follows the title. Changing it on a published post breaks links to the old address. Two posts cannot share a slug: saving one with a slug another post already has (a draft included) is refused with a message. |
43
43
  | Excerpt | A sentence or two shown in listings. Also used as the search-engine description when **SEO Description** is empty. |
44
44
  | Content | Markdown. Every shortcode the site supports works here too. |
45
45
  | SEO Title / SEO Description | What search engines and link previews show. Leave empty to use the title and excerpt. |
@@ -26,6 +26,38 @@ export function isVisible(post) {
26
26
  (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
27
27
  }
28
28
 
29
+ /** Whether a post is this blog's (a Pro-era post in another blog is not), whatever its status. */
30
+ function inThisBlog(post) {
31
+ return !post.blogId || post.blogId === 'main';
32
+ }
33
+
34
+ /**
35
+ * The post readers get for an address: a LIVE one. The list is newest first,
36
+ * so `find(slug)` alone picked an author's fresh draft over the published
37
+ * post with the same slug and answered 404 for the live one.
38
+ *
39
+ * @param {object[]} posts
40
+ * @param {string} slug
41
+ * @returns {object|null}
42
+ */
43
+ export function publicPost(posts, slug) {
44
+ return posts.find((p) => p.slug === slug && isVisible(p)) ?? null;
45
+ }
46
+
47
+ /**
48
+ * The post, other than `exceptId`, that already holds a slug - a save that
49
+ * would make two posts answer one address. Drafts count: the one that is live
50
+ * today is not always the one that is live tomorrow.
51
+ *
52
+ * @param {object[]} posts
53
+ * @param {string} slug
54
+ * @param {string|null} [exceptId] - the post being saved
55
+ * @returns {object|null}
56
+ */
57
+ export function slugHolder(posts, slug, exceptId = null) {
58
+ return posts.find((p) => p.slug === slug && p.id !== exceptId && inThisBlog(p)) ?? null;
59
+ }
60
+
29
61
  /** Settings as they are NOW - display choices apply without a restart. */
30
62
  export function liveSettings(registered) {
31
63
  return {...defaultConfig, ...DISPLAY_DEFAULTS, ...registered, ...(getPluginStates()?.blog?.settings ?? {})};
@@ -5,7 +5,7 @@ import {getPluginStates, savePluginState} from '../../server/services/plugins.js
5
5
  import {renderBlogPage} from '../../server/services/renderer.js';
6
6
  import {cleanBasePath, DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
7
7
  import {catalogue, seedBlocks} from './lib/render.js';
8
- import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
8
+ import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
9
9
  import {addNavLink, moveNavLink, removeNavLink} from './lib/nav-link.js';
10
10
  import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
11
11
  import {commentKey, commentNotice} from './lib/notices.js';
@@ -81,6 +81,35 @@ export function postFields(body, user) {
81
81
  return out;
82
82
  }
83
83
 
84
+ // What a visitor may say in a comment, and how much of it. The form's
85
+ // `maxlength` is the browser's word only; the route must cap too.
86
+ const COMMENT_CAPS = {authorName: 80, authorEmail: 160, authorUrl: 300, body: 5000};
87
+ const EMAIL_RE = /^[^\s@"<>]+@[^\s@"<>]+\.[^\s@"<>]+$/;
88
+
89
+ /**
90
+ * A public comment as the route will store it, or the reason it will not.
91
+ * Pure, so the caps and the email check can be tested without a server.
92
+ *
93
+ * @param {object} body
94
+ * @returns {{error: string}|{fields: object}}
95
+ */
96
+ export function commentFields(body = {}) {
97
+ const {postId, authorName, authorEmail, authorUrl, body: commentBody} = body;
98
+ if (!postId) return {error: 'postId is required'};
99
+ if (!authorName) return {error: 'authorName is required'};
100
+ if (!authorEmail) return {error: 'authorEmail is required'};
101
+ if (!commentBody) return {error: 'body is required'};
102
+ const email = String(authorEmail).trim().slice(0, COMMENT_CAPS.authorEmail);
103
+ if (!EMAIL_RE.test(email)) return {error: 'authorEmail must be an email address'};
104
+ return {fields: {
105
+ postId: String(postId).slice(0, 64),
106
+ authorName: String(authorName).trim().slice(0, COMMENT_CAPS.authorName),
107
+ authorEmail: email,
108
+ authorUrl: authorUrl ? String(authorUrl).trim().slice(0, COMMENT_CAPS.authorUrl) : '',
109
+ body: String(commentBody).slice(0, COMMENT_CAPS.body)
110
+ }};
111
+ }
112
+
84
113
  /** Derive a URL slug from a title string. */
85
114
  function slugify(str) {
86
115
  return String(str)
@@ -363,7 +392,7 @@ export default async function blogPlugin(fastify, options) {
363
392
  });
364
393
  const html = await renderBlogPage(
365
394
  fileURLToPath(new URL('./templates/post.html', import.meta.url)),
366
- { post: page.html, assetV: '1.6.0' },
395
+ { post: page.html, assetV: '1.9.6' },
367
396
  { title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
368
397
  usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
369
398
  );
@@ -462,6 +491,12 @@ export default async function blogPlugin(fastify, options) {
462
491
  const derivedSlug = slug
463
492
  ? String(slug).trim()
464
493
  : slugify(title);
494
+ if (!derivedSlug) return reply.code(400).send({ error: 'slug is required' });
495
+
496
+ // One address, one post: a draft saved with a live post's slug sat in
497
+ // front of it in the newest-first list and took it offline.
498
+ const taken = slugHolder(await loadAll(POSTS_SLUG), derivedSlug);
499
+ if (taken) return reply.code(409).send({ error: `Another post already uses the slug "${derivedSlug}" - choose a different one.` });
465
500
 
466
501
  const fields = postFields(body, user);
467
502
  const entry = await createEntry(POSTS_SLUG, {
@@ -503,7 +538,13 @@ export default async function blogPlugin(fastify, options) {
503
538
  return reply.code(403).send({ error: 'Forbidden' });
504
539
  }
505
540
 
506
- const merged = { ...entry.data, ...postFields(request.body ?? {}, user) };
541
+ const fields = postFields(request.body ?? {}, user);
542
+ if (fields.slug !== undefined) {
543
+ if (!fields.slug) return reply.code(400).send({ error: 'slug is required' });
544
+ const taken = fields.slug !== entry.data.slug && slugHolder(await loadAll(POSTS_SLUG), fields.slug, id);
545
+ if (taken) return reply.code(409).send({ error: `Another post already uses the slug "${fields.slug}" - choose a different one.` });
546
+ }
547
+ const merged = { ...entry.data, ...fields };
507
548
  // `seo` is merged too: the editor sends title and description only, and
508
549
  // must not wipe what the SEO Tool set (image, noindex, focus keyword...).
509
550
  if (merged.seo && typeof merged.seo === 'object' && entry.data.seo && typeof entry.data.seo === 'object') {
@@ -689,8 +730,12 @@ export default async function blogPlugin(fastify, options) {
689
730
  };
690
731
  });
691
732
 
692
- /** POST /api/plugins/blog/comments - public, no authenticate */
693
- fastify.post('/comments', async (request, reply) => {
733
+ /**
734
+ * POST /api/plugins/blog/comments - public, no authenticate. Its own rate
735
+ * limit: under the global bucket alone (500/min/IP) a script filed hundreds
736
+ * of comments a minute, each one a staff notification.
737
+ */
738
+ fastify.post('/comments', {config: {rateLimit: {max: 5, timeWindow: '1 minute'}}}, async (request, reply) => {
694
739
  const body = request.body ?? {};
695
740
 
696
741
  // Read the settings as they are NOW. `settings` above is captured when
@@ -705,27 +750,24 @@ export default async function blogPlugin(fastify, options) {
705
750
  return reply.code(201).send({ ok: true });
706
751
  }
707
752
 
708
- const { postId, authorName, authorEmail, authorUrl, body: commentBody } = body;
753
+ const checked = commentFields(body);
754
+ if (checked.error) return reply.code(400).send({ error: checked.error });
755
+ const { fields } = checked;
709
756
 
710
- if (!postId) return reply.code(400).send({ error: 'postId is required' });
711
- if (!authorName) return reply.code(400).send({ error: 'authorName is required' });
712
- if (!authorEmail) return reply.code(400).send({ error: 'authorEmail is required' });
713
- if (!commentBody) return reply.code(400).send({ error: 'body is required' });
757
+ // Only a post readers can see takes comments - not a draft, not an id
758
+ // that never existed (each of those still raised a notification).
759
+ const post = await getEntry(POSTS_SLUG, fields.postId).catch(() => null);
760
+ if (!post || !isVisible(post.data)) return reply.code(404).send({ error: 'No such post' });
714
761
 
715
762
  const commentStatus = live.commentModeration !== false ? 'pending' : 'approved';
716
763
 
717
764
  const entry = await createEntry(COMMENTS_SLUG, {
718
- postId: String(postId),
719
- authorName: String(authorName).trim(),
720
- authorEmail: String(authorEmail).trim(),
721
- authorUrl: authorUrl ? String(authorUrl).trim() : '',
722
- body: String(commentBody),
765
+ ...fields,
723
766
  status: commentStatus,
724
767
  createdAt: new Date().toISOString()
725
768
  }, { source: 'public' });
726
769
 
727
770
  const made = toComment(entry);
728
- const post = await getEntry(POSTS_SLUG, String(postId)).catch(() => null);
729
771
  const notice = commentNotice(made, post?.data?.title, '#/plugins/blog/comments');
730
772
  if (notice) notify(notice);
731
773
  return reply.code(201).send(made);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.9.5",
4
+ "version": "1.9.6",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-09-27",
8
+ "date": "2026-10-13",
9
9
  "icon": "file-text",
10
10
  "admin": {
11
11
  "css": [
@@ -18,13 +18,13 @@ import {renderBlogPage} from '../../server/services/renderer.js';
18
18
  import {getConfig} from '../../server/config.js';
19
19
  import {byNewest, escapeHtml, matchBlogPath, pickDisplay, safeImageUrl} from './lib/layouts.js';
20
20
  import {catalogue, renderCards} from './lib/render.js';
21
- import {authorNamesFor, isVisible, liveBase, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
21
+ import {authorNamesFor, isVisible, liveBase, liveSettings, loadEntries, publicPost, renderPostPage, resolveAuthorName} from './lib/page.js';
22
22
 
23
23
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
24
24
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
25
25
 
26
26
  /** Bumped with the plugin, so a release is never served a stale script. */
27
- const ASSET_V = '1.6.3';
27
+ const ASSET_V = '1.9.6';
28
28
 
29
29
  /**
30
30
  * Above this many posts a listing pages on the server instead: the browser
@@ -210,8 +210,9 @@ ${items}
210
210
  /** Individual post */
211
211
  async post(request, reply, {slug}, base) {
212
212
  const allPosts = await loadEntries('blog-posts');
213
- const post = allPosts.find((p) => p.slug === slug);
214
- if (!post || !isVisible(post)) return reply.code(404).send('Not found');
213
+ // The live post with this slug, not whichever is newest (a draft may share it).
214
+ const post = publicPost(allPosts, slug);
215
+ if (!post) return reply.code(404).send('Not found');
215
216
 
216
217
  const siteUrl = (config?.site?.url || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
217
218
  const page = await renderPostPage(post, allPosts, liveSettings(settings), {base, siteUrl});
@@ -16,7 +16,8 @@ import zlib from 'zlib';
16
16
  import Fastify from 'fastify';
17
17
  import compress from '@fastify/compress';
18
18
  import blogPublicPlugin from '../plugin.public.js';
19
- import {postFields, usePermissionLookup} from '../plugin.js';
19
+ import {commentFields, postFields, usePermissionLookup} from '../plugin.js';
20
+ import {publicPost, slugHolder} from '../lib/page.js';
20
21
 
21
22
  async function buildPublic() {
22
23
  const app = Fastify();
@@ -92,3 +93,31 @@ test('the pages live at the base path in the settings, not a fixed /blog', async
92
93
  assert.equal(other.body, 'site page', 'the old path is the site\'s again');
93
94
  assert.equal((await app.inject({method: 'POST', url: '/news'})).statusCode, 404, 'GET only');
94
95
  });
96
+
97
+ test('a draft that shares a live post\'s slug neither takes it offline nor gets saved', () => {
98
+ // Newest first, as loadEntries sorts: the draft sits in front of the live post.
99
+ const posts = [
100
+ {id: 'd', slug: 'hello', status: 'draft'},
101
+ {id: 'p', slug: 'hello', status: 'published'},
102
+ {id: 'o', slug: 'other', status: 'published', blogId: 'second'}
103
+ ];
104
+ assert.equal(publicPost(posts, 'hello')?.id, 'p', 'readers get the live post, not the newest');
105
+ assert.equal(publicPost(posts, 'missing'), null);
106
+ assert.equal(publicPost(posts, 'other'), null, 'a Pro-era post in another blog is not this blog\'s');
107
+ assert.equal(slugHolder(posts, 'hello')?.id, 'd', 'a new post may not take a slug in use, draft or not');
108
+ assert.equal(slugHolder(posts, 'hello', 'd')?.id, 'p', 'saving the draft itself still meets the live post');
109
+ assert.equal(slugHolder(posts, 'fresh'), null);
110
+ assert.equal(slugHolder(posts, 'other'), null, 'another blog\'s post is at another base');
111
+ });
112
+
113
+ test('a public comment is capped, needs an email, and names its post', () => {
114
+ const ok = commentFields({postId: 'p1', authorName: ' Jo ', authorEmail: ' jo@example.com ', body: 'x'.repeat(6000), authorUrl: 'https://jo.example'});
115
+ assert.equal(ok.fields.authorName, 'Jo');
116
+ assert.equal(ok.fields.authorEmail, 'jo@example.com');
117
+ assert.equal(ok.fields.body.length, 5000, 'the form\'s maxlength is the browser\'s word only');
118
+ assert.equal(commentFields({postId: 'p1', authorName: 'x'.repeat(200), authorEmail: 'a@b.co', body: 'hi'}).fields.authorName.length, 80);
119
+ assert.match(commentFields({postId: 'p1', authorName: 'Jo', authorEmail: 'not-an-email', body: 'hi'}).error, /email/);
120
+ assert.match(commentFields({postId: 'p1', authorName: 'Jo', authorEmail: 'a@b.co"<x>', body: 'hi'}).error, /email/);
121
+ assert.match(commentFields({authorName: 'Jo', authorEmail: 'a@b.co', body: 'hi'}).error, /postId/);
122
+ assert.match(commentFields({postId: 'p1', authorName: 'Jo', authorEmail: 'a@b.co'}).error, /body/);
123
+ });
@@ -162,6 +162,9 @@ only to roles that hold it.
162
162
  ## Gotchas
163
163
 
164
164
  - Add `return reply.send(...)` / return a value in every async route (compressed responses come back empty).
165
- - `siteBase()` uses `site.baseUrl` when set, else the last request's origin (emails from reminders have no request).
165
+ - `siteBase()` is `site.baseUrl` or '' - NEVER a request's Host (1.6.3): without a Site URL emails go out with no
166
+ links and the log warns once. Before, a booker with a chosen Host header got the owner an email whose "Start the
167
+ call" button went to their domain carrying the host key. tests/api.test.js sets `baseUrl` in config/site.json for
168
+ the run (restored after) and forges a Host to prove it.
166
169
  - The API tests create contacts in domma-cms's `content/collections/contacts-contacts/data.json` (tracked) - they
167
170
  snapshot and restore it; a crashed run can leave it changed: `git checkout` it in domma-cms.
@@ -22,7 +22,7 @@ Let people book time with you, your team, or a room or piece of equipment, strai
22
22
  4. Open the settings (the cog in the Booking banner) and check the booking page's address, your time zone and the emails.
23
23
  5. Share the booking page link, or put the booking form on any page with the `[booking /]` shortcode.
24
24
 
25
- Booking emails need the site's outgoing email (SMTP) to be set up in **Settings**. Without it, bookings still work, but no emails are sent, and the Booking settings will warn you.
25
+ Booking emails need the site's outgoing email (SMTP) to be set up in **Settings**. Without it, bookings still work, but no emails are sent, and the Booking settings will warn you. The links in those emails (change or cancel, join the call) use the **Site URL** in **Settings > Site**; until it is set, emails go out without links.
26
26
 
27
27
  ## Booking from Contacts and Email
28
28
 
@@ -48,21 +48,23 @@ import {
48
48
  } from './lib/store.js';
49
49
 
50
50
  /**
51
- * The origin staff last used the admin on - for emails sent with no request
52
- * (reminders) when `site.baseUrl` is not set. Only signed-in requests set it:
53
- * a customer's Host header is theirs to choose, and must not decide where
54
- * everyone else's reminder links point.
51
+ * The site's address for links in emails and the admin: `site.baseUrl` (Settings > Site), else '' - a link is then
52
+ * left out and the log says so once. Until 1.6.3 a request's Host header stood in when the setting was empty, so a
53
+ * booker who could reach the server with a Host of their choosing (default vhost, direct IP, misconfigured proxy)
54
+ * got the owner an email whose "Start the call" button went to their domain carrying the host key. A Host header
55
+ * is the caller's to choose; it never decides where anyone's link points. `request` is accepted for the registry's
56
+ * callers (Pro) and ignored.
55
57
  */
56
- let lastOrigin = '';
57
-
58
- function siteBase(request) {
58
+ let warnedNoBase = false;
59
+ function siteBase(request = null, log = null) {
59
60
  let base = '';
60
61
  try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
61
62
  if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
62
- if (!request) return lastOrigin;
63
- const origin = `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
64
- if (request.user) lastOrigin = origin;
65
- return origin;
63
+ if (log && !warnedNoBase) {
64
+ warnedNoBase = true;
65
+ log.warn?.('[booking] Site URL is not set (Settings > Site): booking emails go out without links until it is.');
66
+ }
67
+ return '';
66
68
  }
67
69
 
68
70
  const manageUrl = (s, b, base) => `${base}${s.bookingPath}/manage/${b.id}?t=${b.token}`;
@@ -209,7 +211,7 @@ export default async function booking(fastify, options) {
209
211
  async function tell(b, what, {request = null, toCustomer = true, toOwner = true} = {}) {
210
212
  try {
211
213
  const s = await liveSettings();
212
- const base = siteBase(request);
214
+ const base = siteBase(request, log);
213
215
  const title = siteTitle();
214
216
  const link = base && b.token ? manageUrl(s, b, base) : '';
215
217
  const may = customerMayChange(b, s) && (s.allowCancel || s.allowMove);
@@ -282,7 +284,7 @@ export default async function booking(fastify, options) {
282
284
  registry.services = {
283
285
  name: NAME, liveSettings, allBookings, allServices, allBookables, getBooking, getService, getBookable,
284
286
  changeBooking, sendReminders,
285
- /** The site's address: `site.baseUrl`, else the request's origin (a signed-in one), else the last admin's. */
287
+ /** The site's address: `site.baseUrl`, else '' (never a request's Host). */
286
288
  siteBase: (request = null) => siteBase(request),
287
289
  /** A booking as the admin may see it (no customer token; an online one's host key as `hostUrl`). */
288
290
  adminBooking,
@@ -352,7 +354,6 @@ export default async function booking(fastify, options) {
352
354
  const id = await linkContact(customer, bk?.userId || s.contactsOwnerId, contactsOn()).catch(() => '');
353
355
  if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
354
356
  }
355
- if (request) siteBase(request);
356
357
  if (notify) tell(b, 'made', {request, toOwner: source !== 'admin'});
357
358
  registry.emit('made', b);
358
359
  return b;
@@ -411,7 +412,7 @@ export default async function booking(fastify, options) {
411
412
  };
412
413
  }
413
414
 
414
- fastify.get('/bootstrap', can('read'), async (request) => { siteBase(request); return bootstrap(); });
415
+ fastify.get('/bootstrap', can('read'), async () => bootstrap());
415
416
 
416
417
  fastify.get('/count', can('read'), async () => {
417
418
  const s = await liveSettings();
@@ -513,7 +514,6 @@ export default async function booking(fastify, options) {
513
514
  const id = await linkContact(customer, owner, contactsOn()).catch(() => '');
514
515
  if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
515
516
  }
516
- siteBase(request);
517
517
  if (body.notify !== false) tell(b, 'made', {request, toOwner: false});
518
518
  registry.emit('made', b);
519
519
  return reply.code(201).send(adminBooking(b));
@@ -536,7 +536,6 @@ export default async function booking(fastify, options) {
536
536
  try {
537
537
  const b = await placeBooking({service, candidates, rules, startAt: startAt.toISOString(), tz: s.timeZone, moveId: current.id,
538
538
  force: body.force === true, fields: {by: request.user?.name || ''}});
539
- siteBase(request);
540
539
  if (body.notify !== false) tell(b, 'moved', {request, toOwner: false});
541
540
  registry.emit('moved', b, current);
542
541
  endCall(current).catch(() => {});
@@ -564,7 +563,6 @@ export default async function booking(fastify, options) {
564
563
  if (!res) return fail(reply, 404, 'No such booking.');
565
564
  const {booking: b, from} = res;
566
565
  if (b.status !== from) {
567
- siteBase(request);
568
566
  if (b.status === 'cancelled' && body.notify !== false) tell(b, 'cancelled', {request, toOwner: false});
569
567
  registry.emit(b.status === 'cancelled' ? 'cancelled' : 'status', b, from);
570
568
  if (b.status === 'cancelled') endCall(b).catch(() => {});
@@ -754,7 +752,6 @@ export default async function booking(fastify, options) {
754
752
  const id = await linkContact(customer, bk?.userId || s.contactsOwnerId, contactsOn()).catch(() => '');
755
753
  if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
756
754
  }
757
- siteBase(request);
758
755
  tell(b, 'made', {request});
759
756
  notice(b, 'made');
760
757
  registry.emit('made', b);
@@ -793,7 +790,6 @@ export default async function booking(fastify, options) {
793
790
  ? `Bookings can be cancelled here up to ${s.changeNoticeHours} hours before. Please contact us instead.`
794
791
  : 'This booking cannot be cancelled here. Please contact us instead.');
795
792
  const {booking: done} = await changeBooking(b.id, {status: 'cancelled', reason: String(request.body?.reason || '').slice(0, 300)}, b.name);
796
- siteBase(request);
797
793
  tell(done, 'cancelled', {request});
798
794
  notice(done, 'cancelled');
799
795
  registry.emit('cancelled', done, 'confirmed');
@@ -817,7 +813,6 @@ export default async function booking(fastify, options) {
817
813
  const {service, candidates, rules} = await candidatesFor(b.serviceId, b.bookableId);
818
814
  try {
819
815
  const moved = await placeBooking({service, candidates, rules, startAt: offered.start, tz: s.timeZone, moveId: b.id, fields: {by: b.name}});
820
- siteBase(request);
821
816
  tell(moved, 'moved', {request});
822
817
  notice(moved, 'moved');
823
818
  registry.emit('moved', moved, b);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "booking",
3
3
  "displayName": "Booking",
4
- "version": "1.6.2",
4
+ "version": "1.6.3",
5
5
  "tier": "free",
6
6
  "description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-03",
8
+ "date": "2026-10-13",
9
9
  "icon": "clipboard-check",
10
10
  "uses": {
11
11
  "contacts": "Customers become contacts; Book… on a contact and a Bookings panel on the contact card",