domma-cms 1.8.32 → 1.8.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,748 @@
1
+ /**
2
+ * Docs storage and the rules around it: documents on disk, who may do what
3
+ * with one, per-user stars and recents, edit locks, the public-link index and
4
+ * the sanitiser every stored body goes through.
5
+ *
6
+ * Shared by plugin.js (the admin API) and plugin.public.js (the public link
7
+ * page). Both are loaded by the same process, so the module state here - the
8
+ * running settings, the lock table, the caches - is one copy between them.
9
+ *
10
+ * Files only: data/documents/<id>.json, data/versions/<id>/<n>.json,
11
+ * data/user-state.json - and, for a PDF document (2.13), its file at
12
+ * data/files/<id>.pdf with each earlier upload at data/versions/<id>/<n>.pdf.
13
+ * Writes are whole-file and single-process, as before.
14
+ *
15
+ * @module docs/lib/store
16
+ */
17
+
18
+ import fs from 'fs/promises';
19
+ import path from 'path';
20
+ import crypto from 'crypto';
21
+ import {fileURLToPath} from 'url';
22
+ import sanitizeHtml from 'sanitize-html';
23
+ import {countWords, preview} from '../admin/lib/convert.js';
24
+ import {docsRegistry} from './extensions.js';
25
+
26
+ const here = path.dirname(fileURLToPath(import.meta.url));
27
+ // DOCS_DATA_DIR: tests point this at a temp dir so they never touch a real site's documents.
28
+ export const DATA_DIR = process.env.DOCS_DATA_DIR || path.join(here, '..', 'data');
29
+ const DOCS_DIR = path.join(DATA_DIR, 'documents');
30
+ const VERSIONS_DIR = path.join(DATA_DIR, 'versions');
31
+ const FILES_DIR = path.join(DATA_DIR, 'files');
32
+ const USER_STATE = path.join(DATA_DIR, 'user-state.json');
33
+
34
+ // ------------------------------------------------------------------ settings
35
+
36
+ /**
37
+ * The running settings, shared by both halves of the plugin. plugin.js
38
+ * fills it at boot and on every PUT /settings; the public page reads it.
39
+ */
40
+ export const runtime = {
41
+ config: {
42
+ scope: 'user',
43
+ autoSaveInterval: 30000,
44
+ maxVersions: 50,
45
+ publicLinks: true,
46
+ trashDays: 30
47
+ },
48
+ /**
49
+ * Bumped after every finished document or star write - through a route or not (Docs Pro writes
50
+ * through `services`). GET /count only caches an answer read within one generation (2.10).
51
+ */
52
+ writeGen: 0
53
+ };
54
+
55
+ /** A document or a star changed: answers cached before now are stale. */
56
+ export function bumpWriteGen() { runtime.writeGen++; }
57
+
58
+ // ----------------------------------------------------------------- sanitise
59
+
60
+ const TAGS = ['p', 'div', 'span', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'b', 'em', 'i', 'u', 's',
61
+ 'del', 'strike', 'ins', 'sub', 'sup', 'mark', 'code', 'pre', 'kbd', 'blockquote', 'ul', 'ol', 'li', 'a', 'img',
62
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'figure', 'figcaption'];
63
+
64
+ const STYLE_VALUE = {
65
+ 'text-align': [/^(left|right|center|justify)$/],
66
+ 'background-color': [/^#[0-9a-f]{3,8}$/i, /^rgba?\([\d\s.,%]+\)$/i, /^transparent$/],
67
+ color: [/^#[0-9a-f]{3,8}$/i, /^rgba?\([\d\s.,%]+\)$/i],
68
+ width: [/^\d+(\.\d+)?(px|%|em|rem)$/],
69
+ 'margin-left': [/^\d+(\.\d+)?(px|em|rem)$/],
70
+ 'padding-left': [/^\d+(\.\d+)?(px|em|rem)$/]
71
+ };
72
+
73
+ /** Extra tags/attributes an add-on (Docs Pro) needs kept - comment anchors and the like. */
74
+ const extraRules = {tags: new Set(), attributes: {}};
75
+
76
+ /**
77
+ * Let an extension keep more markup through the sanitiser.
78
+ *
79
+ * @param {{tags?:string[], attributes?:Object<string,string[]>}} rules
80
+ */
81
+ export function allowMarkup(rules = {}) {
82
+ for (const t of rules.tags || []) extraRules.tags.add(t);
83
+ for (const [tag, attrs] of Object.entries(rules.attributes || {})) {
84
+ extraRules.attributes[tag] = [...new Set([...(extraRules.attributes[tag] || []), ...attrs])];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * What a document body may contain. Everything a document is written with,
90
+ * nothing that runs: no scripts, no event handlers, no javascript: links, no
91
+ * forms or frames, and only the few inline styles the editor itself writes.
92
+ *
93
+ * @param {string} html
94
+ * @returns {string}
95
+ */
96
+ /**
97
+ * A body as readers see it (2.5): pending suggestions shown as not made -
98
+ * suggested words out, suggested deletions kept as plain text. Docs does this
99
+ * itself, so a lapsed Docs Pro can never put unaccepted words on a public page.
100
+ *
101
+ * @param {string} html sanitised
102
+ * @returns {string}
103
+ */
104
+ export function withoutSuggestions(html) {
105
+ const s = String(html ?? '');
106
+ if (!s.includes('data-sug')) return s;
107
+ return sanitizeHtml(s, {
108
+ // Already sanitised: the same tags, every attribute it kept.
109
+ allowedTags: [...TAGS, ...extraRules.tags],
110
+ allowedAttributes: false,
111
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
112
+ allowedSchemesByTag: {img: ['http', 'https', 'data']},
113
+ exclusiveFilter: (frame) => frame.tag === 'ins' && frame.attribs?.['data-sug'] !== undefined,
114
+ transformTags: {del: (tag, attribs) => (attribs['data-sug'] !== undefined ? {tagName: 'span', attribs: {}} : {tagName: tag, attribs})}
115
+ });
116
+ }
117
+
118
+ export function sanitizeDoc(html) {
119
+ const attrs = {
120
+ a: ['href', 'title', 'target', 'rel'],
121
+ img: ['src', 'alt', 'title', 'width', 'height'],
122
+ td: ['colspan', 'rowspan'],
123
+ th: ['colspan', 'rowspan', 'scope'],
124
+ ol: ['start', 'type'],
125
+ ul: ['data-type'],
126
+ li: ['data-checked'],
127
+ code: ['class'],
128
+ pre: ['class'],
129
+ // Suggested edits (2.5, Docs Pro) keep their marks even while Pro is off - so they stay suggestions.
130
+ ins: ['data-sug', 'data-by', 'data-name', 'data-at'],
131
+ del: ['data-sug', 'data-by', 'data-name', 'data-at'],
132
+ '*': ['style']
133
+ };
134
+ for (const [tag, list] of Object.entries(extraRules.attributes)) attrs[tag] = [...(attrs[tag] || []), ...list];
135
+ return sanitizeHtml(String(html ?? ''), {
136
+ allowedTags: [...TAGS, ...extraRules.tags],
137
+ allowedAttributes: attrs,
138
+ allowedClasses: {code: [/^language-[\w-]+$/], pre: [/^language-[\w-]+$/]},
139
+ allowedStyles: {'*': STYLE_VALUE},
140
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
141
+ allowedSchemesByTag: {img: ['http', 'https', 'data']},
142
+ allowProtocolRelative: false,
143
+ transformTags: {
144
+ // A link out of a document opens in a new tab and hands the
145
+ // target page nothing back.
146
+ a: (tagName, attribs) => {
147
+ const out = {...attribs};
148
+ if (out.href && /^https?:/i.test(out.href)) { out.target = '_blank'; out.rel = 'noopener noreferrer'; }
149
+ return {tagName, attribs: out};
150
+ },
151
+ font: 'span'
152
+ }
153
+ });
154
+ }
155
+
156
+ // ------------------------------------------------------------------- files
157
+
158
+ /**
159
+ * @param {string} id
160
+ * @returns {boolean}
161
+ */
162
+ export function validId(id) {
163
+ return typeof id === 'string' && /^[A-Za-z0-9-]{8,64}$/.test(id);
164
+ }
165
+
166
+ /**
167
+ * What kind of document this is (2.13): 'pdf' - a file, shown as it is - or
168
+ * 'html' - a body written in the editor. Everything that renders, exports or
169
+ * edits a body asks this first.
170
+ *
171
+ * @param {object} doc
172
+ * @returns {'html'|'pdf'}
173
+ */
174
+ export function kindOf(doc) {
175
+ return doc?.format === 'pdf' ? 'pdf' : 'html';
176
+ }
177
+
178
+ /** Where a PDF document's file lives. */
179
+ export function filePath(id) {
180
+ return path.join(FILES_DIR, `${id}.pdf`);
181
+ }
182
+
183
+ /** Where an earlier upload of a PDF document is kept, beside its version record. */
184
+ export function versionFilePath(id, num) {
185
+ return path.join(VERSIONS_DIR, id, `${num}.pdf`);
186
+ }
187
+
188
+ /** A PDF document's bytes, or null. */
189
+ export async function readFileBytes(id) {
190
+ if (!validId(id)) return null;
191
+ try { return await fs.readFile(filePath(id)); } catch { return null; }
192
+ }
193
+
194
+ /** Write a PDF document's file atomically (tmp + rename). The record is written separately. */
195
+ export async function writeFileBytes(id, buf) {
196
+ if (!validId(id)) throw new Error('Bad document id');
197
+ await fs.mkdir(FILES_DIR, {recursive: true});
198
+ const file = filePath(id);
199
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
200
+ await fs.writeFile(tmp, buf);
201
+ await fs.rename(tmp, file);
202
+ }
203
+
204
+ /** Copy one PDF document's file to another document (a duplicate). */
205
+ export async function copyFileBytes(fromId, toId) {
206
+ if (!validId(fromId) || !validId(toId)) return false;
207
+ await fs.mkdir(FILES_DIR, {recursive: true});
208
+ try { await fs.copyFile(filePath(fromId), filePath(toId)); return true; } catch { return false; }
209
+ }
210
+
211
+ export async function readDoc(id) {
212
+ if (!validId(id)) return null;
213
+ try {
214
+ return JSON.parse(await fs.readFile(path.join(DOCS_DIR, `${id}.json`), 'utf8'));
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /** Serialise writes per document - two saves racing must not interleave. */
221
+ const writing = new Map();
222
+
223
+ export async function writeDoc(doc) {
224
+ await fs.mkdir(DOCS_DIR, {recursive: true});
225
+ const file = path.join(DOCS_DIR, `${doc.id}.json`);
226
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
227
+ const prev = writing.get(doc.id) || Promise.resolve();
228
+ const job = prev.catch(() => {}).then(async () => {
229
+ await fs.writeFile(tmp, JSON.stringify(doc, null, 2));
230
+ await fs.rename(tmp, file);
231
+ });
232
+ writing.set(doc.id, job);
233
+ try { await job; } finally { if (writing.get(doc.id) === job) writing.delete(doc.id); bumpWriteGen(); }
234
+ linkIndex = null;
235
+ return doc;
236
+ }
237
+
238
+ /**
239
+ * Change one document safely: re-read it inside that document's write
240
+ * chain, let `fn` return the new version (or null for no change), write it.
241
+ * Every route that edits a document goes through here, so a share saved
242
+ * while an autosave lands can never write back a stale body.
243
+ *
244
+ * @param {string} id
245
+ * @param {Function} fn (doc) => newDoc | null (may be async; keep it short)
246
+ * @returns {Promise<object|null>} the document as written, or as it was
247
+ */
248
+ export async function mutateDoc(id, fn) {
249
+ if (!validId(id)) return null;
250
+ const prev = writing.get(id) || Promise.resolve();
251
+ let result = null;
252
+ const job = prev.catch(() => {}).then(async () => {
253
+ const doc = await readDoc(id);
254
+ if (!doc) return;
255
+ const next = await fn(doc);
256
+ if (!next) { result = doc; return; }
257
+ await fs.mkdir(DOCS_DIR, {recursive: true});
258
+ const file = path.join(DOCS_DIR, `${id}.json`);
259
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
260
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2));
261
+ await fs.rename(tmp, file);
262
+ linkIndex = null;
263
+ result = next;
264
+ });
265
+ writing.set(id, job);
266
+ try { await job; } finally { if (writing.get(id) === job) writing.delete(id); bumpWriteGen(); }
267
+ return result;
268
+ }
269
+
270
+ export async function removeDoc(id) {
271
+ if (!validId(id)) return;
272
+ await fs.rm(path.join(DOCS_DIR, `${id}.json`), {force: true});
273
+ await fs.rm(path.join(VERSIONS_DIR, id), {recursive: true, force: true});
274
+ await fs.rm(filePath(id), {force: true});
275
+ linkIndex = null;
276
+ bumpWriteGen();
277
+ }
278
+
279
+ export async function listDocs() {
280
+ await fs.mkdir(DOCS_DIR, {recursive: true});
281
+ const files = (await fs.readdir(DOCS_DIR)).filter(f => f.endsWith('.json'));
282
+ const docs = await Promise.all(files.map(async (f) => {
283
+ try { return JSON.parse(await fs.readFile(path.join(DOCS_DIR, f), 'utf8')); } catch { return null; }
284
+ }));
285
+ return docs.filter(Boolean);
286
+ }
287
+
288
+ /**
289
+ * A new document, stamped. Bodies are sanitised on every way in. A PDF
290
+ * document (2.13) has `format: 'pdf'` and a `file` record {name, size, pages,
291
+ * sha256, uploadedAt, uploadedBy}; its `content` is the text read out of the
292
+ * file (for search and the excerpt), sanitised like any body.
293
+ *
294
+ * @param {object} fields
295
+ * @param {object} user request.user
296
+ * @returns {object}
297
+ */
298
+ export function newDoc({title, content = '', folderId = null, tags = [], format = 'html', file = null}, user) {
299
+ const now = new Date().toISOString();
300
+ const body = sanitizeDoc(content);
301
+ const pdf = format === 'pdf' && file;
302
+ return {
303
+ id: crypto.randomUUID(),
304
+ title: cleanTitle(title),
305
+ content: body,
306
+ format: pdf ? 'pdf' : 'html',
307
+ ...(pdf ? {file: fileRecord(file, user, now)} : {}),
308
+ folderId: folderId || null,
309
+ tags: Array.isArray(tags) ? tags.map(String).slice(0, 50) : [],
310
+ userId: user.id,
311
+ shares: [],
312
+ publicLink: null,
313
+ trashedAt: null,
314
+ wordCount: countWords(body),
315
+ createdAt: now,
316
+ updatedAt: now,
317
+ updatedBy: user.id
318
+ };
319
+ }
320
+
321
+ /** The file record a PDF document carries - whitelisted, never spread from a request. */
322
+ export function fileRecord(file, user, at = new Date().toISOString()) {
323
+ return {
324
+ name: String(file?.name || 'document.pdf').slice(0, 160),
325
+ size: Math.max(0, Number(file?.size) || 0),
326
+ pages: Math.max(0, Math.round(Number(file?.pages) || 0)),
327
+ sha256: /^[a-f0-9]{64}$/.test(String(file?.sha256 || '')) ? file.sha256 : '',
328
+ encrypted: file?.encrypted === true,
329
+ uploadedAt: at,
330
+ uploadedBy: user?.id || null
331
+ };
332
+ }
333
+
334
+ export function cleanTitle(title) {
335
+ const t = String(title ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
336
+ return t || 'Untitled';
337
+ }
338
+
339
+ // ----------------------------------------------------------------- versions
340
+
341
+ /**
342
+ * Keep the content a document is about to lose, then trim its history to
343
+ * the newest `keep`. Numbers keep climbing; only the oldest files go.
344
+ *
345
+ * @param {object} doc the document BEFORE the change
346
+ * @param {number} keep
347
+ * @param {string} [by] who made the change that replaced it
348
+ */
349
+ export async function snapshotVersion(doc, keep, by = null) {
350
+ const dir = path.join(VERSIONS_DIR, doc.id);
351
+ await fs.mkdir(dir, {recursive: true});
352
+ const nums = [...new Set((await fs.readdir(dir).catch(() => [])).filter(f => f.endsWith('.json')).map(f => parseInt(f, 10)).filter(n => !isNaN(n)))].sort((a, b) => a - b);
353
+ const next = nums.length ? nums[nums.length - 1] + 1 : 1;
354
+ // A PDF document's version is the file it had (2.13): copied first, so a record never names a file that is not there.
355
+ const pdf = kindOf(doc) === 'pdf';
356
+ if (pdf) { try { await fs.copyFile(filePath(doc.id), versionFilePath(doc.id, next)); } catch { /* no file yet: the record alone */ } }
357
+ await fs.writeFile(path.join(dir, `${next}.json`), JSON.stringify({
358
+ num: next, title: doc.title, content: doc.content, createdAt: new Date().toISOString(),
359
+ ...(pdf ? {format: 'pdf', file: doc.file || null} : {}),
360
+ savedBy: doc.updatedBy || doc.userId || null, replacedBy: by,
361
+ // 2.12: a change credited by name (Wiki Pro's approved suggestion from a visitor) - only while it is that save's.
362
+ ...(doc.creditName && doc.creditAt && doc.creditAt === doc.updatedAt ? {savedByName: String(doc.creditName).slice(0, 80)} : {})
363
+ }, null, 2));
364
+ const all = [...nums, next];
365
+ for (const n of all.slice(0, Math.max(0, all.length - keep))) {
366
+ await fs.rm(path.join(dir, `${n}.json`), {force: true});
367
+ await fs.rm(path.join(dir, `${n}.pdf`), {force: true});
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Put an earlier upload back as a PDF document's file (2.13) - a restore.
373
+ *
374
+ * @returns {Promise<boolean>} false when that version kept no file
375
+ */
376
+ export async function restoreVersionFile(id, num) {
377
+ if (!validId(id) || !/^\d{1,7}$/.test(String(num))) return false;
378
+ await fs.mkdir(FILES_DIR, {recursive: true});
379
+ try { await fs.copyFile(versionFilePath(id, num), filePath(id)); return true; } catch { return false; }
380
+ }
381
+
382
+ /** An earlier upload's bytes, or null. */
383
+ export async function readVersionFile(id, num) {
384
+ if (!validId(id) || !/^\d{1,7}$/.test(String(num))) return null;
385
+ try { return await fs.readFile(versionFilePath(id, num)); } catch { return null; }
386
+ }
387
+
388
+ /**
389
+ * Whether a save should keep what it replaces as a version. Not when the
390
+ * same person saved the document less than ten minutes ago: that text is
391
+ * their own work in progress (autosave), and keeping every one of those
392
+ * would push real history out of the retention limit.
393
+ */
394
+ export function shouldSnapshot(doc, userId, now = Date.now()) {
395
+ if (doc.updatedBy !== userId) return true;
396
+ // 2.12: a save credited to someone else by name (an approved suggestion) is never folded into the next one.
397
+ if (doc.creditName && doc.creditAt && doc.creditAt === doc.updatedAt) return true;
398
+ const last = Date.parse(doc.updatedAt || '') || 0;
399
+ return now - last > 10 * 60 * 1000;
400
+ }
401
+
402
+ export async function listVersions(id) {
403
+ const dir = path.join(VERSIONS_DIR, id);
404
+ const files = (await fs.readdir(dir).catch(() => [])).filter(f => f.endsWith('.json'));
405
+ const out = [];
406
+ for (const f of files) {
407
+ try { out.push(JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))); } catch { /* skip */ }
408
+ }
409
+ return out.sort((a, b) => b.num - a.num);
410
+ }
411
+
412
+ export async function readVersion(id, num) {
413
+ if (!/^\d{1,7}$/.test(String(num))) return null;
414
+ try {
415
+ return JSON.parse(await fs.readFile(path.join(VERSIONS_DIR, id, `${num}.json`), 'utf8'));
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+
421
+ // ------------------------------------------------------------------- access
422
+
423
+ /**
424
+ * What this user may do with a document.
425
+ *
426
+ * - `owner` - everything, including trash, delete, move and transfer
427
+ * - `editor` - edit, share, public link, send
428
+ * - `viewer` - read, export, duplicate into their own documents
429
+ * - null - nothing; the API answers 404 so a document's existence
430
+ * does not leak
431
+ *
432
+ * `scope: 'all'` (the old site-wide setting) still reads for everybody and
433
+ * lets an admin change anything.
434
+ *
435
+ * @param {object} doc
436
+ * @param {object} user request.user
437
+ * @param {boolean} isAdmin
438
+ * @returns {'owner'|'editor'|'suggester'|'viewer'|null} (a Suggester is a Viewer who may also suggest changes - 2.5)
439
+ */
440
+ export function accessOf(doc, user, isAdmin = false) {
441
+ if (!doc || !user) return null;
442
+ if (doc.userId === user.id) return 'owner';
443
+ const share = (doc.shares || []).find(s => s.userId === user.id);
444
+ const siteWide = runtime.config.scope === 'all';
445
+ if (siteWide && isAdmin) return 'owner';
446
+ const own = share ? (share.role === 'editor' ? 'editor' : share.role === 'suggester' ? 'suggester' : 'viewer')
447
+ : siteWide ? 'viewer' : null;
448
+ // An add-on may give more (2.11): the Wiki makes its editors editors of its pages.
449
+ if (own === 'editor') return own;
450
+ const extra = extensionAccess(doc, user);
451
+ if (extra === 'editor') return 'editor';
452
+ return own || extra;
453
+ }
454
+
455
+ /**
456
+ * Why this person may not share, link or send this document (2.11), or ''.
457
+ * Access that comes only through an add-on is for editing and history - never
458
+ * for passing the document on (and a share would outlive the add-on's grant).
459
+ * A document an add-on restricts (a members-only wiki page) leaves Docs for nobody.
460
+ */
461
+ export function outwardRefusal(doc, user) {
462
+ if (onlyThroughExtension(doc, user)) return 'You can edit this document through another tool, not share, link or send it.';
463
+ try { return docsRegistry().restrictionOf?.(doc) || ''; } catch { return 'This document cannot leave Docs right now.'; }
464
+ }
465
+
466
+ /**
467
+ * Why this person may not change this document now (2.12): an add-on's freeze (a
468
+ * protected wiki page), owner included - or ''. A failing registry freezes.
469
+ */
470
+ export function frozenFor(doc, user) {
471
+ if (!doc || !user) return '';
472
+ try { return docsRegistry().frozenFor?.(doc, user) || ''; } catch { return 'This document cannot be changed right now.'; }
473
+ }
474
+
475
+ /** What add-ons give this user on this document (2.11) - 'editor', 'viewer' or null. */
476
+ export function extensionAccess(doc, user) {
477
+ try { return docsRegistry().extraAccess?.(doc, user) || null; } catch { return null; }
478
+ }
479
+
480
+ /**
481
+ * Reached only through an add-on (2.11): not theirs, not shared with them, not
482
+ * site-wide. Such a document (a Wiki page) is in their Starred and Recent when
483
+ * they starred or opened it, and in no other place on Home - it was never shared
484
+ * with them.
485
+ */
486
+ export function onlyThroughExtension(doc, user) {
487
+ if (!doc || !user || doc.userId === user.id || runtime.config.scope === 'all') return false;
488
+ if ((doc.shares || []).some(s => s.userId === user.id)) return false;
489
+ return Boolean(extensionAccess(doc, user));
490
+ }
491
+
492
+ export const canEdit = (a) => a === 'owner' || a === 'editor';
493
+
494
+ // --------------------------------------------------------------- user state
495
+
496
+ let userState = null;
497
+
498
+ async function loadUserState() {
499
+ if (userState) return userState;
500
+ try { userState = JSON.parse(await fs.readFile(USER_STATE, 'utf8')); } catch { userState = {}; }
501
+ if (!userState || typeof userState !== 'object' || Array.isArray(userState)) userState = {};
502
+ return userState;
503
+ }
504
+
505
+ let stateWrite = Promise.resolve();
506
+ async function saveUserState() {
507
+ const data = JSON.stringify(userState, null, 2);
508
+ stateWrite = stateWrite.catch(() => {}).then(async () => {
509
+ await fs.mkdir(DATA_DIR, {recursive: true});
510
+ const tmp = `${USER_STATE}.${process.pid}.tmp`;
511
+ await fs.writeFile(tmp, data);
512
+ await fs.rename(tmp, USER_STATE);
513
+ });
514
+ return stateWrite;
515
+ }
516
+
517
+ /**
518
+ * @param {string} userId
519
+ * @returns {Promise<{starred:string[], recent:Array<{id:string, at:string}>}>}
520
+ */
521
+ export async function stateFor(userId) {
522
+ const all = await loadUserState();
523
+ const s = all[userId] || {};
524
+ return {starred: Array.isArray(s.starred) ? s.starred : [], recent: Array.isArray(s.recent) ? s.recent : []};
525
+ }
526
+
527
+ export async function setStar(userId, docId, on) {
528
+ const all = await loadUserState();
529
+ const s = await stateFor(userId);
530
+ const starred = s.starred.filter(id => id !== docId);
531
+ if (on) starred.unshift(docId);
532
+ all[userId] = {...s, starred: starred.slice(0, 500)};
533
+ try { await saveUserState(); } finally { bumpWriteGen(); }
534
+ return on;
535
+ }
536
+
537
+ export async function touchRecent(userId, docId) {
538
+ const all = await loadUserState();
539
+ const s = await stateFor(userId);
540
+ const recent = [{id: docId, at: new Date().toISOString()}, ...s.recent.filter(r => r.id !== docId)].slice(0, 100);
541
+ all[userId] = {...s, recent};
542
+ await saveUserState();
543
+ }
544
+
545
+ /** Forget a document everywhere once it is gone for good. */
546
+ export async function forgetDoc(docId) {
547
+ const all = await loadUserState();
548
+ let changed = false;
549
+ for (const [uid, s] of Object.entries(all)) {
550
+ const starred = (s.starred || []).filter(id => id !== docId);
551
+ const recent = (s.recent || []).filter(r => r.id !== docId);
552
+ if (starred.length !== (s.starred || []).length || recent.length !== (s.recent || []).length) {
553
+ all[uid] = {...s, starred, recent};
554
+ changed = true;
555
+ }
556
+ }
557
+ if (changed) await saveUserState();
558
+ }
559
+
560
+ // -------------------------------------------------------------------- locks
561
+
562
+ /**
563
+ * Who is editing what. In memory: a lock outlives nothing but the process,
564
+ * and a restart letting everyone back in is the right way round to fail.
565
+ * Keyed by document; held by one editor session (a browser tab).
566
+ */
567
+ const locks = new Map();
568
+ export const LOCK_TTL_MS = 90 * 1000;
569
+
570
+ export function lockOf(docId, now = Date.now()) {
571
+ const l = locks.get(docId);
572
+ if (!l) return null;
573
+ if (l.expires <= now) { locks.delete(docId); return null; }
574
+ return l;
575
+ }
576
+
577
+ /**
578
+ * Take or refresh the lock. Refused while another session holds it, unless
579
+ * `force` - the owner taking over, or the same person in another tab.
580
+ *
581
+ * @returns {{ok:boolean, lock:object}}
582
+ */
583
+ export function acquireLock(docId, {userId, name, session, force = false, suggester = false}, now = Date.now()) {
584
+ const held = lockOf(docId, now);
585
+ if (held && held.session !== session && !force) return {ok: false, lock: held};
586
+ const lock = {userId, name: name || 'Someone', session, suggester: Boolean(suggester), since: held && held.session === session ? held.since : new Date(now).toISOString(), expires: now + LOCK_TTL_MS};
587
+ locks.set(docId, lock);
588
+ return {ok: true, lock, tookOver: Boolean(held && held.session !== session)};
589
+ }
590
+
591
+ export function releaseLock(docId, session) {
592
+ const held = locks.get(docId);
593
+ if (held && held.session === session) locks.delete(docId);
594
+ }
595
+
596
+ /** The lock as the API shows it - never the session of somebody else. */
597
+ export function publicLock(lock, session) {
598
+ if (!lock) return null;
599
+ return {userId: lock.userId, name: lock.name, since: lock.since, mine: lock.session === session, suggester: Boolean(lock.suggester)};
600
+ }
601
+
602
+ export function clearLocks() { locks.clear(); }
603
+
604
+ // ------------------------------------------------------------ public links
605
+
606
+ let linkIndex = null;
607
+
608
+ export function newLinkToken() {
609
+ return crypto.randomBytes(18).toString('base64url');
610
+ }
611
+
612
+ /**
613
+ * The document behind a public link, or null. Trashed documents and a site
614
+ * with public links switched off answer null.
615
+ *
616
+ * @param {string} token
617
+ * @returns {Promise<object|null>}
618
+ */
619
+ export async function docByToken(token) {
620
+ if (!runtime.config.publicLinks) return null;
621
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{16,64}$/.test(token)) return null;
622
+ if (!linkIndex) {
623
+ linkIndex = new Map();
624
+ for (const d of await listDocs()) if (d.publicLink?.token) linkIndex.set(d.publicLink.token, d.id);
625
+ }
626
+ const id = linkIndex.get(token);
627
+ if (!id) return null;
628
+ const doc = await readDoc(id);
629
+ if (!doc || doc.trashedAt || doc.publicLink?.token !== token) return null;
630
+ return doc;
631
+ }
632
+
633
+ // -------------------------------------------------------------------- rows
634
+
635
+ /**
636
+ * A document as a list row: no body, an excerpt instead.
637
+ *
638
+ * @param {object} doc
639
+ * @param {object} ctx {user, isAdmin, state, names:Map, session}
640
+ */
641
+ export function toRow(doc, {user, isAdmin, state, names}) {
642
+ const access = accessOf(doc, user, isAdmin);
643
+ const lock = lockOf(doc.id);
644
+ const opened = state.recent.find(r => r.id === doc.id)?.at || null;
645
+ return {
646
+ id: doc.id,
647
+ title: doc.title || 'Untitled',
648
+ excerpt: preview(doc.content, 400),
649
+ wordCount: Number(doc.wordCount) || 0,
650
+ // 2.13: a PDF document shows its pages and size where an HTML one shows words.
651
+ format: kindOf(doc),
652
+ ...(kindOf(doc) === 'pdf' ? {file: {pages: Number(doc.file?.pages) || 0, size: Number(doc.file?.size) || 0}} : {}),
653
+ folderId: doc.userId === user.id ? (doc.folderId || null) : null,
654
+ userId: doc.userId,
655
+ ownerName: names.get(doc.userId) || 'Unknown',
656
+ access,
657
+ starred: state.starred.includes(doc.id),
658
+ shared: (doc.shares || []).length > 0,
659
+ public: Boolean(doc.publicLink?.token),
660
+ // 2.11: no Share or Email on Home for these (see outwardRefusal).
661
+ outward: !outwardRefusal(doc, user),
662
+ locked: lock && lock.userId !== user.id ? {name: lock.name, since: lock.since} : null,
663
+ createdAt: doc.createdAt,
664
+ updatedAt: doc.updatedAt,
665
+ openedAt: opened,
666
+ trashedAt: doc.trashedAt || null
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Pick and order the rows for one view of Home.
672
+ *
673
+ * @param {Array} docs every document on disk
674
+ * @param {object} q {view, folder, q}
675
+ * @param {object} ctx as toRow
676
+ * @returns {Array} rows
677
+ */
678
+ /** Is `d` in this place (Home's rail) for this user? selectRows and placeCounts share it. */
679
+ export function inView(d, view, {user, isAdmin, state}) {
680
+ if (!accessOf(d, user, isAdmin)) return false;
681
+ if (view === 'trash') return Boolean(d.trashedAt) && d.userId === user.id;
682
+ if (d.trashedAt) return false;
683
+ if (onlyThroughExtension(d, user)) {
684
+ if (view === 'starred') return state.starred.includes(d.id);
685
+ if (view === 'recent') return state.recent.some(r => r.id === d.id);
686
+ return false;
687
+ }
688
+ switch (view) {
689
+ case 'starred': return state.starred.includes(d.id);
690
+ case 'shared': return d.userId !== user.id;
691
+ case 'recent': return true;
692
+ case 'all': return true;
693
+ default: return d.userId === user.id; // mine
694
+ }
695
+ }
696
+
697
+ /**
698
+ * How many documents each of Home's places shows when opened (2.10) - the
699
+ * numbers on the rail. "My documents" is its top level, as the list shows it
700
+ * (documents in folders are under their folder).
701
+ *
702
+ * @returns {{starred: number, shared: number, mine: number, trash: number}}
703
+ */
704
+ export function placeCounts(docs, ctx) {
705
+ const n = (view, more = () => true) => docs.filter(d => inView(d, view, ctx) && more(d)).length;
706
+ return {starred: n('starred'), shared: n('shared'), mine: n('mine', d => !d.folderId), trash: n('trash')};
707
+ }
708
+
709
+ export function selectRows(docs, {view = 'mine', folder, q} = {}, ctx) {
710
+ const {user} = ctx;
711
+ const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
712
+ const time = (iso) => Date.parse(iso || '') || 0;
713
+ let list = docs.filter(d => inView(d, view, ctx));
714
+ if (view === 'mine' && folder !== undefined) {
715
+ const f = folder === '' || folder === 'null' ? null : folder;
716
+ list = list.filter(d => (d.folderId || null) === f);
717
+ }
718
+ if (term) {
719
+ list = list.filter(d => (d.title || '').toLowerCase().includes(term)
720
+ || String(d.content || '').replace(/<[^>]+>/g, ' ').toLowerCase().includes(term));
721
+ }
722
+ const rows = list.map(d => toRow(d, ctx));
723
+ if (view === 'recent') {
724
+ rows.sort((a, b) => Math.max(time(b.openedAt), b.userId === user.id ? time(b.updatedAt) : 0)
725
+ - Math.max(time(a.openedAt), a.userId === user.id ? time(a.updatedAt) : 0));
726
+ return rows.slice(0, 60);
727
+ }
728
+ if (view === 'trash') return rows.sort((a, b) => time(b.trashedAt) - time(a.trashedAt));
729
+ return rows.sort((a, b) => time(b.updatedAt) - time(a.updatedAt));
730
+ }
731
+
732
+ /**
733
+ * Remove documents that have sat in Trash longer than the setting allows.
734
+ *
735
+ * @returns {Promise<number>} how many went
736
+ */
737
+ export async function purgeTrash(now = Date.now()) {
738
+ const days = Math.max(1, Number(runtime.config.trashDays) || 30);
739
+ let n = 0;
740
+ for (const d of await listDocs()) {
741
+ if (d.trashedAt && now - Date.parse(d.trashedAt) > days * 86400000) {
742
+ await removeDoc(d.id);
743
+ await forgetDoc(d.id);
744
+ n++;
745
+ }
746
+ }
747
+ return n;
748
+ }