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.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -0
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +15 -6
- 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/compose.js +7 -2
- package/plugins/_lib/mail/defaults.js +12 -0
- package/plugins/_lib/mail/hostGuard.js +86 -0
- package/plugins/_lib/mail/message-action.js +56 -0
- package/plugins/_lib/mail/readRoutes.js +20 -1
- package/plugins/_lib/mail/scheduler.js +2 -0
- package/plugins/_lib/mail/send.js +12 -3
- package/plugins/_lib/mail/store.js +16 -6
- package/plugins/_lib/outreach/index.js +1 -1
- package/plugins/_lib/outreach/ledger.js +10 -3
- package/plugins/_lib/outreach/sending.js +9 -3
- package/plugins/_lib/outreach/suppression.js +14 -3
- package/plugins/_lib/outreach/unsubscribe.js +13 -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 +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/routes/outreach-public.js +54 -10
- 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
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import {getConfig} from '../../server/config.js';
|
|
26
|
+
import {originOf, trustedOrigin} from '../../server/services/passwordReset.js';
|
|
26
27
|
import {cleanCheckout, orderLabel} from './public/lib/orders.js';
|
|
27
28
|
import {formatMoney} from './public/lib/money.js';
|
|
28
29
|
import {publicProduct, byShelf} from './public/lib/products.js';
|
|
@@ -37,24 +38,45 @@ import {badge, summarise} from './lib/summary.js';
|
|
|
37
38
|
import * as pay from './lib/payments.js';
|
|
38
39
|
import {
|
|
39
40
|
NAME, allOrders, allProducts, annotateOrder, cleanSettings, cleanTracking, createOrder, ensureCollections, extendOrder,
|
|
40
|
-
getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, publicOrder, removeOrder,
|
|
41
|
-
refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt
|
|
41
|
+
getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, moveOrderNow, publicOrder, removeOrder,
|
|
42
|
+
refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt, withLock
|
|
42
43
|
} from './lib/shop.js';
|
|
43
44
|
import {cardHtml, esc, gridHtml} from './lib/render.js';
|
|
44
45
|
|
|
45
|
-
/** The
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
/** Where this site lives, for the addresses a payment provider sends shoppers back to. */
|
|
49
|
-
function siteBase(request) {
|
|
46
|
+
/** The Site URL setting as an origin, or ''. */
|
|
47
|
+
function configuredBase() {
|
|
50
48
|
let base = '';
|
|
51
49
|
try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
return /^https?:\/\/[^/\s]+/i.test(base) ? base.replace(/\/+$/, '') : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where this site lives, for the addresses a payment provider sends shoppers back to: the Site URL setting,
|
|
55
|
+
* else the origin of the shopper's own request (they land back where they came from). Never remembered for
|
|
56
|
+
* later: any request can claim any Host, and a kept one would put a stranger's address - and the receipt
|
|
57
|
+
* tokens - in every customer email until the next request.
|
|
58
|
+
*/
|
|
59
|
+
function siteBase(request) {
|
|
60
|
+
return configuredBase() || (request ? `${request.protocol}://${request.host || request.headers.host || request.hostname}` : '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let warnedNoBase = false;
|
|
64
|
+
let warn = (msg) => console.warn(msg);
|
|
65
|
+
/**
|
|
66
|
+
* The address for links with no shopper's request behind them (emails, links handed to other Tools): core's
|
|
67
|
+
* rule as for password resets - the Site URL, else an address someone has really signed in on. '' when the
|
|
68
|
+
* site knows none; the email then goes without its link, and the log says why once.
|
|
69
|
+
*/
|
|
70
|
+
async function quietBase() {
|
|
71
|
+
const base = configuredBase() || await trustedOrigin('').catch(() => '');
|
|
72
|
+
if (!base && !warnedNoBase) {
|
|
73
|
+
warnedNoBase = true;
|
|
74
|
+
warn('[shopping-cart] Site URL is not set (Site Settings): emails go out without links to the order until it is.');
|
|
75
|
+
}
|
|
76
|
+
return base;
|
|
57
77
|
}
|
|
78
|
+
/** The same rule for an email answering a request (account confirm, forgotten password). */
|
|
79
|
+
const linkBase = (request) => configuredBase() || trustedOrigin(originOf(request)).catch(() => '');
|
|
58
80
|
|
|
59
81
|
const receiptPath = (s, order) => `${s.shopPath}/order/${order.id}?t=${order.data.token}`;
|
|
60
82
|
|
|
@@ -138,6 +160,7 @@ function registerShortcodes(hooks) {
|
|
|
138
160
|
}
|
|
139
161
|
|
|
140
162
|
export default async function shoppingCart(fastify, options) {
|
|
163
|
+
warn = (msg) => fastify.log.warn(msg);
|
|
141
164
|
const {authenticate, requirePermission} = options.auth;
|
|
142
165
|
const hooks = options.hooks || {};
|
|
143
166
|
const can = (action) => ({preHandler: [authenticate, requirePermission('shop', action)]});
|
|
@@ -181,7 +204,10 @@ export default async function shoppingCart(fastify, options) {
|
|
|
181
204
|
name: NAME,
|
|
182
205
|
liveSettings, allOrders, getOrder, allProducts, getProduct, extendOrder, publicOrder,
|
|
183
206
|
formatMoney, orderLabel,
|
|
207
|
+
/** The Site URL as an origin, or '' - sync, for the shopper-facing paths. Email links: `linkBase`. */
|
|
184
208
|
siteBase: () => siteBase(null),
|
|
209
|
+
/** The address for links in emails (async): the Site URL, else an address someone has really signed in on, else ''. */
|
|
210
|
+
linkBase: quietBase,
|
|
185
211
|
/** Change only an extension's part of a product, under the order lock (lib/shop.js updateProductExt). */
|
|
186
212
|
updateProductExt,
|
|
187
213
|
/** Move an order on (Shopping Cart Pro fulfils an order with nothing to post), telling every extension. */
|
|
@@ -190,7 +216,7 @@ export default async function shoppingCart(fastify, options) {
|
|
|
190
216
|
announceMove(order, from, order.data.status);
|
|
191
217
|
return order;
|
|
192
218
|
},
|
|
193
|
-
receiptUrl: (order) => liveSettings()
|
|
219
|
+
receiptUrl: async (order) => { const [s, base] = await Promise.all([liveSettings(), quietBase()]); return base ? `${base}${receiptPath(s, order)}` : ''; }
|
|
194
220
|
};
|
|
195
221
|
|
|
196
222
|
// ---- Admin: overview, badge, extensions ----------------------------------------
|
|
@@ -366,26 +392,32 @@ export default async function shoppingCart(fastify, options) {
|
|
|
366
392
|
/** Refund an order in full (see above). Throws {status, message}. */
|
|
367
393
|
async function refundOrder(id, {restock = false, note = '', viaProvider = true, by = 'admin'} = {}) {
|
|
368
394
|
const bad = (status, message) => Object.assign(new Error(message), {status});
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
395
|
+
// The whole refund runs under the order lock: two refunds at once would both reach the provider, and a
|
|
396
|
+
// write landing between the provider's yes and ours would be lost. The provider call carries an
|
|
397
|
+
// Idempotency-Key from the order id, so a retry after a timeout returns the first refund, not a second.
|
|
398
|
+
const {order, from, how} = await withLock(async () => {
|
|
399
|
+
const o = await getOrder(id);
|
|
400
|
+
if (!o) throw bad(404, 'No such order.');
|
|
401
|
+
const d = o.data;
|
|
402
|
+
if (!['paid', 'fulfilled'].includes(d.status)) throw bad(409, 'Only a paid order can be refunded.');
|
|
403
|
+
const s = await liveSettings();
|
|
404
|
+
let via = 'marked refunded';
|
|
405
|
+
try {
|
|
406
|
+
if (viaProvider && d.provider === 'stripe' && d.paymentRef) {
|
|
407
|
+
if (!s.stripeSecretKey) throw bad(409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
|
|
408
|
+
await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef, idempotencyKey: `${NAME}-refund-${o.id}`});
|
|
409
|
+
via = 'refunded through Stripe';
|
|
410
|
+
} else if (viaProvider && d.provider === 'paypal' && d.paymentRef) {
|
|
411
|
+
if (!s.paypalClientId || !s.paypalSecret) throw bad(409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
|
|
412
|
+
await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef});
|
|
413
|
+
via = 'refunded through PayPal';
|
|
414
|
+
}
|
|
415
|
+
} catch (err) {
|
|
416
|
+
throw bad(err.status || 502, err.status === 409 ? err.message : `Not refunded: ${err.message}`);
|
|
384
417
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
const {order, from} = await moveOrder(o.id, 'refunded', {by, note: [how, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
|
|
418
|
+
const moved = await moveOrderNow(o.id, 'refunded', {by, note: [via, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
|
|
419
|
+
return {...moved, how: via};
|
|
420
|
+
});
|
|
389
421
|
announceMove(order, from, 'refunded');
|
|
390
422
|
return {order, how};
|
|
391
423
|
}
|
|
@@ -465,30 +497,35 @@ export default async function shoppingCart(fastify, options) {
|
|
|
465
497
|
/** `rest` -> an absolute address under the shop, for emails. */
|
|
466
498
|
const shopLink = async (request) => {
|
|
467
499
|
const s = await liveSettings();
|
|
468
|
-
const base =
|
|
469
|
-
return {s, link: (rest) => `${base}${s.shopPath}/${rest}`};
|
|
500
|
+
const base = await linkBase(request);
|
|
501
|
+
return {s, base, link: (rest) => `${base}${s.shopPath}/${rest}`};
|
|
470
502
|
};
|
|
471
503
|
const accountFail = (reply, err) => fail(reply, err.status || 500, err.message, err.errors ? {errors: err.errors} : {});
|
|
472
504
|
|
|
473
505
|
fastify.post('/account/register', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
474
506
|
if (await accountsOff(reply)) return undefined;
|
|
475
|
-
const {s, link} = await shopLink(request);
|
|
507
|
+
const {s, base, link} = await shopLink(request);
|
|
508
|
+
// A confirm link needs an address the site trusts; the Host header is not one.
|
|
509
|
+
if (!base) return fail(reply, 409, 'Accounts cannot be made just now - please check out as a guest.');
|
|
476
510
|
try {
|
|
477
511
|
return await accounts.register(request.body || {}, {link, shopTitle: s.shopTitle || 'the shop', log: request.log});
|
|
478
512
|
} catch (err) { return accountFail(reply, err); }
|
|
479
513
|
});
|
|
480
514
|
|
|
481
515
|
fastify.post('/account/confirm', {config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
516
|
+
if (await accountsOff(reply)) return undefined;
|
|
482
517
|
try {
|
|
483
|
-
|
|
484
|
-
|
|
518
|
+
// Nothing about the account comes back - not even its email: `u` is a user id, and those are public.
|
|
519
|
+
await accounts.confirm(request.body?.u, request.body?.t);
|
|
520
|
+
return {confirmed: true};
|
|
485
521
|
} catch (err) { return accountFail(reply, err); }
|
|
486
522
|
});
|
|
487
523
|
|
|
488
524
|
fastify.post('/account/forgot', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
489
525
|
if (await accountsOff(reply)) return undefined;
|
|
490
|
-
const {s, link} = await shopLink(request);
|
|
491
|
-
await accounts.forgot(request.body?.email, {link, shopTitle: s.shopTitle || 'the shop', log: request.log}).catch(() => null);
|
|
526
|
+
const {s, base, link} = await shopLink(request);
|
|
527
|
+
if (base) await accounts.forgot(request.body?.email, {link, shopTitle: s.shopTitle || 'the shop', log: request.log}).catch(() => null);
|
|
528
|
+
else request.log.warn('[shopping-cart] password reset not sent: the site has no trusted address (set the Site URL)');
|
|
492
529
|
// The same answer whoever asked: the form must not tell anyone who has an account.
|
|
493
530
|
return {ok: true};
|
|
494
531
|
});
|
|
@@ -531,7 +568,6 @@ export default async function shoppingCart(fastify, options) {
|
|
|
531
568
|
});
|
|
532
569
|
|
|
533
570
|
fastify.post('/quote', async (request) => {
|
|
534
|
-
siteBase(request);
|
|
535
571
|
const s = await liveSettings();
|
|
536
572
|
const {quote} = await quoteFor(request.body, s);
|
|
537
573
|
return shopperQuote(quote, s);
|
|
@@ -690,7 +726,7 @@ export default async function shoppingCart(fastify, options) {
|
|
|
690
726
|
const methods = methodsOf(s).map(m => m.id);
|
|
691
727
|
const via = quote.total === 0 ? 'free' : (provider || methods[0] || '');
|
|
692
728
|
if (via !== 'free' && !methods.includes(via)) throw bad(409, 'The Shop has no way to take payment set up yet.');
|
|
693
|
-
const origin = /^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') :
|
|
729
|
+
const origin = configuredBase() || (/^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') : await quietBase());
|
|
694
730
|
const back = /^\/(?!\/)/.test(String(returnTo)) ? `${origin}${returnTo}` : '';
|
|
695
731
|
const order = await createOrder({quote, checkout, provider: via, delivery: deliver ? 'ship' : delivery === 'collect' ? 'collect' : 'none',
|
|
696
732
|
code: String(code || '').slice(0, 60), ref, returnTo: back, ownEmails: ownEmails === true, payNote});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopping-cart",
|
|
3
3
|
"displayName": "Shopping Cart",
|
|
4
|
-
"version": "1.6.
|
|
4
|
+
"version": "1.6.1",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
8
|
+
"date": "2026-10-13",
|
|
9
9
|
"icon": "shopping-bag",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -208,9 +208,9 @@ function signedInView(body, me) {
|
|
|
208
208
|
|
|
209
209
|
async function confirmView(body) {
|
|
210
210
|
try {
|
|
211
|
-
|
|
211
|
+
await account.confirm(params.get('u'), params.get('t'));
|
|
212
212
|
document.querySelector('.shop-account h1').textContent = 'Your account';
|
|
213
|
-
signedOutView(body, {
|
|
213
|
+
signedOutView(body, {notice: 'Thank you - your account is confirmed. Sign in to carry on.'});
|
|
214
214
|
} catch (err) {
|
|
215
215
|
document.querySelector('.shop-account h1').textContent = 'That link did not work';
|
|
216
216
|
body.innerHTML = `<p class="shop-co-error">${esc(err.message)}</p><p><a class="shop-linkbtn" href="${esc(shopPath)}/account">Sign in or create an account</a></p>`;
|
|
@@ -62,6 +62,7 @@ const linkIn = (m) => /https?:\/\/\S+/.exec(m.text)?.[0] || '';
|
|
|
62
62
|
|
|
63
63
|
test('accounts: off by default; create, confirm by email, details, addresses, orders by email, reset, delete', async () => {
|
|
64
64
|
assert.equal((await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'a-long-password'})).status, 404, 'off until switched on');
|
|
65
|
+
assert.equal((await call('POST', '/account/confirm', {u: '00000000-0000-4000-8000-000000000000', t: '0'.repeat(48)})).status, 404, 'confirm is off too');
|
|
65
66
|
const put = await call('PUT', '/settings', {accounts: true, offlineEnabled: true, countries: ['GB']});
|
|
66
67
|
assert.equal(put.status, 200, JSON.stringify(put.body));
|
|
67
68
|
assert.equal(put.body.settings?.accounts ?? put.body.accounts, true, JSON.stringify(put.body).slice(0, 300));
|
|
@@ -81,11 +82,19 @@ test('accounts: off by default; create, confirm by email, details, addresses, or
|
|
|
81
82
|
assert.equal(mail.length, 1, 'still pending: no second email, no second account');
|
|
82
83
|
|
|
83
84
|
assert.equal((await call('POST', '/account/confirm', {u: user.id, t: '0'.repeat(48)})).status, 404);
|
|
85
|
+
assert.equal((await call('POST', '/account/confirm', {u: user.id})).status, 404, 'no token, no answer');
|
|
84
86
|
r = await call('POST', '/account/confirm', {u: confirmLink.searchParams.get('u'), t: confirmLink.searchParams.get('t')});
|
|
85
|
-
assert.deepEqual(r.body, {confirmed: true, email
|
|
87
|
+
assert.deepEqual(r.body, {confirmed: true}, 'confirms, and says nothing about the account - not even its email');
|
|
86
88
|
user = await getUserById(user.id);
|
|
87
89
|
assert.equal(user.isActive, true);
|
|
88
90
|
assert.ok(!user.meta.shop.pending);
|
|
91
|
+
// The link is spent: a second click, or any guess at an active user's id, learns nothing (ids are public).
|
|
92
|
+
r = await call('POST', '/account/confirm', {u: confirmLink.searchParams.get('u'), t: confirmLink.searchParams.get('t')});
|
|
93
|
+
assert.equal(r.status, 404);
|
|
94
|
+
assert.equal(JSON.stringify(r.body).includes(EMAILS[0]), false);
|
|
95
|
+
r = await call('POST', '/account/confirm', {u: user.id, t: 'x'});
|
|
96
|
+
assert.equal(r.status, 404);
|
|
97
|
+
assert.equal(JSON.stringify(r.body).includes(EMAILS[0]), false);
|
|
89
98
|
r = await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'a-long-password'});
|
|
90
99
|
assert.match(mail.at(-1).subject, /already have an account/);
|
|
91
100
|
|
|
@@ -302,3 +302,26 @@ test('sales: the report and its CSV over the API', async () => {
|
|
|
302
302
|
assert.match(csv.headers['content-disposition'], /attachment; filename="sales-2020-01-01-to-2030-12-31\.csv"/);
|
|
303
303
|
assert.ok(csv.body.startsWith('Order,Paid,Status'));
|
|
304
304
|
});
|
|
305
|
+
|
|
306
|
+
test('review: links for emails never come from a request\'s Host header; two refunds at once have one winner', async () => {
|
|
307
|
+
const reg = globalThis[Symbol.for('domma.shopping-cart')];
|
|
308
|
+
await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB'], taxRate: 0, shippingFlat: 0});
|
|
309
|
+
const pen = (await call('POST', '/products', {name: 'Pen', price: 500, status: 'active', shippable: false})).body;
|
|
310
|
+
// A forged Host on a public request must not become the address in anyone's email.
|
|
311
|
+
const forged = await app.inject({method: 'POST', url: '/quote', headers: {host: 'evil.example'}, payload: {lines: [{productId: pen.id, qty: 1}]}});
|
|
312
|
+
assert.equal(forged.statusCode, 200);
|
|
313
|
+
assert.equal(String(reg.services.siteBase()).includes('evil.example'), false);
|
|
314
|
+
const placed = await call('POST', '/checkout', {lines: [{productId: pen.id, qty: 1}], provider: 'offline', customer: {name: 'Eve', email: 'eve@example.com'}});
|
|
315
|
+
assert.equal(placed.status, 200, JSON.stringify(placed.body));
|
|
316
|
+
const order = await reg.services.getOrder(placed.body.orderId);
|
|
317
|
+
const receipt = await reg.services.receiptUrl(order);
|
|
318
|
+
assert.equal(receipt.includes('evil.example'), false, `receipt link: ${receipt}`);
|
|
319
|
+
assert.equal((await reg.services.linkBase()).includes('evil.example'), false);
|
|
320
|
+
if (receipt) assert.match(receipt, /^https?:\/\/[^/]+\/shop\/order\//);
|
|
321
|
+
|
|
322
|
+
// Paid, then refunded twice in the same instant: one refund, one "only a paid order" refusal.
|
|
323
|
+
await call('PUT', `/orders/${order.id}/status`, {status: 'paid'});
|
|
324
|
+
const both = await Promise.all([call('POST', `/orders/${order.id}/refund`, {}), call('POST', `/orders/${order.id}/refund`, {})]);
|
|
325
|
+
assert.deepEqual(both.map(r => r.status).sort(), [200, 409], JSON.stringify(both.map(r => r.body)));
|
|
326
|
+
assert.equal((await reg.services.getOrder(order.id)).data.history.filter(h => h.status === 'refunded').length, 1);
|
|
327
|
+
});
|
|
@@ -5,7 +5,7 @@ import {formatMoney, fromMinor, includedTax, percentOf, toMinor} from '../public
|
|
|
5
5
|
import {available, cleanProduct, priceRange, publicProduct, slugify, unitPrice} from '../public/lib/products.js';
|
|
6
6
|
import {canMove, cleanCheckout} from '../public/lib/orders.js';
|
|
7
7
|
import {cleanCharges, cleanCustomLines, cleanLines, priceBasket} from '../lib/pricing.js';
|
|
8
|
-
import {paypalPaid, stripeForm, stripeLineItems, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
|
|
8
|
+
import {paypalPaid, stripeCreateSession, stripeForm, stripeLineItems, stripeRefund, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
|
|
9
9
|
import {badge, summarise} from '../lib/summary.js';
|
|
10
10
|
import {cartRegistry} from '../lib/extensions.js';
|
|
11
11
|
import {matchShopPath} from '../plugin.public.js';
|
|
@@ -298,3 +298,18 @@ test('pricing: a shipping handler can offer services to choose between', async (
|
|
|
298
298
|
assert.equal(q.shipping, 900);
|
|
299
299
|
assert.equal(q.shippingChoice, 'exp');
|
|
300
300
|
});
|
|
301
|
+
|
|
302
|
+
test('payments: Stripe refunds and sessions carry an Idempotency-Key, so a retry after a timeout is not a second one', async () => {
|
|
303
|
+
const seen = [];
|
|
304
|
+
const realFetch = globalThis.fetch;
|
|
305
|
+
globalThis.fetch = async (url, init) => { seen.push({url, init}); return {ok: true, json: async () => ({id: 'cs_test_1', url: 'https://pay.test/x'})}; };
|
|
306
|
+
try {
|
|
307
|
+
await stripeRefund({secretKey: 'sk_test_x', paymentIntent: 'pi_1', idempotencyKey: 'shopping-cart-refund-o1'});
|
|
308
|
+
assert.equal(seen[0].init.headers['Idempotency-Key'], 'shopping-cart-refund-o1');
|
|
309
|
+
await stripeRefund({secretKey: 'sk_test_x', paymentIntent: 'pi_1'});
|
|
310
|
+
assert.equal('Idempotency-Key' in seen[1].init.headers, false, 'none asked for, none sent');
|
|
311
|
+
await stripeCreateSession({secretKey: 'sk_test_x', order: {id: 'o1', number: 1001, currency: 'GBP', total: 100, lines: [{name: 'Mug', qty: 1, unitPrice: 100, lineTotal: 100}], adjustments: []},
|
|
312
|
+
successUrl: 'https://s.test/ok', cancelUrl: 'https://s.test/no'});
|
|
313
|
+
assert.equal(seen[2].init.headers['Idempotency-Key'], 'shop-session-o1');
|
|
314
|
+
} finally { globalThis.fetch = realFetch; }
|
|
315
|
+
});
|
|
@@ -76,3 +76,9 @@ manager also "Quote in reply").
|
|
|
76
76
|
|
|
77
77
|
Site: new replies (`info`), else open requests as an inventory. Manager: requests waiting on Domma,
|
|
78
78
|
`danger` while any is urgent, `attention` otherwise; nothing waiting = every request, inventory.
|
|
79
|
+
|
|
80
|
+
## 1.1.2 (2026-10-13 Tool sweep hygiene)
|
|
81
|
+
|
|
82
|
+
- `docs/guide.md` added (the Documentation > Plugins page Feedback already had). The manifest entry carries
|
|
83
|
+
`?v=1.1.2` and `V = '1.1.2'` loads the sub-views - bump both with the version. lib/receiver.js header corrected
|
|
84
|
+
(fleet tokens are on disk since 2026-10-04).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Guide
|
|
3
|
+
order: 1
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Support gets you help from Domma without leaving your admin: our email, phone and hours are always to hand, and a request is a conversation with the Domma team. It is free, on every site.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
- Shows the **contact card**: Domma's email, phone, hours and how quickly to expect an answer.
|
|
11
|
+
- Sends a request to the Domma team with your name, your site and its CMS version attached, and shows Domma's replies underneath it, newest at the bottom.
|
|
12
|
+
- Tells you when Domma replies, and lets you write back in the same request.
|
|
13
|
+
- Keeps a request safe if Domma cannot be reached, and sends it as soon as it can.
|
|
14
|
+
|
|
15
|
+
## Getting started
|
|
16
|
+
|
|
17
|
+
1. Open [Support](#/plugins/support) from the admin sidebar.
|
|
18
|
+
2. Click **Ask for help**.
|
|
19
|
+
3. Pick **What is it about?**: I need help with something, Something is not working, Billing or my account, or Something else.
|
|
20
|
+
4. Type a **Subject** (one line) and your **Message**.
|
|
21
|
+
5. Choose **Normal** (we answer in order, usually within a working day) or **Urgent** (your site is down, or you cannot take orders, bookings or payments).
|
|
22
|
+
6. Optional: fill in **Where** - the screen or page it is about, for example "Pages" or `/contact`.
|
|
23
|
+
7. Click **Send**.
|
|
24
|
+
|
|
25
|
+
For something that is not working, say what you did, what you expected and what happened instead.
|
|
26
|
+
|
|
27
|
+
## Screens
|
|
28
|
+
|
|
29
|
+
### Requests
|
|
30
|
+
|
|
31
|
+
Each row shows the subject, when it was last written to, and a status:
|
|
32
|
+
|
|
33
|
+
| Status | Meaning |
|
|
34
|
+
|---|---|
|
|
35
|
+
| Waiting on Domma | Domma has it and has not replied yet |
|
|
36
|
+
| Domma replied | There is a reply - it is your turn |
|
|
37
|
+
| Resolved | Finished. Writing again reopens it |
|
|
38
|
+
| Waiting to send | Domma could not be reached yet; it goes automatically |
|
|
39
|
+
|
|
40
|
+
A dot on a row means a new reply. Click a row to read the conversation and **Reply**. Right-click a row (or use the row's menu) to **Archive**, **Resolve** or **Remove** it:
|
|
41
|
+
|
|
42
|
+
- **Archive** tidies a request away under **Archived**. It comes back by itself if Domma writes to it.
|
|
43
|
+
- **Resolve** closes it. You can still write to it, which reopens it.
|
|
44
|
+
- **Remove** takes it off your screen. Domma keeps its copy. Removing a request that has not reached Domma yet cancels it.
|
|
45
|
+
|
|
46
|
+
### Not connected
|
|
47
|
+
|
|
48
|
+
If the screen offers the contact card only and says to email us, your site was not started by a Domma server, so there is nowhere to send a request to. Email or phone us instead.
|
|
49
|
+
|
|
50
|
+
## Settings
|
|
51
|
+
|
|
52
|
+
There is no settings screen. Whoever runs the site can change one option in the plugin's configuration file:
|
|
53
|
+
|
|
54
|
+
| Option | Default | Meaning |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `syncMinutes` | 5 | How often the site sends waiting requests and checks for replies |
|
|
57
|
+
|
|
58
|
+
Opening the Support screen also sends anything waiting and checks for replies straight away. The contact card is set by Domma and arrives with the first sync.
|
|
59
|
+
|
|
60
|
+
## Notifications
|
|
61
|
+
|
|
62
|
+
When Domma replies, you get an admin notification with the request's subject. The sidebar badge counts replies you have not opened; opening the request clears it. With nothing new, the badge shows how many requests are waiting on Domma.
|
|
63
|
+
|
|
64
|
+
## Permissions and roles
|
|
65
|
+
|
|
66
|
+
In System > Roles, Support appears in the **Plugins** group:
|
|
67
|
+
|
|
68
|
+
| Action | Label | Allows |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `support.send` | Send | Ask Domma for help and see this site's requests |
|
|
71
|
+
| `support.manage` | Manage | Only used on the Domma server (see below) |
|
|
72
|
+
|
|
73
|
+
Only the **admin** role has them by default. Everyone with **Send** sees every request the site has sent, not just their own.
|
|
74
|
+
|
|
75
|
+
## On the Domma server
|
|
76
|
+
|
|
77
|
+
On the Domma server itself (the Manager), the same screen is the **Inbox** of every site's requests, for holders of **Support > Manage**:
|
|
78
|
+
|
|
79
|
+
- Filter by **Open**, **Answered**, **Resolved**, **Archived** or **All**, urgent first; search subject, text, person and site.
|
|
80
|
+
- Open a request to read the conversation, **Reply** (the site sees it and is notified, and the person is emailed), **Resolve**, change its priority, and keep **Internal notes** (never sent to the site).
|
|
81
|
+
- Right-click a row to archive, resolve or **Delete** it. Archiving is Domma's own filing: the site does not see it, and a request comes out of the archive when the customer writes again.
|
|
82
|
+
- A new request or a customer's reply is emailed to the Domma support address, with the customer as Reply-To and `[URGENT]` in the subject when it is urgent.
|
|
83
|
+
|
|
84
|
+
## Limitations
|
|
85
|
+
|
|
86
|
+
- Only sites started by a Domma server can send. A standalone install gets the contact card only.
|
|
87
|
+
- No attachments or screenshots. Describe the problem in words, and use **Where** for the page.
|
|
88
|
+
- Subjects are limited to 140 characters and messages to 8,000.
|
|
89
|
+
- The Domma server accepts up to 20 requests and replies from one site in 10 minutes. Any more show as **Waiting to send** and go later.
|
|
90
|
+
- You cannot edit or withdraw a message once sent. Write a follow-up instead.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* names, emails and words.
|
|
7
7
|
*
|
|
8
8
|
* A site is who its fleet token says it is (site-manager/services/fleetTokens.js,
|
|
9
|
-
*
|
|
9
|
+
* minted when the manager forked it and kept in site-manager/data/fleet-tokens.json). That module only exists where
|
|
10
10
|
* Site Manager does, which is also how this plugin knows it is on the manager.
|
|
11
11
|
*
|
|
12
12
|
* @module support/lib/receiver
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "support",
|
|
3
3
|
"displayName": "Support",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.2",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Get help from Domma straight from your admin: our email, phone and hours always to hand, and a conversation with the Domma team for anything you need - urgent requests first. Right-click a request to archive, resolve or remove it.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
8
|
+
"date": "2026-10-13",
|
|
9
9
|
"icon": "headphones",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
],
|
|
62
62
|
"views": {
|
|
63
63
|
"plugin-support": {
|
|
64
|
-
"entry": "support/admin/views/support.js",
|
|
64
|
+
"entry": "support/admin/views/support.js?v=1.1.2",
|
|
65
65
|
"exportName": "supportView"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -56,8 +56,33 @@ const JOURNEYS_FILE = path.join(DATA_DIR, 'journeys.json');
|
|
|
56
56
|
const EXCLUDE_ADMIN = true;
|
|
57
57
|
const EXCLUDE_PATHS = ['/api'];
|
|
58
58
|
const JOURNEY_DAILY_CAP = 5000;
|
|
59
|
+
const JOURNEY_RETENTION_DAYS = 90; // the longest range the screens offer; older days are never read
|
|
59
60
|
const REALTIME_WINDOW_MS = 5 * 60 * 1000;
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The beacon's `url` becomes an object key in lifetime.json and daily.json, so
|
|
64
|
+
* it must be a site path: starting with '/', no whitespace, at most 2 KB
|
|
65
|
+
* (anything a browser posts is far shorter; a 1 MB key would otherwise persist
|
|
66
|
+
* for ever). Prototype names are refused explicitly as well - the '/' rule
|
|
67
|
+
* already excludes them, but `lifetime["constructor"] + 1` turning every total
|
|
68
|
+
* into a string is exactly the bug a relaxed rule must not bring back.
|
|
69
|
+
*/
|
|
70
|
+
const URL_MAX = 2048;
|
|
71
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
72
|
+
function isBeaconPath(url) {
|
|
73
|
+
return typeof url === 'string' && url.length <= URL_MAX && url.startsWith('/') && !/\s/.test(url);
|
|
74
|
+
}
|
|
75
|
+
function isSafeKey(key) {
|
|
76
|
+
return !UNSAFE_KEYS.has(key) && !UNSAFE_KEYS.has(key.replace(/^\/+/, ''));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Drop journey days older than the retention - nothing reads them, and the file is rewritten on every hit. */
|
|
80
|
+
function pruneJourneys(journeys, today, days = JOURNEY_RETENTION_DAYS) {
|
|
81
|
+
const cutoff = todayKey(new Date(Date.parse(today) - days * 86400000));
|
|
82
|
+
for (const day of Object.keys(journeys)) if (day < cutoff) delete journeys[day];
|
|
83
|
+
return journeys;
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
async function readJson(file, fallback) {
|
|
62
87
|
try {
|
|
63
88
|
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
@@ -401,11 +426,17 @@ export async function analyticsRoutes(fastify, opts = {}) {
|
|
|
401
426
|
if (!url || typeof url !== 'string') {
|
|
402
427
|
return reply.status(400).send({ error: 'url is required' });
|
|
403
428
|
}
|
|
429
|
+
if (!isBeaconPath(url)) {
|
|
430
|
+
return reply.status(400).send({ error: 'url must be a site path of 2 KB or less' });
|
|
431
|
+
}
|
|
404
432
|
if (dnt === true || dnt === '1') {
|
|
405
433
|
return { ok: true, ignored: 'dnt' };
|
|
406
434
|
}
|
|
407
435
|
|
|
408
436
|
const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
|
|
437
|
+
if (!isSafeKey(normalised)) {
|
|
438
|
+
return reply.status(400).send({ error: 'url must be a site path of 2 KB or less' });
|
|
439
|
+
}
|
|
409
440
|
if (EXCLUDE_ADMIN && normalised.startsWith('/admin')) {
|
|
410
441
|
return { ok: true, ignored: 'admin' };
|
|
411
442
|
}
|
|
@@ -432,6 +463,7 @@ export async function analyticsRoutes(fastify, opts = {}) {
|
|
|
432
463
|
// Journey event - only if sid is a string of reasonable shape, and under cap
|
|
433
464
|
const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
|
|
434
465
|
const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
|
|
466
|
+
pruneJourneys(journeys, today);
|
|
435
467
|
if (safeSid) {
|
|
436
468
|
if (!journeys[today]) journeys[today] = [];
|
|
437
469
|
if (shouldRecordJourney(journeys[today])) {
|