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,1108 @@
1
+ /**
2
+ * Docs - the admin API (`/api/plugins/docs`).
3
+ *
4
+ * Storage, access rules, locks and the sanitiser live in lib/store.js, shared
5
+ * with plugin.public.js (the public link page). See CLAUDE.md for the model.
6
+ *
7
+ * @module docs/plugin
8
+ */
9
+
10
+ import {createStore} from '../_lib/dataStore.js';
11
+ import {savePluginState} from '../../server/services/plugins.js';
12
+ import {getEffectiveLevel, getEffectiveRoles} from '../../server/services/userRoles.js';
13
+ import {listUsers} from '../../server/services/users.js';
14
+ import {getPermissionsFor, getRoleLevel} from '../../server/services/roles.js';
15
+ import {getConfig} from '../../server/config.js';
16
+ import {originOf, trustedOrigin} from '../../server/services/passwordReset.js';
17
+ import {createTransport, sendEmail} from '../../server/services/email.js';
18
+ import {marked} from 'marked';
19
+ import defaultConfig from './config.js';
20
+ import {randomUUID, randomBytes, createHmac, timingSafeEqual} from 'crypto';
21
+ import fs from 'fs/promises';
22
+ import path from 'path';
23
+ import {
24
+ runtime, sanitizeDoc, withoutSuggestions, allowMarkup, readDoc, writeDoc, mutateDoc, removeDoc, listDocs, newDoc, cleanTitle, shouldSnapshot,
25
+ snapshotVersion, listVersions, readVersion, accessOf, canEdit, stateFor, setStar, touchRecent, forgetDoc,
26
+ lockOf, acquireLock, releaseLock, publicLock, newLinkToken, selectRows, placeCounts, purgeTrash, validId, DATA_DIR,
27
+ outwardRefusal, onlyThroughExtension, frozenFor,
28
+ kindOf, readFileBytes, writeFileBytes, copyFileBytes, restoreVersionFile, readVersionFile, fileRecord
29
+ } from './lib/store.js';
30
+ import {importFile, kindOf as importKind} from './lib/importer.js';
31
+ import {inspectPdf, extractPdfText, extractToHtml, extractCapability, contentDisposition, cleanFileName, MAX_PDF_BYTES} from './lib/pdf.js';
32
+ import {docsRegistry} from './lib/extensions.js';
33
+ import {countWords, escapeHtml, htmlToText, htmlToMarkdown, standaloneHtml, fileName, startsWithTitle, parseHtml} from './admin/lib/convert.js';
34
+
35
+ const foldersStore = createStore(path.join(DATA_DIR, 'folders.json'));
36
+ const templatesStore = createStore(path.join(DATA_DIR, 'templates.json'));
37
+
38
+ const MIN_VERSIONS = 5;
39
+ const MAX_VERSIONS = 500;
40
+ const EMAIL_RE = /^[^\s@<>(),;:"]+@[^\s@<>(),;:"]+\.[^\s@<>(),;:"]+$/;
41
+ const MAX_RECIPIENTS = 10;
42
+ const DAILY_RECIPIENTS = 50;
43
+
44
+ /** Addresses each person has emailed today, by user id. In memory: a restart forgives. */
45
+ const sentToday = new Map();
46
+ function allowance(userId, n, now = Date.now()) {
47
+ const day = new Date(now).toISOString().slice(0, 10);
48
+ const cur = sentToday.get(userId);
49
+ const used = cur && cur.day === day ? cur.n : 0;
50
+ if (used + n > DAILY_RECIPIENTS) return false;
51
+ sentToday.set(userId, {day, n: used + n});
52
+ return true;
53
+ }
54
+
55
+ /** 'https://example.com' from a configured address, or '' when it is not one. */
56
+ function origin(value) {
57
+ const v = String(value ?? '').trim().replace(/\/+$/, '');
58
+ return /^https?:\/\/[^\s/?#]+$/i.test(v) ? v : '';
59
+ }
60
+
61
+ /** The Site URL setting (or the old `site.url`) as an origin, else ''. Sync; links for emails: `linkBase`. */
62
+ export function siteBase() {
63
+ const site = getConfig('site') || {};
64
+ return origin(site.baseUrl) || origin(site.url);
65
+ }
66
+
67
+ /**
68
+ * Where links in emails point: the Site URL, else - as core does for password
69
+ * resets - an address someone has really signed in on. Never the Host header
70
+ * of the request: anyone can send any Host, and a share notification would
71
+ * then carry a lookalike address to colleagues. '' when the site knows none.
72
+ */
73
+ export async function linkBase(req) {
74
+ return siteBase() || await trustedOrigin(originOf(req)).catch(() => '');
75
+ }
76
+
77
+ /** Text with no HTML tags at all: written in the old Markdown editor (or plain text). */
78
+ export function looksLikeMarkdown(content) {
79
+ const c = String(content ?? '');
80
+ return c.trim() !== '' && !/<\/?[a-z][a-z0-9]*[\s>/]/i.test(c);
81
+ }
82
+
83
+ /** HTML from a pre-2.0 body: Markdown converted, HTML sanitised. */
84
+ export function upgradeBody(content) {
85
+ const c = String(content ?? '');
86
+ return sanitizeDoc(looksLikeMarkdown(c) ? marked.parse(c, {gfm: true, async: false}) : c);
87
+ }
88
+
89
+ /**
90
+ * Bring pre-2.0 documents and templates up to 2.0, once: bodies with no HTML
91
+ * at all were written in the old Markdown editor (the mode was site-wide
92
+ * and switchable, so it is decided per document, not by the setting) and
93
+ * are converted; everything else is sanitised - 1.x stored bodies raw.
94
+ * Originals go to data/.backup-markdown/ first. Versions are upgraded when
95
+ * restored.
96
+ */
97
+ export async function migrateLegacy(log) {
98
+ const todo = (await listDocs()).filter(d => !d.format);
99
+ const templates = await templatesStore.readArray().catch(() => []);
100
+ const oldTemplates = templates.filter(t => !t.format);
101
+ if (!todo.length && !oldTemplates.length) return 0;
102
+ const backup = path.join(DATA_DIR, '.backup-markdown');
103
+ await fs.mkdir(backup, {recursive: true});
104
+ for (const d of todo) {
105
+ await fs.writeFile(path.join(backup, `${d.id}.json`), JSON.stringify(d, null, 2));
106
+ await mutateDoc(d.id, (cur) => {
107
+ if (cur.format) return null;
108
+ const html = upgradeBody(cur.content);
109
+ return {...cur, content: html, format: 'html', shares: cur.shares || [], publicLink: cur.publicLink || null,
110
+ trashedAt: cur.trashedAt || null, wordCount: countWords(html)};
111
+ });
112
+ }
113
+ if (oldTemplates.length) {
114
+ await fs.writeFile(path.join(backup, 'templates.json'), JSON.stringify(templates, null, 2));
115
+ for (const t of oldTemplates) await templatesStore.updateItem(t.id, {content: upgradeBody(t.content), format: 'html'});
116
+ }
117
+ log?.info?.(`[docs] upgraded ${todo.length} document(s) and ${oldTemplates.length} template(s) to 2.0 (originals in data/.backup-markdown)`);
118
+ return todo.length;
119
+ }
120
+
121
+ export default async function docsPlugin(fastify, options) {
122
+ const {authenticate, requireAdmin, requirePermission} = options.auth;
123
+ const config = runtime.config;
124
+ Object.assign(config, defaultConfig, options.settings || {});
125
+ delete config.editorMode;
126
+
127
+ const registry = docsRegistry();
128
+ // Injectable for tests: the user list and the mail transport (core's
129
+ // createTransport always does network I/O).
130
+ const listPeople = typeof options.listUsers === 'function' ? options.listUsers : listUsers;
131
+ const mailer = options.mailer || {createTransport, sendEmail};
132
+
133
+ // The Docs permission: granted to admin and user; take it from a role
134
+ // under Roles and that role has no Docs. An older core without
135
+ // requirePermission keeps the old rule (any signed-in account).
136
+ const need = (action) => (typeof requirePermission === 'function' ? requirePermission('docs', action) : async () => {});
137
+ const read = [authenticate, need('read')];
138
+ const make = [authenticate, need('create')];
139
+ const edit = [authenticate, need('update')];
140
+ const drop = [authenticate, need('delete')];
141
+ const isAdmin = (req) => { try { return getEffectiveLevel(req.user) <= 1; } catch { return false; } };
142
+
143
+ /** Names by user id, for owners and share lists. Cached briefly - listUsers reads every user file. */
144
+ let names = null;
145
+ let namesAt = 0;
146
+ let usersCache = [];
147
+ async function people() {
148
+ if (!names || Date.now() - namesAt > 30000) {
149
+ usersCache = await Promise.resolve().then(listPeople).catch(() => []);
150
+ names = new Map(usersCache.map(u => [u.id, String(u.name || '').trim() || u.email || 'Unnamed']));
151
+ namesAt = Date.now();
152
+ }
153
+ return {names, users: usersCache};
154
+ }
155
+ const nameOf = (req) => String(req.user?.name || '').trim() || req.user?.email || 'Someone';
156
+
157
+ /**
158
+ * The document and what this user may do with it, or a 404 already sent.
159
+ * `want`: 'read' | 'edit' | 'suggest' (edit, or a Suggester while an add-on
160
+ * offers suggestions - 2.5) | 'owner'.
161
+ */
162
+ async function load(req, reply, want = 'read') {
163
+ const doc = await readDoc(req.params.id);
164
+ const access = accessOf(doc, req.user, isAdmin(req));
165
+ const mayWrite = canEdit(access) || (want === 'suggest' && access === 'suggester' && registry.hasSuggest());
166
+ let ok = access && (want === 'read' || (want === 'edit' || want === 'suggest' ? mayWrite : access === 'owner'));
167
+ // In Trash a document belongs to its owner alone until it is restored.
168
+ if (ok && doc.trashedAt && doc.userId !== req.user.id) ok = false;
169
+ if (!ok) {
170
+ if (access && doc?.trashedAt && doc.userId !== req.user.id) { reply.code(404).send({error: 'Document not found'}); return null; }
171
+ // A viewer asking to edit gets told why; anyone else learns nothing.
172
+ if (access && want !== 'read') reply.code(403).send({error: want === 'owner' ? 'Only the owner can do that.' : 'You can view this document but not edit it.'});
173
+ else reply.code(404).send({error: 'Document not found'});
174
+ return null;
175
+ }
176
+ return {doc, access};
177
+ }
178
+
179
+ /** A folder a document may be filed in: one of this user's own. */
180
+ async function usableFolder(req, folderId) {
181
+ if (!folderId) return true;
182
+ const f = (await foldersStore.readArray()).find(x => x.id === folderId);
183
+ return Boolean(f && (f.userId === req.user.id || config.scope === 'all'));
184
+ }
185
+
186
+ /** The whole document as the editor sees it: body, access, lock, star, people. */
187
+ async function full(req, doc, access) {
188
+ const {names} = await people();
189
+ const state = await stateFor(req.user.id);
190
+ const session = String(req.headers['x-docs-session'] || '');
191
+ return {
192
+ ...doc,
193
+ // Sanitised on the way out too: a body written before 2.0 was stored as it came. A PDF's content is its text extract (2.13).
194
+ content: doc.format === 'html' || kindOf(doc) === 'pdf' ? doc.content : upgradeBody(doc.content),
195
+ format: kindOf(doc),
196
+ access,
197
+ ownerName: names.get(doc.userId) || 'Unknown',
198
+ starred: state.starred.includes(doc.id),
199
+ lock: publicLock(lockOf(doc.id), session),
200
+ shares: (doc.shares || []).map(s => ({...s, name: names.get(s.userId) || 'Unknown'})),
201
+ publicLink: doc.publicLink ? {...doc.publicLink, ...registry.linkInfo(doc)} : null,
202
+ publicLinksAllowed: config.publicLinks !== false,
203
+ // 2.11: why it may not be shared, linked or sent by this person ('' = it may).
204
+ outwardRefusal: outwardRefusal(doc, req.user),
205
+ // 2.11: an add-on's restriction - no NEW people either (existing shares keep working; removing is allowed).
206
+ restricted: registry.restrictionOf?.(doc) || '',
207
+ // 2.12: why this person may not change it now (a protected wiki page) - the editor opens read only.
208
+ frozen: frozenFor(doc, req.user),
209
+ // 2.13: a PDF's viewer address for this person, good for twenty minutes (refreshed with every poll).
210
+ ...(kindOf(doc) === 'pdf' ? {fileTicket: viewTicket(doc, req)} : {})
211
+ };
212
+ }
213
+
214
+ // Services an extension (Docs Pro) works through.
215
+ registry.services = {
216
+ // 2.12: why this person may not share, link, send - or publish - this document ('' = they may).
217
+ outwardRefusal: (doc, user) => outwardRefusal(doc, user),
218
+ readDoc, writeDoc, listDocs, accessOf: (doc, user) => accessOf(doc, user, (() => { try { return getEffectiveLevel(user) <= 1; } catch { return false; } })()),
219
+ sanitizeDoc, allowMarkup, siteBase, linkBase, people, config: () => ({...config}), htmlToText,
220
+ // Folders (2.1) - Docs Pro's Spaces are folders.
221
+ listFolders: () => foldersStore.readArray(),
222
+ /** Mark a document's public link as protected by an add-on (2.3): Docs then refuses it when no gate is there. */
223
+ setLinkGuard: (docId, on) => mutateDoc(docId, (cur) => (Boolean(cur.linkGuarded) === Boolean(on) ? null : {...cur, linkGuarded: Boolean(on)})),
224
+ // Conversions (2.2) - Docs Pro's cloud copies write the same files Download does.
225
+ convert: {standaloneHtml, htmlToMarkdown, htmlToText, fileName, startsWithTitle, parseHtml},
226
+ /**
227
+ * Make a document for a user, as POST /documents does (2.4): the body is
228
+ * sanitised, the folder must be one they may file in, it goes in their Recent.
229
+ */
230
+ createDoc: async ({title, content = '', folderId = null}, user) => {
231
+ const req = {user};
232
+ if (folderId && !(await usableFolder(req, folderId))) throw new Error('No such folder');
233
+ const doc = await writeDoc(newDoc({title, content, folderId}, user));
234
+ await touchRecent(user.id, doc.id);
235
+ return doc;
236
+ },
237
+ isAdmin: (user) => { try { return getEffectiveLevel(user) <= 1; } catch { return false; } },
238
+ // 2.11 (the Wiki): change a document inside its write chain, as every Docs route does; a body as the
239
+ // public sees it (sanitised, pending suggestions not made, add-ons' filters); who is editing it now.
240
+ mutateDoc: (id, fn) => mutateDoc(id, fn),
241
+ publicHtml: (html) => registry.publicHtml(withoutSuggestions(sanitizeDoc(html))),
242
+ lockOf: (id) => publicLock(lockOf(id), ''),
243
+ // 2.12 (Wiki Pro's approved suggestions): keep what a change replaces as a version, as a save does.
244
+ // A document's `creditName` + `creditAt` (= its updatedAt) names who that save is credited to.
245
+ snapshotVersion: (doc, by = null) => snapshotVersion(doc, Number(config.maxVersions) || 50, by),
246
+ // 2.13: PDF documents. kindOf says which a document is; readFile is its bytes; writeFile replaces them
247
+ // (checked, extracted, versioned - as PUT .../file does) and answers the document as written.
248
+ kindOf,
249
+ readFile: (docId) => readFileBytes(docId),
250
+ writeFile: (docId, buf, user, {name} = {}) => replacePdf(docId, buf, {name, user}),
251
+ pdfCapability: () => extractCapability(),
252
+ /**
253
+ * Make a PDF document from bytes for a user (2.13.1 - Docs Pro's "take pages out"): checked and read
254
+ * as an upload is, filed where they may file, into their Recent. Throws {status, message}.
255
+ */
256
+ createPdfDoc: async ({title, buf, name = 'document.pdf', folderId = null}, user) => {
257
+ const req = {user};
258
+ if (folderId && !(await usableFolder(req, folderId))) throw Object.assign(new Error('No such folder'), {status: 400});
259
+ const prepared = await preparePdf(buf, name, user);
260
+ const doc = newDoc({title: String(title || '').trim() || cleanFileName(name).replace(/\.pdf$/i, '') || 'Untitled', content: prepared.content, folderId, format: 'pdf', file: prepared.file}, user);
261
+ await writeFileBytes(doc.id, buf);
262
+ await writeDoc(doc);
263
+ await touchRecent(user.id, doc.id);
264
+ return doc;
265
+ }
266
+ };
267
+
268
+ // ------------------------------------------------------------- PDF files (2.13)
269
+
270
+ /**
271
+ * Check a PDF and read its text. {content, file} for the record, or an
272
+ * Error with the plain reason the person sees.
273
+ */
274
+ async function preparePdf(buf, name, user) {
275
+ const checked = inspectPdf(buf, {maxBytes: MAX_PDF_BYTES});
276
+ if (!checked.ok) throw Object.assign(new Error(checked.reason), {status: 400});
277
+ const read = checked.encrypted ? null : await extractPdfText(buf);
278
+ const content = read?.text ? sanitizeDoc(extractToHtml(read.text)) : '';
279
+ return {content, file: fileRecord({name: cleanFileName(name), size: buf.length, pages: read?.pages || checked.pages, sha256: checked.sha256, encrypted: checked.encrypted}, user)};
280
+ }
281
+
282
+ /**
283
+ * Replace a PDF document's file (an upload of a new version). The record's
284
+ * text extract and file facts follow; what it had is kept as a version
285
+ * (always - a new upload is a real change, not an autosave). Refused in Trash
286
+ * (409), while another session holds the edit lock (423), frozen (403).
287
+ */
288
+ async function replacePdf(docId, buf, {name, user, session = ''}) {
289
+ const before = await readDoc(docId);
290
+ if (!before) throw Object.assign(new Error('Document not found'), {status: 404});
291
+ if (kindOf(before) !== 'pdf') throw Object.assign(new Error('Only a PDF document takes a file.'), {status: 400});
292
+ const prepared = await preparePdf(buf, name || before.file?.name, user);
293
+ let refused = null;
294
+ const updated = await mutateDoc(docId, async (doc) => {
295
+ if (doc.trashedAt) { refused = [409, {error: 'This document is in Trash. Restore it to replace its file.'}]; return null; }
296
+ const frozen = frozenFor(doc, user);
297
+ if (frozen) { refused = [403, {error: frozen, frozen: true}]; return null; }
298
+ const held = lockOf(doc.id);
299
+ if (held && held.session !== session) { refused = [423, {error: `${held.name} has this document open.`, lock: publicLock(held, session)}]; return null; }
300
+ await snapshotVersion(doc, Number(config.maxVersions) || 50, user.id);
301
+ await writeFileBytes(doc.id, buf);
302
+ return {...doc, content: prepared.content, file: prepared.file, wordCount: countWords(prepared.content), updatedAt: new Date().toISOString(), updatedBy: user.id};
303
+ });
304
+ if (refused) throw Object.assign(new Error(refused[1].error), {status: refused[0], body: refused[1]});
305
+ if (!updated) throw Object.assign(new Error('Document not found'), {status: 404});
306
+ registry.emit('saved', updated, user);
307
+ return updated;
308
+ }
309
+
310
+ /**
311
+ * View tickets (2.13). The admin shows a PDF in an <iframe>, which can carry no bearer
312
+ * header, and the admin's CSP frames only 'self' - so the frame's address is the file
313
+ * route with a short-lived ticket: HMAC of document, person and expiry under a key made
314
+ * at boot (a restart ends every ticket; the screen gets a fresh one with each poll). The
315
+ * ticket says who asked and what they could do then; the route checks the document again.
316
+ */
317
+ const VIEW_TTL_MS = 20 * 60 * 1000;
318
+ const viewKey = randomBytes(32);
319
+ function viewTicket(doc, req) {
320
+ const body = Buffer.from(JSON.stringify({d: doc.id, u: req.user.id, a: isAdmin(req) ? 1 : 0, e: Date.now() + VIEW_TTL_MS})).toString('base64url');
321
+ return `${body}.${createHmac('sha256', viewKey).update(body).digest('base64url')}`;
322
+ }
323
+ function readViewTicket(ticket, docId) {
324
+ const [body, sig] = String(ticket || '').split('.');
325
+ if (!body || !sig || body.length > 400) return null;
326
+ const want = createHmac('sha256', viewKey).update(body).digest();
327
+ let got;
328
+ try { got = Buffer.from(sig, 'base64url'); } catch { return null; }
329
+ if (got.length !== want.length || !timingSafeEqual(got, want)) return null;
330
+ let claims;
331
+ try { claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); } catch { return null; }
332
+ if (!claims || claims.d !== docId || typeof claims.u !== 'string' || !(claims.e > Date.now())) return null;
333
+ return {userId: claims.u, admin: claims.a === 1};
334
+ }
335
+
336
+ /** The headers a PDF goes out with: never sniffed, never cached, never scripted, never indexed. */
337
+ function sendPdf(reply, buf, title, {inline = false} = {}) {
338
+ return reply.type('application/pdf')
339
+ .header('Content-Disposition', contentDisposition(fileName(title, 'pdf'), inline))
340
+ .header('X-Content-Type-Options', 'nosniff')
341
+ .header('Cache-Control', 'private, no-store')
342
+ .header('Content-Security-Policy', "default-src 'none'; sandbox")
343
+ .header('X-Robots-Tag', 'noindex')
344
+ .send(buf);
345
+ }
346
+
347
+ /** A base64 upload body {name, data}: the bytes, or a 400 already sent. */
348
+ function bytesOf(req, reply) {
349
+ const b = req.body || {};
350
+ if (typeof b.data !== 'string' || !b.data) { reply.code(400).send({error: 'No file was uploaded.'}); return null; }
351
+ let buf;
352
+ try { buf = Buffer.from(b.data.replace(/^data:[^,]*,/, ''), 'base64'); } catch { buf = null; }
353
+ if (!buf || !buf.length) { reply.code(400).send({error: 'The file could not be read.'}); return null; }
354
+ if (buf.length > MAX_PDF_BYTES) { reply.code(400).send({error: `The file is over ${Math.round(MAX_PDF_BYTES / 1024 / 1024)} MB.`}); return null; }
355
+ return buf;
356
+ }
357
+ const UPLOAD_BODY = Math.ceil(MAX_PDF_BYTES * 1.4) + 4096;
358
+
359
+ fastify.addHook('onReady', async () => {
360
+ await migrateLegacy(fastify.log).catch(err => fastify.log.error({err}, '[docs] 2.0 upgrade of old documents failed'));
361
+ await purgeTrash().catch(() => {});
362
+ });
363
+
364
+ // ------------------------------------------------------------- settings
365
+
366
+ /*
367
+ * What the admin screen may know about its own configuration -
368
+ * whitelisted, never spread, so a secret added to the settings object is
369
+ * not handed to the browser.
370
+ */
371
+ fastify.get('/settings', {preHandler: read}, async (req) => ({
372
+ autoSaveInterval: Number(config.autoSaveInterval) || 0,
373
+ maxVersions: Number(config.maxVersions) || 50,
374
+ publicLinks: config.publicLinks !== false,
375
+ allowSend: config.allowSend !== false,
376
+ trashDays: Number(config.trashDays) || 30,
377
+ // Shown, never edited here: flipping 'user' to 'all' would hand every
378
+ // user everybody else's documents - a decision for config.
379
+ scope: config.scope === 'all' ? 'all' : 'user',
380
+ canEdit: isAdmin(req),
381
+ extensions: registry.adminModules(),
382
+ suggestions: registry.hasSuggest(),
383
+ // 2.13: PDF documents - uploads always; the text inside them is searchable when pdfjs-dist is on this server.
384
+ pdf: {maxBytes: MAX_PDF_BYTES, search: extractCapability().available, reason: extractCapability().reason}
385
+ }));
386
+
387
+ fastify.put('/settings', {preHandler: [authenticate, requireAdmin]}, async (req, reply) => {
388
+ const body = req.body || {};
389
+ const next = {};
390
+ if (body.autoSaveInterval !== undefined) {
391
+ const ms = Number(body.autoSaveInterval);
392
+ if (!Number.isFinite(ms) || ms < 0) return reply.code(400).send({error: 'autoSaveInterval must be 0 or more'});
393
+ // Under five seconds is a save per keystroke burst, not an autosave.
394
+ next.autoSaveInterval = ms === 0 ? 0 : Math.min(600000, Math.max(5000, Math.round(ms)));
395
+ }
396
+ if (body.maxVersions !== undefined) {
397
+ const n = Math.round(Number(body.maxVersions));
398
+ if (!Number.isFinite(n)) return reply.code(400).send({error: 'maxVersions must be a number'});
399
+ next.maxVersions = Math.min(MAX_VERSIONS, Math.max(MIN_VERSIONS, n));
400
+ }
401
+ if (body.publicLinks !== undefined) next.publicLinks = body.publicLinks === true;
402
+ if (body.allowSend !== undefined) next.allowSend = body.allowSend === true;
403
+ if (body.trashDays !== undefined) {
404
+ const n = Math.round(Number(body.trashDays));
405
+ if (!Number.isFinite(n)) return reply.code(400).send({error: 'trashDays must be a number'});
406
+ next.trashDays = Math.min(365, Math.max(1, n));
407
+ }
408
+ Object.assign(config, next);
409
+ const saved = {...(options.settings || {}), ...next};
410
+ delete saved.editorMode;
411
+ savePluginState('docs', {settings: saved});
412
+ options.settings = saved;
413
+ return reply.send({ok: true, ...next});
414
+ });
415
+
416
+ // --------------------------------------------------------------- people
417
+
418
+ /**
419
+ * Who a document can be shared with: people whose role holds Docs. Id and
420
+ * name; an admin also sees the email, to tell two Sams apart.
421
+ */
422
+ fastify.get('/people', {preHandler: read}, async (req) => {
423
+ const {users} = await people();
424
+ let roles = null;
425
+ try { roles = new Set(getPermissionsFor('docs', 'read')); } catch { roles = null; }
426
+ // Only the root role listed means the permission is not registered
427
+ // (an older core, a harness) - then nobody can be told apart by it.
428
+ if (roles && [...roles].every(r => { try { return getRoleLevel(r) === 0; } catch { return true; } })) roles = null;
429
+ const admin = isAdmin(req);
430
+ return users
431
+ .filter(u => u.id !== req.user.id && u.disabled !== true && u.isActive !== false && u.active !== false)
432
+ .filter(u => !roles || !roles.size || getEffectiveRoles(u).some(r => roles.has(r)))
433
+ .map(u => ({id: u.id, name: String(u.name || '').trim() || 'Unnamed', ...(admin ? {email: u.email || ''} : {})}));
434
+ });
435
+
436
+ // ------------------------------------------------------------ sidebar badge
437
+
438
+ /**
439
+ * GET /count - the sidebar badge (core countUrl contract): how many documents this person owns
440
+ * (Trash left out), an inventory a closed folder leaves out of its total. Docs has nothing that
441
+ * waits on anyone, so there is no attention case. Polled every minute from every open tab and
442
+ * listDocs reads every file, so it is cached for a minute per person. Every finished write bumps
443
+ * a generation; a count only keeps its answer if no write finished while it was reading, so the
444
+ * refresh straight after a save is never an older read stored after the clear.
445
+ */
446
+ const BADGE_TTL = 60_000;
447
+ const badgeCache = new Map(); // uid -> {at, gen, value}
448
+ // The generation lives in the store (2.10): every document/star write bumps it, Docs Pro's
449
+ // writes through `services` included. Finished non-GET requests still bump it too.
450
+ fastify.addHook('onResponse', async (req) => { if (req.method !== 'GET' && req.method !== 'HEAD') runtime.writeGen++; });
451
+ fastify.get('/count', {preHandler: read}, async (req) => {
452
+ const uid = req.user.id;
453
+ const now = Date.now();
454
+ for (const [k, v] of badgeCache) if (now - v.at >= BADGE_TTL) badgeCache.delete(k);
455
+ const hit = badgeCache.get(uid);
456
+ if (hit && hit.gen === runtime.writeGen) return hit.value;
457
+ const gen = runtime.writeGen;
458
+ const admin = isAdmin(req);
459
+ const all = await listDocs();
460
+ const visible = all.filter(d => accessOf(d, req.user, admin));
461
+ const mine = visible.filter(d => d.userId === uid && !d.trashedAt)
462
+ .sort((a, b) => (Date.parse(b.updatedAt || '') || 0) - (Date.parse(a.updatedAt || '') || 0));
463
+ const shared = visible.filter(d => d.userId !== uid && !d.trashedAt && (d.shares || []).some(x => x.userId === uid)).length;
464
+ const trashed = visible.filter(d => d.userId === uid && d.trashedAt).length;
465
+ const live = new Set(visible.filter(d => !d.trashedAt).map(d => d.id));
466
+ const state = await stateFor(uid);
467
+ const starred = state.starred.filter(id => live.has(id)).length;
468
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
469
+ const value = {
470
+ count: mine.length,
471
+ label: `${plural(mine.length, 'document')}${shared ? ` - ${shared} shared with you` : ''}`,
472
+ tone: 'info',
473
+ inventory: true,
474
+ // What each of Home's places shows when opened - the numbers on its rail (2.10).
475
+ places: placeCounts(all, {user: req.user, isAdmin: admin, state}),
476
+ details: {
477
+ title: 'Docs',
478
+ rows: [{label: 'Yours', value: String(mine.length)}, {label: 'Shared with you', value: String(shared)},
479
+ {label: 'Starred', value: String(starred)}, {label: 'In Trash', value: String(trashed)}],
480
+ items: mine.slice(0, 5).map(d => ({text: d.title || 'Untitled'})),
481
+ empty: 'No documents yet.'
482
+ }
483
+ };
484
+ if (gen === runtime.writeGen) badgeCache.set(uid, {at: now, gen, value});
485
+ return value;
486
+ });
487
+
488
+ // ------------------------------------------------------------ documents
489
+
490
+ /**
491
+ * GET /documents?view=mine|recent|starred|shared|all|trash&folder=&q=
492
+ * Rows, not bodies: an excerpt instead of the content.
493
+ */
494
+ fastify.get('/documents', {preHandler: read}, async (req) => {
495
+ const {view = 'mine', folder, q} = req.query || {};
496
+ if (view === 'trash') await purgeTrash().catch(() => {});
497
+ const {names} = await people();
498
+ const ctx = {user: req.user, isAdmin: isAdmin(req), state: await stateFor(req.user.id), names};
499
+ return selectRows(await listDocs(), {view: String(view), folder, q}, ctx);
500
+ });
501
+
502
+ // A document with pictures built in (an import, a paste) is larger than Fastify's 1 MB default (2.4).
503
+ const DOC_BODY = 5 * 1024 * 1024;
504
+
505
+ fastify.post('/documents', {preHandler: make, bodyLimit: DOC_BODY}, async (req, reply) => {
506
+ const body = req.body || {};
507
+ if (!(await usableFolder(req, body.folderId))) return reply.code(400).send({error: 'No such folder'});
508
+ // Only an upload makes a PDF document: a record saying 'pdf' with no file behind it is never made here.
509
+ const doc = await writeDoc(newDoc({title: body.title, content: body.content, folderId: body.folderId, tags: body.tags}, req.user));
510
+ await touchRecent(req.user.id, doc.id);
511
+ return reply.code(201).send(await full(req, doc, 'owner'));
512
+ });
513
+
514
+ /** POST /documents/import {name, text, folderId} - a .md, .html or .txt file as a new document. */
515
+ fastify.post('/documents/import', {preHandler: make, bodyLimit: 5 * 1024 * 1024}, async (req, reply) => {
516
+ const {name, text, folderId = null} = req.body || {};
517
+ if (typeof text !== 'string') return reply.code(400).send({error: 'No file content'});
518
+ if (!importKind(name)) return reply.code(400).send({error: /\.pdf$/i.test(String(name || '')) ? 'A PDF is uploaded as a file, not imported: drop it on the list or choose New > Upload a PDF.' : 'Docs can import .md, .html and .txt files.'});
519
+ if (!(await usableFolder(req, folderId))) return reply.code(400).send({error: 'No such folder'});
520
+ const imported = importFile({name, text});
521
+ const doc = await writeDoc(newDoc({...imported, folderId}, req.user));
522
+ await touchRecent(req.user.id, doc.id);
523
+ return reply.code(201).send(await full(req, doc, 'owner'));
524
+ });
525
+
526
+ /**
527
+ * POST /documents/upload {name, data (base64), folderId, title?} - a PDF
528
+ * as a new document (2.13). The file is checked (lib/pdf.js), its text
529
+ * read for search, and it is kept as it is: Docs never rewrites a PDF.
530
+ */
531
+ fastify.post('/documents/upload', {preHandler: make, bodyLimit: UPLOAD_BODY, config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (req, reply) => {
532
+ const {name, folderId = null, title} = req.body || {};
533
+ const buf = bytesOf(req, reply);
534
+ if (!buf) return;
535
+ if (!/\.pdf$/i.test(String(name || '')) && buf.subarray(0, 1024).toString('latin1').indexOf('%PDF-') === -1) return reply.code(400).send({error: 'Docs takes PDF files here.'});
536
+ if (!(await usableFolder(req, folderId))) return reply.code(400).send({error: 'No such folder'});
537
+ let prepared;
538
+ try { prepared = await preparePdf(buf, name, req.user); } catch (err) { return reply.code(err.status || 400).send({error: err.message}); }
539
+ const base = cleanFileName(name).replace(/\.pdf$/i, '').trim();
540
+ const doc = newDoc({title: typeof title === 'string' && title.trim() ? title : (base || 'Untitled'), content: prepared.content, folderId, format: 'pdf', file: prepared.file}, req.user);
541
+ await writeFileBytes(doc.id, buf);
542
+ await writeDoc(doc);
543
+ await touchRecent(req.user.id, doc.id);
544
+ return reply.code(201).send(await full(req, doc, 'owner'));
545
+ });
546
+
547
+ /** GET /documents/:id/file - the PDF itself, inline; ?download=1 saves it. Anyone who may read the document. */
548
+ fastify.get('/documents/:id/file', {preHandler: read}, async (req, reply) => {
549
+ const hit = await load(req, reply);
550
+ if (!hit) return;
551
+ if (kindOf(hit.doc) !== 'pdf') return reply.code(404).send({error: 'This document has no file.'});
552
+ const buf = await readFileBytes(hit.doc.id);
553
+ if (!buf) return reply.code(404).send({error: 'The file is missing.'});
554
+ return sendPdf(reply, buf, hit.doc.title || 'Untitled', {inline: req.query?.download !== '1'});
555
+ });
556
+
557
+ /**
558
+ * GET /documents/:id/view?t=<ticket> - the PDF for the admin's frame (2.13). No bearer
559
+ * header here: the ticket (from GET /documents/:id) is the credential, and the document's
560
+ * access is checked again for the person it names.
561
+ */
562
+ fastify.get('/documents/:id/view', {config: {rateLimit: {max: 120, timeWindow: '1 minute'}}}, async (req, reply) => {
563
+ const claims = validId(req.params.id) ? readViewTicket(req.query?.t, req.params.id) : null;
564
+ if (!claims) return reply.code(403).send({error: 'This viewer link has expired. Reload the document.'});
565
+ // The person must still be here with a role that holds Docs: a ticket outlives neither.
566
+ const viewer = (await people()).users.find(u => String(u.id) === claims.userId);
567
+ if (!viewer || viewer.disabled === true || viewer.isActive === false || viewer.active === false) return reply.code(403).send({error: 'This viewer link no longer works.'});
568
+ try {
569
+ const allowed = getPermissionsFor('docs', 'read');
570
+ if (Array.isArray(allowed) && allowed.length && !getEffectiveRoles(viewer).some(r => allowed.includes(r) || (() => { try { return getRoleLevel(r) === 0; } catch { return false; } })())) return reply.code(403).send({error: 'This viewer link no longer works.'});
571
+ } catch { /* an older core without the permission: the ticket stands */ }
572
+ const doc = await readDoc(req.params.id);
573
+ const access = accessOf(doc, {id: claims.userId}, claims.admin);
574
+ if (!doc || !access || (doc.trashedAt && doc.userId !== claims.userId) || kindOf(doc) !== 'pdf') return reply.code(404).send({error: 'Document not found'});
575
+ const buf = await readFileBytes(doc.id);
576
+ if (!buf) return reply.code(404).send({error: 'The file is missing.'});
577
+ return sendPdf(reply, buf, doc.title || 'Untitled', {inline: true});
578
+ });
579
+
580
+ /** PUT /documents/:id/file {name, data} - a new upload replaces the file; the old one becomes a version. Editors. */
581
+ fastify.put('/documents/:id/file', {preHandler: edit, bodyLimit: UPLOAD_BODY, config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (req, reply) => {
582
+ const hit = await load(req, reply, 'edit');
583
+ if (!hit) return;
584
+ if (kindOf(hit.doc) !== 'pdf') return reply.code(400).send({error: 'Only a PDF document takes a file.'});
585
+ const buf = bytesOf(req, reply);
586
+ if (!buf) return;
587
+ const session = String(req.headers['x-docs-session'] || req.body?.session || '');
588
+ try {
589
+ const updated = await replacePdf(hit.doc.id, buf, {name: req.body?.name, user: req.user, session});
590
+ return full(req, updated, hit.access);
591
+ } catch (err) {
592
+ return reply.code(err.status || 500).send(err.body || {error: err.message || 'The file could not be replaced.'});
593
+ }
594
+ });
595
+
596
+ fastify.get('/documents/:id', {preHandler: read}, async (req, reply) => {
597
+ const hit = await load(req, reply);
598
+ if (!hit) return;
599
+ // A reader's screen polls with ?poll=1: that is not "opening" it.
600
+ if (req.query?.poll !== '1') await touchRecent(req.user.id, hit.doc.id);
601
+ return full(req, hit.doc, hit.access);
602
+ });
603
+
604
+ /**
605
+ * PUT /documents/:id {title?, content?, folderId?, tags?, baseUpdatedAt?}
606
+ *
607
+ * `baseUpdatedAt` is the version the editor started from. When the
608
+ * document has been saved by anyone since, the save is refused (409)
609
+ * rather than written over their work - the editor keeps its text as a
610
+ * copy. Refused too (423) while another session holds the edit lock.
611
+ */
612
+ fastify.put('/documents/:id', {preHandler: edit, bodyLimit: DOC_BODY}, async (req, reply) => {
613
+ const hit = await load(req, reply, 'suggest');
614
+ if (!hit) return;
615
+ const {access} = hit;
616
+ const body = {...(req.body || {})};
617
+ // A Suggester changes the body only - and only by suggesting (checked below, against the stored body).
618
+ const suggester = access === 'suggester';
619
+ if (suggester) {
620
+ if (body.folderId !== undefined) return reply.code(403).send({error: 'Only the owner can move a document.'});
621
+ delete body.title;
622
+ delete body.tags;
623
+ }
624
+ // 2.13: a PDF document's body is its file - replaced through PUT .../file, never written as text.
625
+ if (body.content !== undefined && kindOf(hit.doc) === 'pdf') return reply.code(400).send({error: 'A PDF document has no text to edit. Upload a new file to replace it.'});
626
+ const touchesText = body.content !== undefined || body.title !== undefined;
627
+ // 2.12: an add-on's freeze (a protected wiki page) - no text or title, for anyone.
628
+ const frozen = touchesText ? frozenFor(hit.doc, req.user) : '';
629
+ if (frozen) return reply.code(403).send({error: frozen, frozen: true});
630
+ const session = String(req.headers['x-docs-session'] || body.session || '');
631
+ if (body.folderId !== undefined) {
632
+ if (access !== 'owner') return reply.code(403).send({error: 'Only the owner can move a document.'});
633
+ if (!(await usableFolder(req, body.folderId))) return reply.code(400).send({error: 'No such folder'});
634
+ }
635
+ const content = body.content !== undefined ? sanitizeDoc(body.content) : undefined;
636
+ let refused = null;
637
+ const updated = await mutateDoc(hit.doc.id, async (doc) => {
638
+ if (doc.trashedAt) { refused = [409, {error: 'This document is in Trash. Restore it to edit it.'}]; return null; }
639
+ const held = lockOf(doc.id);
640
+ if (held && held.session !== session && touchesText) {
641
+ refused = [423, {error: `${held.name} is editing this document.`, lock: publicLock(held, session)}];
642
+ return null;
643
+ }
644
+ if (suggester && touchesText && !held) {
645
+ refused = [423, {error: 'Start editing again to suggest changes.'}];
646
+ return null;
647
+ }
648
+ if (typeof body.baseUpdatedAt === 'string' && body.baseUpdatedAt && body.baseUpdatedAt !== doc.updatedAt && touchesText) {
649
+ refused = [409, {error: 'This document was saved by someone else since you opened it.', conflict: true, updatedAt: doc.updatedAt}];
650
+ return null;
651
+ }
652
+ let nextContent = content !== undefined ? content : doc.content;
653
+ if (suggester && content !== undefined && content !== doc.content) {
654
+ const checked = registry.checkSuggestion(doc.content, content, req.user);
655
+ if (checked === null) {
656
+ refused = [403, {error: 'You can suggest changes to this document, not edit it. Your changes were not saved.', suggestOnly: true}];
657
+ return null;
658
+ }
659
+ nextContent = sanitizeDoc(checked);
660
+ }
661
+ if (content !== undefined && nextContent !== doc.content && shouldSnapshot(doc, req.user.id)) {
662
+ await snapshotVersion(doc, Number(config.maxVersions) || 50, req.user.id);
663
+ }
664
+ return {
665
+ ...doc,
666
+ title: body.title !== undefined ? cleanTitle(body.title) : doc.title,
667
+ content: nextContent,
668
+ format: kindOf(doc) === 'pdf' ? 'pdf' : 'html',
669
+ folderId: body.folderId !== undefined ? (body.folderId || null) : doc.folderId,
670
+ tags: Array.isArray(body.tags) ? body.tags.map(String).slice(0, 50) : doc.tags,
671
+ wordCount: countWords(nextContent),
672
+ updatedAt: new Date().toISOString(),
673
+ updatedBy: req.user.id
674
+ };
675
+ });
676
+ if (refused) return reply.code(refused[0]).send(refused[1]);
677
+ if (!updated) return reply.code(404).send({error: 'Document not found'});
678
+ registry.emit('saved', updated, req.user);
679
+ return full(req, updated, access);
680
+ });
681
+
682
+ /**
683
+ * DELETE /documents/:id - to Trash. With ?permanent=1, and only from
684
+ * Trash, gone for good with its history.
685
+ */
686
+ fastify.delete('/documents/:id', {preHandler: drop}, async (req, reply) => {
687
+ const hit = await load(req, reply, 'owner');
688
+ if (!hit) return;
689
+ const {doc} = hit;
690
+ const frozen = frozenFor(doc, req.user);
691
+ if (frozen) return reply.code(403).send({error: frozen, frozen: true});
692
+ const permanent = req.query?.permanent === '1' || req.query?.permanent === 'true';
693
+ if (permanent) {
694
+ if (!doc.trashedAt) return reply.code(409).send({error: 'Move it to Trash first.'});
695
+ await removeDoc(doc.id);
696
+ await forgetDoc(doc.id);
697
+ registry.emit('deleted', doc, req.user);
698
+ return {ok: true, deleted: true};
699
+ }
700
+ const trashed = await mutateDoc(doc.id, (cur) => ({...cur, trashedAt: new Date().toISOString(), trashedBy: req.user.id}));
701
+ releaseLock(doc.id, lockOf(doc.id)?.session);
702
+ registry.emit('trashed', trashed, req.user);
703
+ return {ok: true, trashed: true};
704
+ });
705
+
706
+ fastify.post('/documents/:id/restore', {preHandler: drop}, async (req, reply) => {
707
+ const hit = await load(req, reply, 'owner');
708
+ if (!hit) return;
709
+ // 2.12: a frozen document (a protected wiki page) stays where it is.
710
+ const frozen = frozenFor(hit.doc, req.user);
711
+ if (frozen) return reply.code(403).send({error: frozen, frozen: true});
712
+ // A folder deleted while the document sat in Trash: back to the top.
713
+ const folderGone = hit.doc.folderId && !(await usableFolder(req, hit.doc.folderId));
714
+ const back = await mutateDoc(hit.doc.id, (cur) => ({...cur, trashedAt: null, trashedBy: null, folderId: folderGone ? null : cur.folderId}));
715
+ if (back) registry.emit('untrashed', back, req.user);
716
+ return {ok: true};
717
+ });
718
+
719
+ /** POST /trash/empty - everything in this user's own Trash, gone. */
720
+ fastify.post('/trash/empty', {preHandler: drop}, async (req) => {
721
+ let n = 0;
722
+ for (const d of await listDocs()) {
723
+ // Their own only - even an admin on a site-wide install empties nobody else's Trash.
724
+ // 2.12: a frozen one (a protected wiki page) is kept.
725
+ if (d.trashedAt && d.userId === req.user.id && !frozenFor(d, req.user)) {
726
+ await removeDoc(d.id);
727
+ await forgetDoc(d.id);
728
+ registry.emit('deleted', d, req.user);
729
+ n++;
730
+ }
731
+ }
732
+ return {ok: true, deleted: n};
733
+ });
734
+
735
+ /** A copy is always the copier's own: private, unshared, no public link. */
736
+ fastify.post('/documents/:id/duplicate', {preHandler: make}, async (req, reply) => {
737
+ const hit = await load(req, reply);
738
+ if (!hit) return;
739
+ const {doc} = hit;
740
+ const title = typeof req.body?.title === 'string' && req.body.title.trim() ? req.body.title : `${doc.title} (copy)`;
741
+ const pdf = kindOf(doc) === 'pdf';
742
+ const content = typeof req.body?.content === 'string' && !pdf ? req.body.content : doc.content;
743
+ const copy = newDoc({title, content, folderId: doc.userId === req.user.id ? doc.folderId : null, tags: doc.tags,
744
+ ...(pdf ? {format: 'pdf', file: {...doc.file, name: doc.file?.name}} : {})}, req.user);
745
+ // 2.13: a PDF copy is the file too - copied before the record exists, so no record ever points at nothing.
746
+ if (pdf && !(await copyFileBytes(doc.id, copy.id))) return reply.code(409).send({error: 'The file is missing, so it cannot be copied.'});
747
+ await writeDoc(copy);
748
+ await touchRecent(req.user.id, copy.id);
749
+ return reply.code(201).send(await full(req, copy, 'owner'));
750
+ });
751
+
752
+ fastify.post('/documents/:id/star', {preHandler: read}, async (req, reply) => {
753
+ const hit = await load(req, reply);
754
+ if (!hit) return;
755
+ const on = req.body?.on !== false;
756
+ await setStar(req.user.id, hit.doc.id, on);
757
+ return {ok: true, starred: on};
758
+ });
759
+
760
+ // ---------------------------------------------------------------- locks
761
+
762
+ /**
763
+ * POST /documents/:id/lock {session, force} - take or keep the edit lock.
764
+ * The editor calls this on open and every 30 seconds. {ok:false, lock}
765
+ * while somebody else holds it; `force` takes it over (the owner, or
766
+ * yourself in another tab).
767
+ */
768
+ fastify.post('/documents/:id/lock', {preHandler: read}, async (req, reply) => {
769
+ const hit = await load(req, reply);
770
+ if (!hit) return;
771
+ const {doc, access} = hit;
772
+ const session = String(req.body?.session || '');
773
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(session)) return reply.code(400).send({error: 'A session id is required'});
774
+ const suggests = access === 'suggester' && registry.hasSuggest();
775
+ if ((!canEdit(access) && !suggests) || doc.trashedAt) return {ok: false, readOnly: true, lock: publicLock(lockOf(doc.id), session)};
776
+ // 2.12: frozen by an add-on - read only, with the reason.
777
+ const frozen = frozenFor(doc, req.user);
778
+ if (frozen) return {ok: false, readOnly: true, frozen, lock: publicLock(lockOf(doc.id), session)};
779
+ const held = lockOf(doc.id);
780
+ // Editors may take over from a Suggester (2.5) - suggesting must never keep an editor out.
781
+ const mayForce = access === 'owner' || held?.userId === req.user.id || (canEdit(access) && Boolean(held?.suggester));
782
+ if (req.body?.force && !mayForce) return reply.code(403).send({error: 'Only the owner can take over editing.'});
783
+ const r = acquireLock(doc.id, {userId: req.user.id, name: nameOf(req), session, force: Boolean(req.body?.force) && mayForce, suggester: !canEdit(access)});
784
+ // 200 either way: a lock held elsewhere is an answer, not an error (the admin's H hides error bodies).
785
+ if (!r.ok) return {ok: false, lock: publicLock(r.lock, session), canTakeOver: mayForce};
786
+ return {ok: true, lock: publicLock(r.lock, session), updatedAt: doc.updatedAt};
787
+ });
788
+
789
+ fastify.delete('/documents/:id/lock', {preHandler: read}, async (req) => {
790
+ if (validId(req.params.id)) releaseLock(req.params.id, String(req.query?.session || ''));
791
+ return {ok: true};
792
+ });
793
+
794
+ // -------------------------------------------------------------- sharing
795
+
796
+ /**
797
+ * PUT /documents/:id/shares {shares:[{userId, role}], notify, message}
798
+ * Replaces the list. The owner and editors may share; notifying emails
799
+ * the people newly added.
800
+ */
801
+ fastify.put('/documents/:id/shares', {preHandler: edit, config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (req, reply) => {
802
+ const hit = await load(req, reply, 'edit');
803
+ if (!hit) return;
804
+ const {doc, access} = hit;
805
+ if (doc.trashedAt) return reply.code(409).send({error: 'This document is in Trash.'});
806
+ // Only add-on access (2.11): editing, not passing it on. A restricted document takes nobody new (below).
807
+ if (onlyThroughExtension(doc, req.user)) return reply.code(403).send({error: outwardRefusal(doc, req.user)});
808
+ const {users} = await people();
809
+ const known = new Map(users.map(u => [u.id, u]));
810
+ const incoming = Array.isArray(req.body?.shares) ? req.body.shares : [];
811
+ const seen = new Set();
812
+ const shares = [];
813
+ for (const s of incoming.slice(0, 200)) {
814
+ const uid = String(s?.userId || '');
815
+ if (!known.has(uid) || uid === doc.userId || seen.has(uid)) continue;
816
+ seen.add(uid);
817
+ const prev = (doc.shares || []).find(x => x.userId === uid);
818
+ // Suggester (2.5) while an add-on offers suggestions - or kept as it was when one lapsed.
819
+ const role = s.role === 'editor' ? 'editor'
820
+ : s.role === 'suggester' && (registry.hasSuggest() || prev?.role === 'suggester') ? 'suggester' : 'viewer';
821
+ shares.push({userId: uid, role, addedAt: prev?.addedAt || new Date().toISOString(), addedBy: prev?.addedBy || req.user.id});
822
+ }
823
+ // An editor cannot take the owner's rights away from another editor - only the owner removes editors.
824
+ if (access !== 'owner') {
825
+ for (const p of doc.shares || []) {
826
+ if (p.role === 'editor' && !shares.some(s => s.userId === p.userId && s.role === 'editor')) {
827
+ return reply.code(403).send({error: 'Only the owner can remove an editor.'});
828
+ }
829
+ }
830
+ }
831
+ // A restricted document (a members-only wiki page, 2.11) takes nobody new - owner included.
832
+ const restricted = registry.restrictionOf?.(doc) || '';
833
+ let added = [];
834
+ const updated = await mutateDoc(doc.id, (cur) => {
835
+ // Against the list as it is now, not as it was read above.
836
+ added = shares.filter(s => !(cur.shares || []).some(p => p.userId === s.userId));
837
+ if (restricted && added.length) return null;
838
+ return {...cur, shares};
839
+ });
840
+ if (restricted && added.length) return reply.code(403).send({error: `${restricted} No one new can be given access.`});
841
+
842
+ let notified = 0;
843
+ if (req.body?.notify && added.length) {
844
+ notified = await notifyShared(req, updated, added.map(s => known.get(s.userId)).filter(u => u?.email), String(req.body.message || '').slice(0, 2000));
845
+ }
846
+ return {...(await full(req, updated, access)), notified};
847
+ });
848
+
849
+ async function notifyShared(req, doc, recipients, message) {
850
+ const smtp = getConfig('site').smtp || {};
851
+ const transport = await mailer.createTransport(smtp);
852
+ // No trusted address: the path alone, with a word, rather than a link built from a Host header.
853
+ const base = await linkBase(req);
854
+ const where = `/admin/#/plugins/docs/d/${doc.id}`;
855
+ const link = base ? `${base}${where}` : where;
856
+ if (!base) req.log.warn('[docs] Site URL is not set: the share email names the path, not a link');
857
+ const who = nameOf(req);
858
+ let n = 0;
859
+ for (const u of recipients) {
860
+ const html = `<p>${escapeHtml(who)} shared a document with you: <strong>${escapeHtml(doc.title)}</strong>.</p>`
861
+ + (message ? `<blockquote style="border-left:3px solid #ccc;margin:0;padding:.25em 1em">${escapeHtml(message).replace(/\n/g, '<br>')}</blockquote>` : '')
862
+ + (base ? `<p><a href="${escapeHtml(link)}">Open it in Docs</a></p>` : `<p>Open it in Docs on the site, at <code>${escapeHtml(where)}</code>.</p>`);
863
+ try {
864
+ await mailer.sendEmail(transport, {from: smtp.fromAddress, fromName: smtp.fromName, to: u.email,
865
+ subject: `${who} shared "${doc.title}" with you`, html,
866
+ text: `${who} shared a document with you: ${doc.title}\n${message ? `\n${message}\n` : ''}\n${link}`});
867
+ n++;
868
+ } catch (err) {
869
+ req.log.warn({err}, '[docs] share notification failed');
870
+ }
871
+ }
872
+ return n;
873
+ }
874
+
875
+ /** POST /documents/:id/link - turn the public link on (idempotent). */
876
+ fastify.post('/documents/:id/link', {preHandler: edit}, async (req, reply) => {
877
+ if (config.publicLinks === false) return reply.code(403).send({error: 'Public links are switched off on this site.'});
878
+ const hit = await load(req, reply, 'edit');
879
+ if (!hit) return;
880
+ const {doc, access} = hit;
881
+ if (doc.trashedAt) return reply.code(409).send({error: 'This document is in Trash.'});
882
+ const refusal = outwardRefusal(doc, req.user);
883
+ if (refusal) return reply.code(403).send({error: refusal});
884
+ const updated = await mutateDoc(doc.id, (cur) => (cur.publicLink?.token ? null
885
+ : {...cur, publicLink: {token: newLinkToken(), createdAt: new Date().toISOString(), createdBy: req.user.id}}));
886
+ return {...(await full(req, updated, access)), url: `${await linkBase(req)}/docs/s/${updated.publicLink.token}`};
887
+ });
888
+
889
+ /** DELETE /documents/:id/link - the link stops working at once. */
890
+ fastify.delete('/documents/:id/link', {preHandler: edit}, async (req, reply) => {
891
+ const hit = await load(req, reply, 'edit');
892
+ if (!hit) return;
893
+ if (onlyThroughExtension(hit.doc, req.user)) return reply.code(403).send({error: outwardRefusal(hit.doc, req.user)});
894
+ const updated = await mutateDoc(hit.doc.id, (cur) => ({...cur, publicLink: null}));
895
+ registry.emit('linkRemoved', updated, req.user);
896
+ return full(req, updated, hit.access);
897
+ });
898
+
899
+ /**
900
+ * POST /documents/:id/send {to, message, mode:'inline'|'link'}
901
+ * Email the document - its body, or a public link to it - to up to ten
902
+ * addresses. The owner and editors only: sending is publishing.
903
+ */
904
+ fastify.post('/documents/:id/send', {preHandler: edit, config: {rateLimit: {max: 10, timeWindow: '10 minutes'}}}, async (req, reply) => {
905
+ if (config.allowSend === false) return reply.code(403).send({error: 'Sending documents by email is switched off on this site.'});
906
+ const hit = await load(req, reply, 'edit');
907
+ if (!hit) return;
908
+ let {doc} = hit;
909
+ if (doc.trashedAt) return reply.code(409).send({error: 'This document is in Trash.'});
910
+ const refusal = outwardRefusal(doc, req.user);
911
+ if (refusal) return reply.code(403).send({error: refusal});
912
+ const to = (Array.isArray(req.body?.to) ? req.body.to : String(req.body?.to || '').split(/[,;\s]+/))
913
+ .map(s => String(s).trim()).filter(Boolean);
914
+ if (!to.length) return reply.code(400).send({error: 'Add at least one email address.'});
915
+ if (to.length > MAX_RECIPIENTS) return reply.code(400).send({error: `Up to ${MAX_RECIPIENTS} addresses at a time.`});
916
+ const bad = to.find(a => !EMAIL_RE.test(a));
917
+ if (bad) return reply.code(400).send({error: `"${bad.slice(0, 80)}" is not an email address.`});
918
+ // A daily allowance per person, so the site's mail cannot be used as a relay.
919
+ if (!allowance(req.user.id, to.length)) {
920
+ return reply.code(429).send({error: `You can email up to ${DAILY_RECIPIENTS} addresses a day from Docs. Try again tomorrow.`});
921
+ }
922
+ const mode = req.body?.mode === 'link' ? 'link' : 'inline';
923
+ const message = String(req.body?.message || '').slice(0, 2000);
924
+ const who = nameOf(req);
925
+
926
+ let url = '';
927
+ if (mode === 'link') {
928
+ if (config.publicLinks === false) return reply.code(403).send({error: 'Public links are switched off on this site.'});
929
+ if (!doc.publicLink?.token) {
930
+ doc = await mutateDoc(doc.id, (cur) => (cur.publicLink?.token ? null
931
+ : {...cur, publicLink: {token: newLinkToken(), createdAt: new Date().toISOString(), createdBy: req.user.id}}));
932
+ }
933
+ const base = await linkBase(req);
934
+ url = `${base}/docs/s/${doc.publicLink.token}`;
935
+ if (!base) req.log.warn('[docs] Site URL is not set: the sent link is a path, not an address');
936
+ }
937
+ const note = message ? `<p>${escapeHtml(message).replace(/\n/g, '<br>')}</p><hr>` : '';
938
+ // 2.13: a PDF document "in the email" is the file, attached.
939
+ const pdf = kindOf(doc) === 'pdf';
940
+ let attachments;
941
+ if (pdf && mode === 'inline') {
942
+ const buf = await readFileBytes(doc.id);
943
+ if (!buf) return reply.code(409).send({error: 'The file is missing, so it cannot be sent.'});
944
+ attachments = [{filename: fileName(doc.title, 'pdf'), content: buf, contentType: 'application/pdf'}];
945
+ }
946
+ const html = mode === 'link'
947
+ ? `${note}<p>${escapeHtml(who)} sent you a document: <strong>${escapeHtml(doc.title)}</strong>.</p><p><a href="${escapeHtml(url)}">Read it here</a></p>`
948
+ : pdf ? `${note}<p>${escapeHtml(who)} sent you a document: <strong>${escapeHtml(doc.title)}</strong>. It is attached as a PDF.</p>`
949
+ : `${note}<h1>${escapeHtml(doc.title)}</h1>${registry.publicHtml(withoutSuggestions(sanitizeDoc(doc.content)))}`;
950
+ const text = mode === 'link'
951
+ ? `${message ? message + '\n\n' : ''}${who} sent you a document: ${doc.title}\n${url}`
952
+ : pdf ? `${message ? message + '\n\n' : ''}${who} sent you a document: ${doc.title}. It is attached as a PDF.`
953
+ : `${message ? message + '\n\n---\n\n' : ''}${doc.title}\n\n${htmlToText(registry.publicHtml(withoutSuggestions(sanitizeDoc(doc.content))))}`;
954
+
955
+ const smtp = getConfig('site').smtp || {};
956
+ const transport = await mailer.createTransport(smtp);
957
+ const failed = [];
958
+ for (const address of to) {
959
+ try {
960
+ await mailer.sendEmail(transport, {from: smtp.fromAddress, fromName: smtp.fromName, to: address,
961
+ subject: mode === 'link' ? `${who} sent you "${doc.title}"` : doc.title, html, text, ...(attachments ? {attachments} : {})});
962
+ } catch (err) {
963
+ req.log.warn({err}, '[docs] send failed');
964
+ failed.push(address);
965
+ }
966
+ }
967
+ if (failed.length === to.length) return reply.code(502).send({error: 'The email could not be sent. Check the site\'s email settings.'});
968
+ return {ok: true, sent: to.length - failed.length, failed, url: url || undefined};
969
+ });
970
+
971
+ // ------------------------------------------------------------- versions
972
+
973
+ // History is for people who can change the document: a viewer should not read what the owner removed before sharing.
974
+ const noHistory = (reply) => reply.code(403).send({error: 'Only people who can edit this document can see its history.'});
975
+
976
+ fastify.get('/documents/:id/versions', {preHandler: read}, async (req, reply) => {
977
+ const hit = await load(req, reply);
978
+ if (!hit) return;
979
+ if (!canEdit(hit.access)) return noHistory(reply);
980
+ const {names} = await people();
981
+ return (await listVersions(hit.doc.id)).map(v => ({...v, savedByName: v.savedByName || (v.savedBy ? names.get(v.savedBy) || 'Unknown' : null)}));
982
+ });
983
+
984
+ fastify.get('/documents/:id/versions/:num', {preHandler: read}, async (req, reply) => {
985
+ const hit = await load(req, reply);
986
+ if (!hit) return;
987
+ if (!canEdit(hit.access)) return noHistory(reply);
988
+ const v = await readVersion(hit.doc.id, req.params.num);
989
+ if (!v) return reply.code(404).send({error: 'Version not found'});
990
+ return v;
991
+ });
992
+
993
+ /** GET /documents/:id/versions/:num/file - the PDF an earlier version was (2.13). Editors, as history is. */
994
+ fastify.get('/documents/:id/versions/:num/file', {preHandler: read}, async (req, reply) => {
995
+ const hit = await load(req, reply);
996
+ if (!hit) return;
997
+ if (!canEdit(hit.access)) return noHistory(reply);
998
+ const v = await readVersion(hit.doc.id, req.params.num);
999
+ if (!v || v.format !== 'pdf') return reply.code(404).send({error: 'Version not found'});
1000
+ const buf = await readVersionFile(hit.doc.id, v.num);
1001
+ if (!buf) return reply.code(404).send({error: 'That version kept no file.'});
1002
+ return sendPdf(reply, buf, `${hit.doc.title || 'Untitled'} (version ${v.num})`, {inline: req.query?.download !== '1'});
1003
+ });
1004
+
1005
+ fastify.post('/documents/:id/versions/:num/restore', {preHandler: edit}, async (req, reply) => {
1006
+ const hit = await load(req, reply, 'edit');
1007
+ if (!hit) return;
1008
+ const {doc, access} = hit;
1009
+ if (doc.trashedAt) return reply.code(409).send({error: 'This document is in Trash.'});
1010
+ const frozen = frozenFor(doc, req.user);
1011
+ if (frozen) return reply.code(403).send({error: frozen, frozen: true});
1012
+ const session = String(req.headers['x-docs-session'] || '');
1013
+ const v = await readVersion(doc.id, req.params.num);
1014
+ if (!v) return reply.code(404).send({error: 'Version not found'});
1015
+ const versionBytes = kindOf(doc) === 'pdf' && v.format === 'pdf' ? await readVersionFile(doc.id, v.num) : null;
1016
+ let refused = null;
1017
+ const restored = await mutateDoc(doc.id, async (cur) => {
1018
+ const held = lockOf(cur.id);
1019
+ if (held && held.session !== session) { refused = {error: `${held.name} is editing this document.`}; return null; }
1020
+ // Always kept: a restore must itself be undoable.
1021
+ await snapshotVersion(cur, Number(config.maxVersions) || 50, req.user.id);
1022
+ // 2.13: a PDF document's version is a file - put back as the file, with the facts it had. Its bytes are read
1023
+ // BEFORE the snapshot above trims history: restoring the oldest version at the cap used to delete that very file.
1024
+ if (kindOf(cur) === 'pdf') {
1025
+ if (v.format !== 'pdf' || !versionBytes) { refused = {error: 'That version kept no file to restore.', status: 409}; return null; }
1026
+ await writeFileBytes(cur.id, versionBytes);
1027
+ return {...cur, content: sanitizeDoc(v.content || ''), file: v.file || cur.file, wordCount: countWords(v.content || ''), updatedAt: new Date().toISOString(), updatedBy: req.user.id};
1028
+ }
1029
+ // A version from before 2.0 may be Markdown.
1030
+ const content = upgradeBody(v.content);
1031
+ return {...cur, content, wordCount: countWords(content), updatedAt: new Date().toISOString(), updatedBy: req.user.id};
1032
+ });
1033
+ if (refused) return reply.code(refused.status || 423).send({error: refused.error});
1034
+ registry.emit('restored', restored, req.user, v.num);
1035
+ return full(req, restored, access);
1036
+ });
1037
+
1038
+ // -------------------------------------------------------------- folders
1039
+
1040
+ fastify.get('/folders', {preHandler: read}, async (req) => {
1041
+ const folders = await foldersStore.readArray();
1042
+ return config.scope === 'user' ? folders.filter(f => f.userId === req.user.id) : folders;
1043
+ });
1044
+
1045
+ fastify.post('/folders', {preHandler: make}, async (req, reply) => {
1046
+ const {name, parentId = null} = req.body || {};
1047
+ if (!name || !String(name).trim()) return reply.code(400).send({error: 'Name is required'});
1048
+ if (parentId && !(await usableFolder(req, parentId))) return reply.code(400).send({error: 'No such folder'});
1049
+ const folder = {id: randomUUID(), name: String(name).trim().slice(0, 120), parentId: parentId || null, userId: req.user.id, createdAt: new Date().toISOString()};
1050
+ await foldersStore.appendItem(folder);
1051
+ return reply.code(201).send(folder);
1052
+ });
1053
+
1054
+ const mayChangeFolder = (req, f) => f.userId === req.user.id || (config.scope === 'all' && isAdmin(req));
1055
+
1056
+ fastify.put('/folders/:id', {preHandler: edit}, async (req, reply) => {
1057
+ const folder = (await foldersStore.readArray()).find(f => f.id === req.params.id);
1058
+ if (!folder || !mayChangeFolder(req, folder)) return reply.code(404).send({error: 'Folder not found'});
1059
+ const {name} = req.body || {};
1060
+ if (!name || !String(name).trim()) return reply.code(400).send({error: 'Name is required'});
1061
+ return foldersStore.updateItem(req.params.id, {name: String(name).trim().slice(0, 120)});
1062
+ });
1063
+
1064
+ fastify.delete('/folders/:id', {preHandler: drop}, async (req, reply) => {
1065
+ const folders = await foldersStore.readArray();
1066
+ const folder = folders.find(f => f.id === req.params.id);
1067
+ if (!folder || !mayChangeFolder(req, folder)) return reply.code(404).send({error: 'Folder not found'});
1068
+ // Its documents and subfolders move up a level; nothing is deleted with it.
1069
+ for (const d of await listDocs()) {
1070
+ if (d.folderId === folder.id) await mutateDoc(d.id, (cur) => (cur.folderId === folder.id ? {...cur, folderId: folder.parentId || null} : null));
1071
+ }
1072
+ for (const f of folders.filter(x => x.parentId === folder.id)) {
1073
+ await foldersStore.updateItem(f.id, {parentId: folder.parentId || null});
1074
+ }
1075
+ await foldersStore.deleteItem(folder.id);
1076
+ return {ok: true};
1077
+ });
1078
+
1079
+ // ------------------------------------------------------------ templates
1080
+
1081
+ fastify.get('/templates', {preHandler: read}, async (req) => {
1082
+ const templates = await templatesStore.readArray();
1083
+ const own = config.scope === 'user' ? templates.filter(t => t.userId === req.user.id) : templates;
1084
+ // Add-ons' ready-made templates (2.6), after the user's own: read only, sanitised like any body.
1085
+ const builtIn = registry.builtInTemplates().map(t => ({
1086
+ id: `x-${t.owner}-${t.id}`, name: String(t.name || 'Template').slice(0, 120), description: String(t.description || '').slice(0, 300),
1087
+ category: String(t.category || '').slice(0, 60), content: sanitizeDoc(t.content || ''), builtIn: true, source: t.label, userId: null, format: 'html'
1088
+ }));
1089
+ return [...own, ...builtIn];
1090
+ });
1091
+
1092
+ fastify.post('/templates', {preHandler: make}, async (req, reply) => {
1093
+ const {name, content = ''} = req.body || {};
1094
+ if (!name || !String(name).trim()) return reply.code(400).send({error: 'Name is required'});
1095
+ const template = {id: randomUUID(), name: String(name).trim().slice(0, 120), content: sanitizeDoc(content), userId: req.user.id, createdAt: new Date().toISOString()};
1096
+ await templatesStore.appendItem(template);
1097
+ return reply.code(201).send(template);
1098
+ });
1099
+
1100
+ fastify.delete('/templates/:id', {preHandler: drop}, async (req, reply) => {
1101
+ const template = (await templatesStore.readArray()).find(t => t.id === req.params.id);
1102
+ if (!template || !(template.userId === req.user.id || (config.scope === 'all' && isAdmin(req)))) {
1103
+ return reply.code(404).send({error: 'Template not found'});
1104
+ }
1105
+ await templatesStore.deleteItem(req.params.id);
1106
+ return {ok: true};
1107
+ });
1108
+ }