domma-cms 1.6.33 → 1.6.35
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/effect-defs.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 +3 -3
- 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/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/free-tier.lock.json +35 -24
- package/plugins/shopping-cart/CLAUDE.md +64 -0
- 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 +3 -2
- 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 +25 -0
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +180 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/pricing.js +65 -13
- package/plugins/shopping-cart/lib/render.js +17 -4
- package/plugins/shopping-cart/lib/sales.js +193 -0
- package/plugins/shopping-cart/lib/shop.js +24 -3
- package/plugins/shopping-cart/lib/summary.js +4 -2
- package/plugins/shopping-cart/plugin.js +156 -22
- package/plugins/shopping-cart/plugin.json +2 -2
- 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 +127 -0
- package/plugins/shopping-cart/tests/api.test.js +63 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/pure.test.js +30 -1
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- 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/shortcode-expiry.mjs +1 -0
- package/public/js/site.js +1 -1
- package/public/js/text-style.mjs +1 -1
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/routes/public.js +24 -1
- package/server/server.js +5 -0
- package/server/services/content.js +7 -7
- package/server/services/markdown.js +15 -5
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/services/shortcodeExpiry.js +146 -0
- package/server/templates/page.html +3 -3
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Shared per-shortcode expiry state for public page renders. */
|
|
2
|
+
import {createHmac, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import {SHORTCODE_EXPIRY_FIELDS} from '../../public/js/shortcode-expiry.mjs';
|
|
7
|
+
|
|
8
|
+
const STATE_FILE = path.resolve('content/shortcode-expiry.json');
|
|
9
|
+
const EXPIRY_KEYS = new Set(['expires-at', 'expire-after', 'expire-views', 'expire-scope', 'expire-ip', 'expire-session', 'expiry-id']);
|
|
10
|
+
let stateQueue = Promise.resolve();
|
|
11
|
+
|
|
12
|
+
export function hasShortcodeExpiry(attrs) {
|
|
13
|
+
return Object.keys(attrs || {}).some(key => EXPIRY_KEYS.has(key));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function validSessionCookie(value, secret) {
|
|
17
|
+
if (!value || !secret) return '';
|
|
18
|
+
const [id, signature, extra] = String(value).split('.');
|
|
19
|
+
if (!id || !signature || extra || !/^[a-f0-9]{32}$/.test(id)) return '';
|
|
20
|
+
const expected = createHmac('sha256', secret).update(id).digest('hex');
|
|
21
|
+
const a = Buffer.from(signature), b = Buffer.from(expected);
|
|
22
|
+
return a.length === b.length && timingSafeEqual(a, b) ? id : '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function newSessionCookie(secret) {
|
|
26
|
+
const id = randomBytes(16).toString('hex');
|
|
27
|
+
const signature = createHmac('sha256', secret).update(id).digest('hex');
|
|
28
|
+
return {id, value:`${id}.${signature}`};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stateKey(page, identity, scope, secret) {
|
|
32
|
+
return createHmac('sha256', secret).update(`${page}\0${identity}\0${scope}`).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ipAllowed(ruleText, ip) {
|
|
36
|
+
const rules = String(ruleText || '').split(/[\s,;]+/).filter(Boolean);
|
|
37
|
+
if (!rules.length) return true;
|
|
38
|
+
return rules.some(rule => {
|
|
39
|
+
if (rule === ip) return true;
|
|
40
|
+
const [network, prefix] = rule.split('/');
|
|
41
|
+
if (!prefix || net.isIP(network) !== net.isIP(ip)) return false;
|
|
42
|
+
try {
|
|
43
|
+
const block = new net.BlockList();
|
|
44
|
+
block.addSubnet(network, Number(prefix), net.isIP(network) === 4 ? 'ipv4' : 'ipv6');
|
|
45
|
+
return block.check(ip, net.isIP(ip) === 4 ? 'ipv4' : 'ipv6');
|
|
46
|
+
} catch { return false; }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Replace expired shortcode ranges, counting each actual public page render once. */
|
|
51
|
+
async function applyShortcodeExpiryUnlocked(markdown, {urlPath = '/', ip = '', sessionId = '', preview = false, secret = '', parseAttrs} = {}) {
|
|
52
|
+
const tokenRe = /\[(\/)?([a-z][\w-]*)(?:\s+((?:[^\]"']|"[^"]*"|'[^']*')*?))?\s*(\/)?\]/gi;
|
|
53
|
+
const tokens = [...markdown.matchAll(tokenRe)].map(match => ({
|
|
54
|
+
start: match.index, end: match.index + match[0].length, raw:match[0], close:!!match[1],
|
|
55
|
+
name:match[2].toLowerCase(), attrsText:match[3] || '', self:!!match[4], attrs:parseAttrs(match[3] || '')
|
|
56
|
+
}));
|
|
57
|
+
const ranges = [];
|
|
58
|
+
const stack = [];
|
|
59
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
60
|
+
const t = tokens[i];
|
|
61
|
+
if (t.close) {
|
|
62
|
+
for (let j = stack.length - 1; j >= 0; j--) {
|
|
63
|
+
if (stack[j].name === t.name) {
|
|
64
|
+
const open = stack.splice(j, 1)[0];
|
|
65
|
+
if (open.expiring) ranges.push({start:open.start, end:t.end, token:open});
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
t.expiring = hasShortcodeExpiry(t.attrs);
|
|
71
|
+
if (t.self || /\/\s*$/.test(t.attrsText)) {
|
|
72
|
+
if (t.expiring) ranges.push({start:t.start, end:t.end, token:t});
|
|
73
|
+
} else stack.push(t);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Treat malformed/unclosed expiry tags as extending to the end of the source.
|
|
77
|
+
for (const open of stack) if (open.expiring) ranges.push({start:open.start, end:markdown.length, token:open});
|
|
78
|
+
if (!ranges.length) return {markdown, hasExpiry:false};
|
|
79
|
+
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
let state = {};
|
|
82
|
+
if (!preview && secret) {
|
|
83
|
+
try { state = JSON.parse(await fs.readFile(STATE_FILE, 'utf8')); } catch {}
|
|
84
|
+
}
|
|
85
|
+
const removals = [];
|
|
86
|
+
const cleanOpeners = [];
|
|
87
|
+
for (const range of ranges.sort((a,b)=>a.start-b.start)) {
|
|
88
|
+
if (removals.some(parent => parent.start <= range.start && parent.end >= range.end)) continue;
|
|
89
|
+
const {token} = range, a = token.attrs;
|
|
90
|
+
const scope = String(a['expire-scope'] || 'ip').toLowerCase() === 'session' ? 'session' : 'ip';
|
|
91
|
+
const visitor = scope === 'session' ? sessionId : ip;
|
|
92
|
+
const identity = String(a['expiry-id'] || a.id || `${token.name}:${token.start}`).trim();
|
|
93
|
+
const date = String(a['expires-at'] || '').trim();
|
|
94
|
+
const expiresAt = date ? Date.parse(date) : NaN;
|
|
95
|
+
const afterMinutes = Number(a['expire-after']);
|
|
96
|
+
const maxViews = Math.floor(Number(a['expire-views']));
|
|
97
|
+
const oncePerSession = a['expire-session'] === true || String(a['expire-session']).toLowerCase() === 'true';
|
|
98
|
+
const keyScope = oncePerSession ? `session:${sessionId}` : `${scope}:${visitor || 'anonymous'}`;
|
|
99
|
+
const key = stateKey(urlPath, `${token.name}:${identity}`, keyScope, secret);
|
|
100
|
+
let record = state[key] || {};
|
|
101
|
+
const blocked = !preview && ((Number.isFinite(expiresAt) && now >= expiresAt)
|
|
102
|
+
|| (Number.isFinite(afterMinutes) && afterMinutes > 0 && record.firstSeen && now >= record.firstSeen + afterMinutes * 60000)
|
|
103
|
+
|| (Number.isInteger(maxViews) && maxViews > 0 && record.views >= maxViews)
|
|
104
|
+
|| (oncePerSession && record.views >= 1)
|
|
105
|
+
|| (a['expire-ip'] && !ipAllowed(a['expire-ip'], ip)));
|
|
106
|
+
if (blocked) { removals.push(range); continue; }
|
|
107
|
+
cleanOpeners.push(token);
|
|
108
|
+
const counterVisitor = (scope === 'session' || oncePerSession) ? sessionId : ip;
|
|
109
|
+
if (!preview && secret && counterVisitor && (afterMinutes > 0 || maxViews > 0 || oncePerSession)) {
|
|
110
|
+
record.firstSeen ||= now;
|
|
111
|
+
record.views = (record.views || 0) + 1;
|
|
112
|
+
record.updated = now;
|
|
113
|
+
state[key] = record;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Nested expired ranges collapse to one removal so offsets remain valid.
|
|
117
|
+
const collapsed = [];
|
|
118
|
+
for (const range of removals.sort((a,b)=>a.start-b.start)) {
|
|
119
|
+
const parent = collapsed.at(-1);
|
|
120
|
+
if (parent && range.start < parent.end) parent.end = Math.max(parent.end, range.end);
|
|
121
|
+
else collapsed.push({...range});
|
|
122
|
+
}
|
|
123
|
+
if (!preview && Object.keys(state).length) {
|
|
124
|
+
// Bound state growth while preserving current visitor records.
|
|
125
|
+
for (const [key, record] of Object.entries(state)) if (record.updated && record.updated < now - 180 * 86400000) delete state[key];
|
|
126
|
+
await fs.mkdir(path.dirname(STATE_FILE), {recursive:true});
|
|
127
|
+
const temp = `${STATE_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
128
|
+
await fs.writeFile(temp, JSON.stringify(state));
|
|
129
|
+
await fs.rename(temp, STATE_FILE);
|
|
130
|
+
}
|
|
131
|
+
const edits = collapsed.map(range => ({start:range.start, end:range.end, value:''}));
|
|
132
|
+
for (const token of cleanOpeners) {
|
|
133
|
+
if (collapsed.some(range => range.start <= token.start && range.end >= token.end)) continue;
|
|
134
|
+
const cleaned = token.raw.replace(/\s+(?:expires-at|expire-after|expire-views|expire-scope|expire-ip|expire-session|expiry-id)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s\]]+))?/gi, '');
|
|
135
|
+
if (cleaned !== token.raw) edits.push({start:token.start, end:token.end, value:cleaned});
|
|
136
|
+
}
|
|
137
|
+
let output = markdown;
|
|
138
|
+
for (const edit of edits.sort((a,b)=>b.start-a.start)) output = output.slice(0,edit.start) + edit.value + output.slice(edit.end);
|
|
139
|
+
return {markdown:output, hasExpiry:true};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function applyShortcodeExpiry(markdown, options = {}) {
|
|
143
|
+
const operation = stateQueue.then(() => applyShortcodeExpiryUnlocked(markdown, options));
|
|
144
|
+
stateQueue = operation.then(() => {}, () => {});
|
|
145
|
+
return operation;
|
|
146
|
+
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
|
|
26
26
|
|
|
27
27
|
<!-- Site CSS -->
|
|
28
|
-
<link rel="stylesheet" href="/public/css/site.css?v=
|
|
28
|
+
<link rel="stylesheet" href="/public/css/site.css?v=20261013-carousel-controls">
|
|
29
29
|
<link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
|
|
30
30
|
<!-- Menu item hover / current-page effects. Loaded after site.css so a
|
|
31
31
|
menu's own highlight wins over the blanket link styling there. -->
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
</script>
|
|
174
174
|
|
|
175
175
|
<!-- Site initialisation -->
|
|
176
|
-
<script src="/public/js/site.js?v=
|
|
176
|
+
<script src="/public/js/site.js?v=20261013-carousel-colours" type="module"></script>
|
|
177
177
|
<script src="/public/js/collection-context.js?v=20261010-utf8-payloads" type="module"></script>
|
|
178
178
|
{{ctxMenusModule}}
|
|
179
179
|
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
<script src="/public/js/forms.js?v=20261009-form-classes" type="module"></script>
|
|
192
192
|
|
|
193
193
|
<!-- Core effects runtime -->
|
|
194
|
-
<script src="/public/js/effects.js?v=
|
|
194
|
+
<script src="/public/js/effects.js?v=20261013-scribe-fonts"></script>
|
|
195
195
|
|
|
196
196
|
<!-- Plugin body-end injection -->
|
|
197
197
|
{{bodyEndInject}}
|