domma-cms 1.6.33 → 1.6.34

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.
@@ -21,6 +21,7 @@ import {buildTextStyle} from '../../public/js/text-style.mjs';
21
21
  import {buildScribeStyle} from '../../public/js/scribe-style.mjs';
22
22
  import {CHARACTERS, resolveCharacterSize} from '../../public/js/celebrations/character-catalog.mjs';
23
23
  import {publicButtonSettings} from '../../public/js/form-submit.mjs';
24
+ import {applyShortcodeExpiry} from './shortcodeExpiry.js';
24
25
 
25
26
  const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
26
27
  const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
@@ -4553,10 +4554,14 @@ export async function parseMarkdown(raw, opts = {}) {
4553
4554
  const uncommented = comments.restore(comments.scrubbed
4554
4555
  .replace(/<!--[\s\S]*?(?:-->|$)/g, '')
4555
4556
  .replace(/^[ ]{0,3}\/\/[^\r\n]*/gm, ''));
4556
- const preprocessed = applyTransforms('markdown:beforeParse', uncommented);
4557
+ const expirySafe = scrubCodeRegions(uncommented, true);
4558
+ const expiry = await applyShortcodeExpiry(expirySafe.scrubbed, {
4559
+ ...(opts.expiry || {}), urlPath: opts.urlPath || '/', parseAttrs: parseShortcodeAttrs
4560
+ });
4561
+ const preprocessed = applyTransforms('markdown:beforeParse', expirySafe.restore(expiry.markdown));
4557
4562
  const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
4558
4563
  const tagSet = new Set();
4559
- const flags = {perViewer: false};
4564
+ const flags = {perViewer: false, hasExpiry:expiry.hasExpiry};
4560
4565
  const withCollection = await processCollectionBlocks(withComponents, tagSet);
4561
4566
  const withView = await processViewBlocks(withCollection, tagSet, opts.user || null, flags);
4562
4567
  const withStaticBlock = await processStaticBlocks(withView, tagSet);
@@ -4670,7 +4675,7 @@ export async function parseMarkdown(raw, opts = {}) {
4670
4675
  const allowed = new Set(_dmTagAllowlist.map(t => t.replace(/^dm-/, '')));
4671
4676
  const filteredUsed = [...usedComponents].filter(name => allowed.has(name));
4672
4677
 
4673
- return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet], perViewer: flags.perViewer};
4678
+ return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet], perViewer: flags.perViewer, hasExpiry:flags.hasExpiry};
4674
4679
  }
4675
4680
 
4676
4681
  /**
@@ -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=20261012-card-icon">
28
+ <link rel="stylesheet" href="/public/css/site.css?v=20261012-carousel-centre">
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. -->