domma-cms 1.8.33 → 1.8.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +1 -1
  3. package/plugins/docs/CLAUDE.md +318 -0
  4. package/plugins/docs/admin/css/docs.css +783 -0
  5. package/plugins/docs/admin/lib/api.js +340 -0
  6. package/plugins/docs/admin/lib/convert.js +482 -0
  7. package/plugins/docs/admin/lib/drafts.js +115 -0
  8. package/plugins/docs/admin/lib/editor.js +851 -0
  9. package/plugins/docs/admin/lib/find-replace.js +426 -0
  10. package/plugins/docs/admin/lib/folders.js +203 -0
  11. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  12. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  13. package/plugins/docs/admin/lib/send.js +156 -0
  14. package/plugins/docs/admin/lib/settings.js +142 -0
  15. package/plugins/docs/admin/lib/share.js +323 -0
  16. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  17. package/plugins/docs/admin/lib/templates.js +194 -0
  18. package/plugins/docs/admin/lib/versions.js +192 -0
  19. package/plugins/docs/admin/templates/docs.html +92 -0
  20. package/plugins/docs/admin/templates/editor.html +86 -0
  21. package/plugins/docs/admin/views/docs.js +777 -0
  22. package/plugins/docs/admin/views/editor.js +978 -0
  23. package/plugins/docs/config.js +24 -0
  24. package/plugins/docs/docs/guide.md +270 -0
  25. package/plugins/docs/lib/extensions.js +213 -0
  26. package/plugins/docs/lib/importer.js +72 -0
  27. package/plugins/docs/lib/pdf-worker.js +48 -0
  28. package/plugins/docs/lib/pdf.js +222 -0
  29. package/plugins/docs/lib/store.js +748 -0
  30. package/plugins/docs/plugin.js +1108 -0
  31. package/plugins/docs/plugin.json +89 -0
  32. package/plugins/docs/plugin.public.js +114 -0
  33. package/plugins/docs/templates/public-doc.html +36 -0
  34. package/plugins/docs/tests/_env.js +10 -0
  35. package/plugins/docs/tests/_pdf.js +73 -0
  36. package/plugins/docs/tests/convert.test.js +95 -0
  37. package/plugins/docs/tests/drafts.test.js +82 -0
  38. package/plugins/docs/tests/pdf.test.js +86 -0
  39. package/plugins/docs/tests/plugin.test.js +816 -0
  40. package/plugins/docs/tests/shortcuts.test.js +30 -0
  41. package/plugins/free-tier.lock.json +105 -1
  42. package/plugins/garage/CLAUDE.md +51 -0
  43. package/plugins/garage/admin/css/garage.css +73 -0
  44. package/plugins/garage/admin/lib/garage.js +236 -0
  45. package/plugins/garage/admin/templates/garage.html +86 -0
  46. package/plugins/garage/admin/views/garage.js +581 -0
  47. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  48. package/plugins/garage/config.js +18 -0
  49. package/plugins/garage/docs/guide.md +118 -0
  50. package/plugins/garage/plugin.js +471 -0
  51. package/plugins/garage/plugin.json +68 -0
  52. package/plugins/garage/tests/access.test.js +134 -0
  53. package/plugins/garage/tests/garage.test.js +106 -0
  54. package/plugins/messages/CLAUDE.md +230 -0
  55. package/plugins/messages/admin/css/messages.css +117 -0
  56. package/plugins/messages/admin/lib/messages.js +452 -0
  57. package/plugins/messages/admin/templates/messages.html +76 -0
  58. package/plugins/messages/admin/views/messages.js +1219 -0
  59. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  60. package/plugins/messages/collections/user-messages/schema.json +23 -0
  61. package/plugins/messages/config.js +14 -0
  62. package/plugins/messages/docs/guide.md +176 -0
  63. package/plugins/messages/lib/call.js +138 -0
  64. package/plugins/messages/plugin.js +994 -0
  65. package/plugins/messages/plugin.json +59 -0
  66. package/plugins/messages/plugin.public.js +52 -0
  67. package/plugins/messages/templates/call.html +6 -0
  68. package/plugins/messages/tests/after-send.test.js +22 -0
  69. package/plugins/messages/tests/api.test.js +187 -0
  70. package/plugins/messages/tests/call.test.js +497 -0
  71. package/plugins/messages/tests/drafts.test.js +25 -0
  72. package/plugins/messages/tests/messages.test.js +167 -0
  73. package/plugins/theme-roller/CLAUDE.md +65 -0
  74. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  75. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  76. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  77. package/plugins/theme-roller/admin/lib/view.js +88 -0
  78. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  79. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  80. package/plugins/theme-roller/config.js +1 -0
  81. package/plugins/theme-roller/docs/guide.md +104 -0
  82. package/plugins/theme-roller/plugin.js +263 -0
  83. package/plugins/theme-roller/plugin.json +42 -0
  84. package/plugins/theme-roller/tests/api.test.js +161 -0
  85. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  86. package/plugins/theme-roller/tests/themes.test.js +77 -0
  87. package/plugins/theme-roller/tests/view.test.js +55 -0
  88. package/server/routes/public.js +16 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * PDF files as documents (2.13): what Docs checks before it keeps one, and
3
+ * the text it reads out of one for search and the card excerpt.
4
+ *
5
+ * An uploaded file is hostile until shown otherwise. `inspectPdf` refuses
6
+ * (with a plain reason, never by stripping) a file that is not a PDF, is too
7
+ * large, or names anything that runs or reaches out - JavaScript, Launch,
8
+ * attached files, rich media, XFA forms - looking inside its FlateDecode
9
+ * streams too, with `#xx` name escapes undone first, because that is where a
10
+ * name hides. Nothing here uses a regular expression that can backtrack over
11
+ * the file.
12
+ *
13
+ * The text extract is pdfjs-dist's (an optional dependency of the CMS), run
14
+ * in a worker thread with a time budget: a file that will not read in time
15
+ * is kept without an extract, never left to stall the server. No pdfjs = no
16
+ * extract, and Docs says so in its settings.
17
+ *
18
+ * @module docs/lib/pdf
19
+ */
20
+
21
+ import zlib from 'zlib';
22
+ import crypto from 'crypto';
23
+ import {Worker} from 'worker_threads';
24
+ import {escapeHtml} from '../admin/lib/convert.js';
25
+
26
+ export const MAX_PDF_BYTES = 20 * 1024 * 1024;
27
+ export const MAX_EXTRACT_CHARS = 200_000;
28
+ export const MAX_EXTRACT_PAGES = 300;
29
+ export const EXTRACT_TIMEOUT_MS = 20_000;
30
+
31
+ // Each inflated stream is capped, and so is the total: a tiny file can inflate into gigabytes. A stream
32
+ // over the cap is not skipped but REFUSED (a name could hide past the cap) - `searchableText` says so.
33
+ const STREAM_INFLATE_CAP = 32 * 1024 * 1024;
34
+ const TOTAL_INFLATE_CAP = 160 * 1024 * 1024;
35
+ const NESTED_INFLATE = 3; // /Filter [/FlateDecode /FlateDecode]: inflated again while the output looks like zlib
36
+
37
+ /**
38
+ * Names that make a PDF do something other than show pages. Each with the
39
+ * reason the person sees. /OpenAction is not here: on its own it is "open at
40
+ * page 1" (every LaTeX export has one), and a script behind it is caught by
41
+ * /JS or /JavaScript anyway.
42
+ */
43
+ export const REFUSED_NAMES = {
44
+ JavaScript: 'contains JavaScript',
45
+ JS: 'contains JavaScript',
46
+ Launch: 'can start programs (a Launch action)',
47
+ EmbeddedFile: 'has files attached inside it',
48
+ EmbeddedFiles: 'has files attached inside it',
49
+ RichMedia: 'contains rich media (Flash, video or 3D)',
50
+ XFA: 'is an XFA form',
51
+ GoToE: 'links into embedded files',
52
+ SubmitForm: 'submits a form to a web address',
53
+ ImportData: 'imports data from another file'
54
+ };
55
+ const NAME_RE = new RegExp(`/(${Object.keys(REFUSED_NAMES).join('|')})(?![A-Za-z0-9_.])`, 'g');
56
+
57
+ /** `/J#61vaScript` -> `/JavaScript`: PDF names may spell any character as #xx. */
58
+ function unescapeNames(text) {
59
+ return text.replace(/#([0-9A-Fa-f]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16)));
60
+ }
61
+
62
+ /** zlib's own header: 0x78 then a byte that makes the pair a multiple of 31. */
63
+ const looksDeflated = (b) => b.length > 2 && b[0] === 0x78 && ((b[0] << 8) | b[1]) % 31 === 0;
64
+
65
+ /**
66
+ * The file's own bytes plus every stream that inflates, as one latin1 string
67
+ * to search. No dictionary is parsed to decide (the first build read the
68
+ * innermost `<<` before `stream`, so a stream whose dictionary held a nested
69
+ * one - every object stream with /DecodeParms - was never inflated); every
70
+ * stream body is simply tried, and what will not inflate was not Flate. A
71
+ * stream whose output looks deflated again is inflated again (a double
72
+ * /FlateDecode filter). One over the cap means the file cannot be checked:
73
+ * `{unchecked: true}`, and the caller refuses it.
74
+ */
75
+ function searchableText(buf) {
76
+ const raw = buf.toString('latin1');
77
+ const parts = [raw];
78
+ let total = 0;
79
+ let at = 0;
80
+ while (at < raw.length) {
81
+ const s = raw.indexOf('stream', at);
82
+ if (s === -1) break;
83
+ // 'endstream' also contains 'stream': only a keyword that starts a stream counts.
84
+ if (raw.startsWith('endstream', s - 3)) { at = s + 6; continue; }
85
+ let dataStart = s + 6;
86
+ if (raw[dataStart] === '\r') dataStart++;
87
+ if (raw[dataStart] === '\n') dataStart++;
88
+ const e = raw.indexOf('endstream', dataStart);
89
+ if (e === -1) break;
90
+ at = e + 9;
91
+ let bytes = buf.subarray(dataStart, e);
92
+ for (let depth = 0; depth < NESTED_INFLATE && looksDeflated(bytes); depth++) {
93
+ let out;
94
+ try {
95
+ out = zlib.inflateSync(bytes, {maxOutputLength: Math.min(STREAM_INFLATE_CAP, Math.max(1, TOTAL_INFLATE_CAP - total)), finishFlush: zlib.constants.Z_SYNC_FLUSH});
96
+ } catch (err) {
97
+ if (err && (err.code === 'ERR_BUFFER_TOO_LARGE' || /maxOutputLength|too large/i.test(String(err.message)))) return {unchecked: true};
98
+ break; // not Flate after all: the raw bytes were searched
99
+ }
100
+ total += out.length;
101
+ if (total > TOTAL_INFLATE_CAP) return {unchecked: true};
102
+ parts.push(out.toString('latin1'));
103
+ bytes = out;
104
+ }
105
+ }
106
+ return {text: unescapeNames(parts.join('\n'))};
107
+ }
108
+
109
+ /**
110
+ * Check a file before it is kept. Never throws.
111
+ *
112
+ * @param {Buffer} buf
113
+ * @param {{maxBytes?: number}} [opts]
114
+ * @returns {{ok: true, pages: number, sha256: string, encrypted: boolean} | {ok: false, reason: string}}
115
+ */
116
+ export function inspectPdf(buf, {maxBytes = MAX_PDF_BYTES} = {}) {
117
+ if (!Buffer.isBuffer(buf) || !buf.length) return {ok: false, reason: 'The file is empty.'};
118
+ if (buf.length > maxBytes) return {ok: false, reason: `The file is over ${Math.round(maxBytes / 1024 / 1024)} MB.`};
119
+ if (buf.subarray(0, 1024).toString('latin1').indexOf('%PDF-') === -1) return {ok: false, reason: 'This is not a PDF file.'};
120
+ const searched = searchableText(buf);
121
+ if (searched.unchecked) return {ok: false, reason: 'This PDF holds a compressed part too large to check, so Docs will not keep it.'};
122
+ const text = searched.text;
123
+ const hit = NAME_RE.exec(text);
124
+ NAME_RE.lastIndex = 0;
125
+ if (hit) return {ok: false, reason: `This PDF ${REFUSED_NAMES[hit[1]]}, so Docs will not keep it.`};
126
+ // An approximate page count from the page objects; pdfjs' number replaces it when there is one.
127
+ const pages = (text.match(/\/Type\s*\/Page(?![a-zA-Z])/g) || []).length;
128
+ return {ok: true, pages, sha256: crypto.createHash('sha256').update(buf).digest('hex'), encrypted: /\/Encrypt(?![a-zA-Z])/.test(text)};
129
+ }
130
+
131
+ let capability = null;
132
+
133
+ /**
134
+ * Whether this server can read the text out of a PDF (pdfjs-dist present).
135
+ * Resolved without loading it.
136
+ *
137
+ * @returns {{available: boolean, reason: string|null}}
138
+ */
139
+ export function extractCapability() {
140
+ if (capability) return capability;
141
+ try {
142
+ import.meta.resolve('pdfjs-dist/legacy/build/pdf.mjs');
143
+ capability = {available: true, reason: null};
144
+ } catch {
145
+ capability = {available: false, reason: 'Searching inside PDF files needs the optional pdfjs-dist package on this server.'};
146
+ }
147
+ return capability;
148
+ }
149
+
150
+ /** Tests only. */
151
+ export function _resetCapability(value = null) { capability = value; }
152
+
153
+ /**
154
+ * The text of a PDF, read in a worker thread within a time budget.
155
+ *
156
+ * @param {Buffer} buf
157
+ * @param {{timeoutMs?: number}} [opts]
158
+ * @returns {Promise<{pages: number, text: string}|null>} null when it cannot be read (no pdfjs, broken, encrypted, out of time)
159
+ */
160
+ export function extractPdfText(buf, {timeoutMs = EXTRACT_TIMEOUT_MS} = {}) {
161
+ if (!extractCapability().available) return Promise.resolve(null);
162
+ return new Promise((resolve) => {
163
+ let done = false;
164
+ const finish = (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } };
165
+ let worker;
166
+ try {
167
+ // A copy: the worker owns its bytes, the caller keeps theirs.
168
+ const copy = new Uint8Array(buf.byteLength);
169
+ copy.set(buf);
170
+ worker = new Worker(new URL('./pdf-worker.js', import.meta.url), {
171
+ workerData: {data: copy.buffer, maxPages: MAX_EXTRACT_PAGES, maxChars: MAX_EXTRACT_CHARS},
172
+ transferList: [copy.buffer],
173
+ resourceLimits: {maxOldGenerationSizeMb: 512}
174
+ });
175
+ } catch { finish(null); return; }
176
+ const timer = setTimeout(() => { finish(null); worker.terminate().catch(() => {}); }, timeoutMs);
177
+ worker.once('message', (m) => { finish(m && typeof m.text === 'string' ? {pages: Number(m.pages) || 0, text: m.text} : null); worker.terminate().catch(() => {}); });
178
+ worker.once('error', () => finish(null));
179
+ worker.once('exit', () => finish(null));
180
+ });
181
+ }
182
+
183
+ /**
184
+ * The extract as a document body: a paragraph per line group, escaped, so
185
+ * `content` holds what search and the card excerpt read, as HTML like any
186
+ * other document's.
187
+ *
188
+ * @param {string} text
189
+ * @returns {string}
190
+ */
191
+ export function extractToHtml(text) {
192
+ const t = String(text || '').replace(/\r\n?/g, '\n').slice(0, MAX_EXTRACT_CHARS);
193
+ return t.split(/\n{2,}/).map(p => p.replace(/[ \t]+/g, ' ').trim()).filter(Boolean)
194
+ .map(p => `<p>${escapeHtml(p).replace(/\n/g, '<br>')}</p>`).join('');
195
+ }
196
+
197
+ /**
198
+ * Content-Disposition with a UTF-8 file name and an ASCII fallback.
199
+ *
200
+ * @param {string} name
201
+ * @param {boolean} [inline=false] shown in the browser rather than saved
202
+ */
203
+ export function contentDisposition(name, inline = false) {
204
+ const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '');
205
+ // RFC 5987: encodeURIComponent leaves ' ( ) * alone, and ' ends the charset part.
206
+ const ext = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
207
+ return `${inline ? 'inline' : 'attachment'}; filename="${ascii}"; filename*=UTF-8''${ext}`;
208
+ }
209
+
210
+ /** "1.2 MB" / "640 KB". */
211
+ export function sizeText(bytes) {
212
+ const n = Number(bytes) || 0;
213
+ if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(n >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
214
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
215
+ return `${n} B`;
216
+ }
217
+
218
+ /** A display name for a file: the uploaded name with path parts and control characters gone. */
219
+ export function cleanFileName(name) {
220
+ const base = String(name || '').split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 160);
221
+ return base || 'document.pdf';
222
+ }