domma-cms 1.6.35 → 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/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/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 +42 -41
- 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 +17 -1
- package/plugins/shopping-cart/docs/guide.md +1 -1
- package/plugins/shopping-cart/lib/accounts.js +8 -3
- package/plugins/shopping-cart/lib/payments.js +7 -4
- package/plugins/shopping-cart/lib/shop.js +8 -3
- package/plugins/shopping-cart/plugin.js +77 -41
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/public/account.js +2 -2
- package/plugins/shopping-cart/tests/accounts.test.js +10 -1
- package/plugins/shopping-cart/tests/api.test.js +23 -0
- package/plugins/shopping-cart/tests/pure.test.js +16 -1
- 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/server/routes/api/analytics.js +32 -0
- package/server/routes/api/collections.js +148 -15
- package/server/server.js +5 -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/request-log.js +70 -0
package/package.json
CHANGED
package/plugins/_lib/call/ice.js
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* {
|
|
7
7
|
* "stun": ["stun:stun.l.google.com:19302"],
|
|
8
8
|
* "turn": {"urls": ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"],
|
|
9
|
-
* "secret": "<coturn static-auth-secret>", "ttlHours":
|
|
9
|
+
* "secret": "<coturn static-auth-secret>", "ttlHours": 4}
|
|
10
10
|
* }
|
|
11
11
|
*
|
|
12
12
|
* TURN credentials are coturn's time-limited kind (use-auth-secret): the
|
|
13
13
|
* username is "<expiry>:<label>" and the password an HMAC of it, so no
|
|
14
14
|
* account is ever stored and a copied credential stops working on its own.
|
|
15
|
+
* The default life is 4 hours (was 12): it is made when a person is let in,
|
|
16
|
+
* and a call rarely runs longer; a shorter one limits what a leaked credential
|
|
17
|
+
* is worth as a relay.
|
|
15
18
|
*
|
|
16
19
|
* @module _lib/call/ice
|
|
17
20
|
*/
|
|
@@ -32,7 +35,7 @@ export function readCallConfig(root = process.cwd()) {
|
|
|
32
35
|
const stun = list(raw?.stun);
|
|
33
36
|
const turnUrls = list(raw?.turn?.urls);
|
|
34
37
|
const secret = typeof raw?.turn?.secret === 'string' ? raw.turn.secret : '';
|
|
35
|
-
const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) ||
|
|
38
|
+
const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) || 4));
|
|
36
39
|
return {stun: stun.length ? stun : DEFAULT_STUN, turn: turnUrls.length && secret ? {urls: turnUrls, secret, ttlHours} : null};
|
|
37
40
|
} catch {
|
|
38
41
|
return {stun: DEFAULT_STUN, turn: null};
|
|
@@ -132,8 +132,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
132
132
|
const item = quiet ? undefined : system(room, person, 'joined', `${person.name} joined`);
|
|
133
133
|
tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person), ...(item && {item})});
|
|
134
134
|
// `chat` is the history so far (their own "joined" line included), so a late joiner sees what was said.
|
|
135
|
+
// The ICE servers (TURN credentials) come HERE, not in the join reply: a guest still waiting, or turned
|
|
136
|
+
// away, never holds a relay credential. Made at admission, so its life starts now.
|
|
137
|
+
const ice = typeof person.ice === 'function' ? person.ice() : person.ice;
|
|
135
138
|
push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording,
|
|
136
|
-
chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c}))});
|
|
139
|
+
chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c})), ...(ice ? {iceServers: ice} : {})});
|
|
137
140
|
tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
|
|
138
141
|
}
|
|
139
142
|
|
|
@@ -149,9 +152,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
149
152
|
* `canInvite`: this person may use the Tool's "Add people" (routes.js decides; see member()), with
|
|
150
153
|
* `inviteOptions` (what of it). `seeHistory: false`: when let in they get only the chat from their own
|
|
151
154
|
* "joined" line on - not what was said before (someone added to a conversation's call is not shown it).
|
|
155
|
+
* `ice`: the ICE servers for this person (an array, or a function making them) - handed over in their
|
|
156
|
+
* `admitted` event, never before: a waiting or turned-away guest holds no TURN credential.
|
|
152
157
|
*/
|
|
153
158
|
join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = '', history, canInvite = false,
|
|
154
|
-
inviteOptions = null, seeHistory = true}) {
|
|
159
|
+
inviteOptions = null, seeHistory = true, ice = null}) {
|
|
155
160
|
let room = rooms.get(key);
|
|
156
161
|
if (!room) {
|
|
157
162
|
const seeded = (Array.isArray(history) ? history : []).map(cleanItem).filter(Boolean).slice(-HISTORY_MAX);
|
|
@@ -177,7 +182,8 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
177
182
|
if (admitted(room).length >= room.max && (host || autoAdmit)) throw new CallError('full', `This call is full (${room.max} people).`);
|
|
178
183
|
const id = newId(9);
|
|
179
184
|
const person = {id, secret: newId(24), identity: String(identity || ''), who: whoOf(identity, id), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
|
|
180
|
-
host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()
|
|
185
|
+
host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now(),
|
|
186
|
+
ice: ice || null};
|
|
181
187
|
room.people.set(person.id, person);
|
|
182
188
|
if (host || autoAdmit) {
|
|
183
189
|
// admit() also tells every host - this one included - who is already waiting.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* nothing: only a Tool that mounts this, and says who may join which room,
|
|
5
5
|
* has calls.
|
|
6
6
|
*
|
|
7
|
-
* POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state,
|
|
7
|
+
* POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state, ...} (ICE servers come in the `admitted` event)
|
|
8
8
|
* GET {prefix}/:room/poll?since=N -> {events, state} (long-poll, 25 s)
|
|
9
9
|
* POST {prefix}/:room/signal {to, data}
|
|
10
10
|
* POST {prefix}/:room/chat {text}
|
|
@@ -118,12 +118,15 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
|
|
|
118
118
|
try {
|
|
119
119
|
const canInvite = inviting && (typeof r.mayInvite === 'boolean' ? r.mayInvite : r.host === true);
|
|
120
120
|
const inviteOptions = canInvite ? inviteOptionsOf(r) : null;
|
|
121
|
+
// The ICE servers (a TURN credential good for ttlHours, when configured) go in this person's `admitted`
|
|
122
|
+
// event, made at admission - not in this reply, which a guest still waiting, or about to be turned away,
|
|
123
|
+
// also gets. Before, anyone holding a call link had a relay credential for the TTL.
|
|
124
|
+
const hostname = request.hostname;
|
|
121
125
|
const joined = rooms.join(key, {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
|
|
122
126
|
recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || '', history, canInvite,
|
|
123
|
-
inviteOptions, seeHistory: r.history !== false});
|
|
127
|
+
inviteOptions, seeHistory: r.history !== false, ice: () => iceServers(config(), hostname)});
|
|
124
128
|
// What "Add people" offers this person: {search, typed, copyLink, external} (invite.js inviteOptionsOf).
|
|
125
|
-
return reply.send({...joined,
|
|
126
|
-
...(inviteOptions ? {inviteOptions} : {})});
|
|
129
|
+
return reply.send({...joined, closesAt: r.closesAt || null, ...(inviteOptions ? {inviteOptions} : {})});
|
|
127
130
|
} catch (err) { return fail(reply, err); }
|
|
128
131
|
});
|
|
129
132
|
|
|
@@ -40,6 +40,18 @@ export default {
|
|
|
40
40
|
*/
|
|
41
41
|
allowInsecureTLS: false,
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Let a mailbox or outgoing server be on a private, loopback or link-local
|
|
45
|
+
* address.
|
|
46
|
+
*
|
|
47
|
+
* Off, deliberately: "test these details" makes this server open TCP to the
|
|
48
|
+
* host typed in and report refused / no answer / unknown - a port scanner of
|
|
49
|
+
* the server's own network behind the Email permission. The site's own
|
|
50
|
+
* configured outgoing server (config/site.json smtp.host) is always allowed.
|
|
51
|
+
* Turn on only for a site whose mail server really is on its LAN.
|
|
52
|
+
*/
|
|
53
|
+
allowPrivateMailHosts: false,
|
|
54
|
+
|
|
43
55
|
/** Largest inline image embedded into a rendered body, in bytes. */
|
|
44
56
|
inlineImageMaxBytes: 2_000_000,
|
|
45
57
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* May this server open a connection to that mail host?
|
|
3
|
+
*
|
|
4
|
+
* `/accounts/test`, saving a mailbox and the outgoing-server routes make the
|
|
5
|
+
* CMS open TCP to whatever host:port the caller typed, and `describeError()`
|
|
6
|
+
* answers "refused", "did not respond" or "could not be resolved" - a port
|
|
7
|
+
* scanner of the server's own network behind the `email` permission
|
|
8
|
+
* (127.0.0.1:27018 is the site's Mongo, :8192 the manager). So a private,
|
|
9
|
+
* loopback or link-local host is refused unless it is the host the site's own
|
|
10
|
+
* outgoing mail already goes to (config/site.json smtp.host - the LAN relay
|
|
11
|
+
* the admin chose) or `allowPrivateMailHosts` is set for a site whose mail
|
|
12
|
+
* server really is on its LAN.
|
|
13
|
+
*
|
|
14
|
+
* The pure half is `isPrivateAddress` / `isLocalName` in unsubscribe.js; this
|
|
15
|
+
* module adds the lookup, which the tests inject.
|
|
16
|
+
*
|
|
17
|
+
* @module _lib/mail/hostGuard
|
|
18
|
+
*/
|
|
19
|
+
import dns from 'node:dns/promises';
|
|
20
|
+
import net from 'node:net';
|
|
21
|
+
import {isLocalName, isPrivateAddress} from './unsubscribe.js';
|
|
22
|
+
|
|
23
|
+
const REFUSED = 'That server is on this machine\'s own network, so the site will not connect to it. '
|
|
24
|
+
+ 'Use the mail server\'s public name, or ask the site administrator to allow private mail hosts.';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalise a typed host for comparison: case, trailing dot, IPv6 brackets.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} host
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function normaliseHost(host) {
|
|
33
|
+
return String(host ?? '').trim().toLowerCase().replace(/\.$/, '').replace(/^\[(.*)\]$/, '$1');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check one host.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} host
|
|
40
|
+
* @param {object} [options]
|
|
41
|
+
* @param {boolean} [options.allowPrivate] - the site's `allowPrivateMailHosts` setting
|
|
42
|
+
* @param {string} [options.ownSmtpHost] - the site's configured outgoing server; always allowed
|
|
43
|
+
* @param {(host: string) => Promise<{address: string}[]>} [options.lookup] - DNS, injectable
|
|
44
|
+
* @returns {Promise<{ok: true} | {ok: false, error: string}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function checkMailHost(host, {allowPrivate = false, ownSmtpHost = '', lookup} = {}) {
|
|
47
|
+
const name = normaliseHost(host);
|
|
48
|
+
if (!name) return {ok: false, error: 'A server is required.'};
|
|
49
|
+
if (allowPrivate) return {ok: true};
|
|
50
|
+
if (ownSmtpHost && normaliseHost(ownSmtpHost) === name) return {ok: true};
|
|
51
|
+
|
|
52
|
+
if (net.isIP(name)) {
|
|
53
|
+
return isPrivateAddress(name) ? {ok: false, error: REFUSED} : {ok: true};
|
|
54
|
+
}
|
|
55
|
+
if (isLocalName(name)) return {ok: false, error: REFUSED};
|
|
56
|
+
|
|
57
|
+
let answers;
|
|
58
|
+
try {
|
|
59
|
+
answers = await (lookup ?? ((h) => dns.lookup(h, {all: true})))(name);
|
|
60
|
+
} catch {
|
|
61
|
+
// Unresolvable: let the connection attempt say so in its own words.
|
|
62
|
+
// The check is about where a name points, not whether it exists.
|
|
63
|
+
return {ok: true};
|
|
64
|
+
}
|
|
65
|
+
// Every answer must be public: a name with one private A record among
|
|
66
|
+
// public ones is exactly the rebinding trick the check exists for.
|
|
67
|
+
const addresses = (answers ?? []).map(a => (typeof a === 'string' ? a : a?.address)).filter(Boolean);
|
|
68
|
+
if (addresses.some(isPrivateAddress)) return {ok: false, error: REFUSED};
|
|
69
|
+
return {ok: true};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The site's own outgoing server, from config/site.json `smtp.host` (what
|
|
74
|
+
* core `createTransport` sends with), read through a getter so a test can
|
|
75
|
+
* hand in its own.
|
|
76
|
+
*
|
|
77
|
+
* @param {(section: string) => any} getConfig
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function siteSmtpHost(getConfig) {
|
|
81
|
+
try {
|
|
82
|
+
return String(getConfig('site')?.smtp?.host ?? '').trim();
|
|
83
|
+
} catch {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
import {simpleParser} from 'mailparser';
|
|
29
29
|
|
|
30
30
|
import {listUsers} from '../../../server/services/users.js';
|
|
31
|
+
import {getConfig} from '../../../server/config.js';
|
|
32
|
+
import {checkMailHost, siteSmtpHost} from './hostGuard.js';
|
|
31
33
|
import * as accounts from './accounts.js';
|
|
32
34
|
import * as bodyTokens from './bodyTokens.js';
|
|
33
35
|
import {buildDiagnostics} from './diagnostics.js';
|
|
@@ -207,6 +209,11 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
207
209
|
});
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
/** Where this server may be asked to connect - see hostGuard.js. */
|
|
213
|
+
const guardHost = (host) => checkMailHost(host, {allowPrivate: config.allowPrivateMailHosts === true, ownSmtpHost: siteSmtpHost(getConfig)});
|
|
214
|
+
// Each test is a connection attempt whose answer says what is listening: a few a minute per address is plenty.
|
|
215
|
+
const testLimit = {config: {rateLimit: {max: 10, timeWindow: '1 minute'}}};
|
|
216
|
+
|
|
210
217
|
/** GET /accounts - every mailbox this user owns, without passwords. */
|
|
211
218
|
fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
|
|
212
219
|
const uid = userId(request);
|
|
@@ -221,6 +228,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
221
228
|
|
|
222
229
|
const result = accounts.validate(request.body ?? {}, null);
|
|
223
230
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
231
|
+
const where = await guardHost(result.value.host);
|
|
232
|
+
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
224
233
|
|
|
225
234
|
const saved = await accounts.saveAccount(uid, null, result.value);
|
|
226
235
|
return {account: accounts.toPublic(saved)};
|
|
@@ -236,6 +245,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
236
245
|
|
|
237
246
|
const result = accounts.validate(request.body ?? {}, existing);
|
|
238
247
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
248
|
+
const where = await guardHost(result.value.host);
|
|
249
|
+
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
239
250
|
|
|
240
251
|
const saved = await accounts.saveAccount(uid, request.params.id, result.value);
|
|
241
252
|
// The pooled connection was opened with the old details.
|
|
@@ -271,7 +282,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
271
282
|
* password field is blank and `id` names an existing mailbox, the stored
|
|
272
283
|
* password is used - otherwise editing a hostname would mean retyping it.
|
|
273
284
|
*/
|
|
274
|
-
fastify.post('/accounts/test', {preHandler: [authenticate]}, async (request, reply) => {
|
|
285
|
+
fastify.post('/accounts/test', {preHandler: [authenticate], ...testLimit}, async (request, reply) => {
|
|
275
286
|
const uid = userId(request);
|
|
276
287
|
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
277
288
|
|
|
@@ -281,6 +292,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
281
292
|
|
|
282
293
|
const result = accounts.validate(body, existing);
|
|
283
294
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
295
|
+
const where = await guardHost(result.value.host);
|
|
296
|
+
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
284
297
|
|
|
285
298
|
let pass = result.value.password;
|
|
286
299
|
if (!pass) {
|
|
@@ -711,6 +724,12 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
711
724
|
reply.code(404).send({error: 'That message is no longer in this folder.'});
|
|
712
725
|
return null;
|
|
713
726
|
}
|
|
727
|
+
// The same ceiling as /messages/:uid and /body: without it a hostile
|
|
728
|
+
// 200 MB message cost a 200 MB buffer plus mailparser output per click.
|
|
729
|
+
if (fetched.source.length > config.maxMessageBytes) {
|
|
730
|
+
reply.code(413).send({error: 'That message is too large to open its attachments.'});
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
714
733
|
|
|
715
734
|
const parsed = await simpleParser(fetched.source);
|
|
716
735
|
const attachment = parsed.attachments?.[index];
|
|
@@ -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;
|
package/plugins/blog/CLAUDE.md
CHANGED
|
@@ -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/
|
|
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. |
|
package/plugins/blog/lib/page.js
CHANGED
|
@@ -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 ?? {})};
|
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
|
|