domma-cms 1.6.34 → 1.6.36
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/admin/js/api.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/scribe-model.js +1 -1
- package/admin/js/lib/shortcode-preview.js +1 -1
- package/admin/js/lib/shortcode-registry.js +2 -2
- package/admin/js/templates/docs/usage-site-settings.html +2 -1
- package/admin/js/templates/page-editor.html +2 -1
- package/admin/js/templates/pages.html +1 -1
- package/admin/js/templates/theme.html +1 -1
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/theme.js +5 -5
- package/package.json +1 -1
- package/plugins/_lib/call/ice.js +5 -2
- package/plugins/_lib/call/rooms.js +9 -3
- package/plugins/_lib/call/routes.js +7 -4
- package/plugins/_lib/mail/defaults.js +12 -0
- package/plugins/_lib/mail/hostGuard.js +86 -0
- package/plugins/_lib/mail/readRoutes.js +20 -1
- package/plugins/_lib/outreach/audience.js +76 -0
- package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
- package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
- package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
- package/plugins/_lib/outreach/expiry.js +46 -0
- package/plugins/_lib/outreach/index.js +15 -0
- package/plugins/_lib/outreach/ledger.js +127 -0
- package/plugins/_lib/outreach/sending.js +121 -0
- package/plugins/_lib/outreach/suppression.js +62 -0
- package/plugins/_lib/outreach/tokens.js +35 -0
- package/plugins/_lib/outreach/unsubscribe.js +46 -0
- package/plugins/_lib/public/call/call.js +3 -1
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/docs/guide.md +1 -1
- package/plugins/blog/lib/page.js +32 -0
- package/plugins/blog/plugin.js +58 -16
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +5 -4
- package/plugins/blog/tests/public.test.js +30 -1
- package/plugins/booking/CLAUDE.md +4 -1
- package/plugins/booking/docs/guide.md +1 -1
- package/plugins/booking/plugin.js +16 -21
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/api.test.js +26 -1
- package/plugins/feedback/CLAUDE.md +7 -2
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/lib/receiver.js +1 -1
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/free-tier.lock.json +63 -51
- package/plugins/mail-reader/CLAUDE.md +6 -0
- package/plugins/mail-reader/docs/guide.md +1 -0
- package/plugins/mail-reader/plugin.json +2 -2
- package/plugins/shopping-cart/CLAUDE.md +62 -1
- package/plugins/shopping-cart/admin/css/index.css +16 -0
- package/plugins/shopping-cart/admin/lib/chart.js +64 -0
- package/plugins/shopping-cart/admin/lib/kit.js +15 -0
- package/plugins/shopping-cart/admin/views/orders.js +1 -0
- package/plugins/shopping-cart/admin/views/overview.js +4 -53
- package/plugins/shopping-cart/admin/views/sales.js +133 -0
- package/plugins/shopping-cart/admin/views/settings.js +3 -1
- package/plugins/shopping-cart/admin/views/shop.js +2 -1
- package/plugins/shopping-cart/config.js +1 -0
- package/plugins/shopping-cart/docs/guide.md +26 -1
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +185 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/payments.js +7 -4
- package/plugins/shopping-cart/lib/render.js +13 -1
- package/plugins/shopping-cart/lib/sales.js +193 -0
- package/plugins/shopping-cart/lib/shop.js +21 -3
- package/plugins/shopping-cart/plugin.js +180 -35
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/plugin.public.js +17 -1
- package/plugins/shopping-cart/public/account.js +277 -0
- package/plugins/shopping-cart/public/cart.js +16 -0
- package/plugins/shopping-cart/public/lib/session.js +82 -0
- package/plugins/shopping-cart/public/shop.css +27 -0
- package/plugins/shopping-cart/roles/shop-customer.json +8 -0
- package/plugins/shopping-cart/tests/accounts.test.js +136 -0
- package/plugins/shopping-cart/tests/api.test.js +34 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/pure.test.js +16 -1
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- package/plugins/support/CLAUDE.md +6 -0
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/docs/guide.md +90 -0
- package/plugins/support/lib/receiver.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/public/css/site.css +1 -1
- package/public/js/effects.js +1 -1
- package/public/js/scribe-style.mjs +1 -1
- package/public/js/site.js +1 -1
- package/public/js/text-style.mjs +1 -1
- package/server/routes/api/analytics.js +32 -0
- package/server/routes/api/collections.js +148 -15
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/server.js +10 -1
- package/server/services/authChecks.js +8 -0
- package/server/services/collectionAccess.js +191 -0
- package/server/services/collections.js +8 -2
- package/server/services/markdown.js +7 -2
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/services/request-log.js +70 -0
- package/server/templates/page.html +3 -3
package/plugins/blog/plugin.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
693
|
-
|
|
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
|
|
753
|
+
const checked = commentFields(body);
|
|
754
|
+
if (checked.error) return reply.code(400).send({ error: checked.error });
|
|
755
|
+
const { fields } = checked;
|
|
709
756
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (!
|
|
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
|
-
|
|
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);
|
package/plugins/blog/plugin.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blog",
|
|
3
3
|
"displayName": "Blog",
|
|
4
|
-
"version": "1.9.
|
|
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-
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
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()`
|
|
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
|
|
52
|
-
*
|
|
53
|
-
* a
|
|
54
|
-
*
|
|
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
|
|
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 (!
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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 (
|
|
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.
|
|
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-
|
|
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",
|
|
@@ -15,8 +15,10 @@ import {bookingRegistry} from '../lib/extensions.js';
|
|
|
15
15
|
import {addDays, atWallClock, wallClock} from '../public/lib/time.js';
|
|
16
16
|
|
|
17
17
|
const PLUGINS = path.resolve('config/plugins.json');
|
|
18
|
+
const SITE = path.resolve('config/site.json');
|
|
18
19
|
const CONTACTS = path.resolve('content/collections/contacts-contacts/data.json');
|
|
19
20
|
let savedContacts;
|
|
21
|
+
let savedSite;
|
|
20
22
|
const DIRS = ['booking-bookables', 'booking-services', 'booking-bookings'].map(c => path.resolve('content/collections', c));
|
|
21
23
|
const TZ = 'Europe/London';
|
|
22
24
|
let savedPlugins;
|
|
@@ -29,6 +31,9 @@ const related = {};
|
|
|
29
31
|
before(async () => {
|
|
30
32
|
savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
|
|
31
33
|
savedContacts = await fs.readFile(CONTACTS, 'utf8').catch(() => null);
|
|
34
|
+
// Links in emails come from the Site URL alone (1.6.3) - the checkout's site.json has none.
|
|
35
|
+
savedSite = await fs.readFile(SITE, 'utf8');
|
|
36
|
+
await fs.writeFile(SITE, JSON.stringify({...JSON.parse(savedSite), baseUrl: 'https://site.test'}, null, 2) + '\n');
|
|
32
37
|
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
33
38
|
setMailer(async (msg) => { sent.push(msg); });
|
|
34
39
|
app = Fastify();
|
|
@@ -54,6 +59,7 @@ after(async () => {
|
|
|
54
59
|
if (savedPlugins === null) await fs.rm(PLUGINS, {force: true});
|
|
55
60
|
else await fs.writeFile(PLUGINS, savedPlugins);
|
|
56
61
|
if (savedContacts !== null) await fs.writeFile(CONTACTS, savedContacts);
|
|
62
|
+
if (savedSite) await fs.writeFile(SITE, savedSite);
|
|
57
63
|
if (dataDir) await fs.rm(dataDir, {recursive: true, force: true});
|
|
58
64
|
});
|
|
59
65
|
|
|
@@ -353,8 +359,27 @@ test('online: a service an extension makes online gives a call - the customer an
|
|
|
353
359
|
assert.match(r.body.booking.callUrl, /\/booking\/call\//);
|
|
354
360
|
await new Promise(res => setTimeout(res, 50));
|
|
355
361
|
const toHost = sent.find(m => m.to === 'vic@example.com');
|
|
356
|
-
assert.match(toHost?.text || '', /Start the call:
|
|
362
|
+
assert.match(toHost?.text || '', /Start the call: https:\/\/site\.test\/\S+&h=/, 'the person booked gets the host link, on the Site URL');
|
|
357
363
|
assert.doesNotMatch(sent.find(m => m.to === 'web@example.com').text, /&h=/);
|
|
364
|
+
|
|
365
|
+
// 1.6.3: a booker's Host header never decides where the owner's link points - with no Site URL there is no link at all.
|
|
366
|
+
sent.length = 0;
|
|
367
|
+
await fs.writeFile(SITE, savedSite);
|
|
368
|
+
try {
|
|
369
|
+
const slot2 = (await call('GET', `/public/slots?service=${svc.id}&from=${day}&to=${day}`)).body.days[0].slots[5];
|
|
370
|
+
const forged = await app.inject({method: 'POST', url: '/public/book', headers: {host: 'evil.example'},
|
|
371
|
+
payload: {serviceId: svc.id, startAt: slot2.start, customer: {name: 'Forger', email: 'forger@example.com'}}});
|
|
372
|
+
assert.equal(forged.statusCode, 201, forged.body);
|
|
373
|
+
await new Promise(res => setTimeout(res, 50));
|
|
374
|
+
const owner = sent.find(m => m.to === 'vic@example.com');
|
|
375
|
+
assert.ok(owner, 'the owner is still told');
|
|
376
|
+
assert.doesNotMatch(owner.text, /evil\.example/);
|
|
377
|
+
assert.doesNotMatch(owner.text, /&h=/, 'the host key goes in no link whose base is not the configured one');
|
|
378
|
+
assert.doesNotMatch(sent.find(m => m.to === 'forger@example.com').text, /evil\.example/);
|
|
379
|
+
await call('DELETE', `/bookings/${forged.json().booking.id}`);
|
|
380
|
+
} finally {
|
|
381
|
+
await fs.writeFile(SITE, JSON.stringify({...JSON.parse(savedSite), baseUrl: 'https://site.test'}, null, 2) + '\n');
|
|
382
|
+
}
|
|
358
383
|
const early = await call('POST', `/call/${r.body.booking.callUrl.split('/call/')[1].split('?')[0]}/join`,
|
|
359
384
|
{b: r.body.booking.id, t: new URL(`http://x${r.body.booking.callUrl}`).searchParams.get('t')});
|
|
360
385
|
assert.equal(early.status, 425, 'not open days ahead');
|
|
@@ -98,8 +98,8 @@ Replying in the inbox to a `_manager` report you filed yourself marks it seen fo
|
|
|
98
98
|
- **Who sent it is never the browser's word.** The sender builds the payload from `request.user`; the
|
|
99
99
|
manager attributes a report to the slug the fleet TOKEN resolves to and ignores any `site` in the body.
|
|
100
100
|
A site can still vouch for any of its own admins - it cannot speak for another site.
|
|
101
|
-
- **Tokens
|
|
102
|
-
401 or ECONNREFUSED for a few seconds. That is why the outbox exists: a report is "sent or waiting",
|
|
101
|
+
- **Tokens survive a manager restart** (since 2026-10-04 fleetTokens.js keeps them in `site-manager/data/fleet-tokens.json`,
|
|
102
|
+
mode 0600) but a site being restarted still meets a 401 or ECONNREFUSED for a few seconds. That is why the outbox exists: a report is "sent or waiting",
|
|
103
103
|
never lost. Waiting ones go on the next `/sent` and on the timer (`syncMinutes`, default 60, which also
|
|
104
104
|
notices new replies and raises a `feedback:reply` notification). A retry carries the same `ref`, and the
|
|
105
105
|
manager answers the existing report (200) instead of making a second.
|
|
@@ -113,3 +113,8 @@ Replying in the inbox to a `_manager` report you filed yourself marks it seen fo
|
|
|
113
113
|
(`.fb-note` is flex) beats the browser's. `index.css` carries `.fb [hidden] {display: none !important}`.
|
|
114
114
|
- Only this machine's sites can reach dcm (the fleet token lives in this manager's memory). Sites on other
|
|
115
115
|
machines would need their own manager to relay with an installation key; not built.
|
|
116
|
+
|
|
117
|
+
## 1.1.4 (2026-10-13 Tool sweep hygiene)
|
|
118
|
+
|
|
119
|
+
- Cache-busters follow the manifest: `feedback.js?v=1.1.4` in plugin.json and `V = '1.1.4'` for the sub-views - bump
|
|
120
|
+
both with the version. Fleet-token note above corrected (tokens are on disk since 2026-10-04).
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import {api, loadKit, store} from '../lib/kit.js';
|
|
17
17
|
|
|
18
18
|
const ROUTE = /^#\/plugins\/feedback(\/|$)/;
|
|
19
|
-
const V = '1.1.
|
|
19
|
+
const V = '1.1.4';
|
|
20
20
|
const TABS = [
|
|
21
21
|
{key: 'inbox', label: 'Inbox', icon: 'inbox'},
|
|
22
22
|
{key: 'mine', label: 'My feedback', icon: 'send'}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* admins' names and emails.
|
|
8
8
|
*
|
|
9
9
|
* A site is who its fleet token says it is (site-manager/services/fleetTokens.js,
|
|
10
|
-
*
|
|
10
|
+
* minted when the manager forked it and kept in site-manager/data/fleet-tokens.json). The module is imported at
|
|
11
11
|
* call time and only exists where Site Manager does, which is also how this
|
|
12
12
|
* plugin knows it is on the manager.
|
|
13
13
|
*
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedback",
|
|
3
3
|
"displayName": "Feedback",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.4",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
8
|
+
"date": "2026-10-13",
|
|
9
9
|
"icon": "message-square",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
],
|
|
41
41
|
"views": {
|
|
42
42
|
"plugin-feedback": {
|
|
43
|
-
"entry": "feedback/admin/views/feedback.js?v=1.1.
|
|
43
|
+
"entry": "feedback/admin/views/feedback.js?v=1.1.4",
|
|
44
44
|
"exportName": "feedbackView"
|
|
45
45
|
}
|
|
46
46
|
}
|