domma-cms 1.6.37 → 1.6.39

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 (82) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/package.json +2 -2
  4. package/plugins/_lib/admin/mail/mail.css +1 -1
  5. package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
  6. package/plugins/_lib/admin/mail/reader-view.js +126 -13
  7. package/plugins/_lib/mail/accounts.js +548 -225
  8. package/plugins/_lib/mail/imapPool.js +63 -13
  9. package/plugins/_lib/mail/oauth.js +453 -0
  10. package/plugins/_lib/mail/oauthRoutes.js +294 -0
  11. package/plugins/_lib/mail/readRoutes.js +23 -2
  12. package/plugins/_lib/mail/scheduler.js +30 -6
  13. package/plugins/blog/CLAUDE.md +11 -1
  14. package/plugins/blog/lib/cache.js +43 -0
  15. package/plugins/blog/lib/page.js +28 -9
  16. package/plugins/blog/plugin.js +14 -13
  17. package/plugins/blog/plugin.json +1 -1
  18. package/plugins/blog/plugin.public.js +1 -1
  19. package/plugins/blog/tests/cache.test.js +65 -0
  20. package/plugins/booking/CLAUDE.md +14 -2
  21. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  22. package/plugins/booking/admin/views/diary.js +153 -152
  23. package/plugins/booking/admin/views/people.js +64 -66
  24. package/plugins/booking/admin/views/settings.js +31 -32
  25. package/plugins/booking/plugin.json +1 -1
  26. package/plugins/booking/tests/admin-diary.test.js +9 -1
  27. package/plugins/feedback/CLAUDE.md +62 -0
  28. package/plugins/feedback/admin/css/index.css +16 -1
  29. package/plugins/feedback/admin/lib/kit.js +106 -0
  30. package/plugins/feedback/admin/lib/settings.js +107 -0
  31. package/plugins/feedback/admin/views/feedback.js +4 -1
  32. package/plugins/feedback/admin/views/inbox.js +11 -2
  33. package/plugins/feedback/admin/views/send.js +74 -7
  34. package/plugins/feedback/docs/guide.md +9 -9
  35. package/plugins/feedback/lib/attachments.js +133 -0
  36. package/plugins/feedback/lib/cache.js +43 -0
  37. package/plugins/feedback/lib/receiver.js +36 -6
  38. package/plugins/feedback/lib/sender.js +89 -6
  39. package/plugins/feedback/lib/shape.js +111 -6
  40. package/plugins/feedback/plugin.js +173 -17
  41. package/plugins/feedback/plugin.json +2 -2
  42. package/plugins/feedback/tests/api.test.js +125 -3
  43. package/plugins/feedback/tests/settings.test.js +59 -0
  44. package/plugins/feedback/tests/shape.test.js +87 -1
  45. package/plugins/free-tier.lock.json +74 -62
  46. package/plugins/mail-reader/CLAUDE.md +15 -4
  47. package/plugins/mail-reader/plugin.json +1 -1
  48. package/plugins/shopping-cart/CLAUDE.md +35 -2
  49. package/plugins/shopping-cart/admin/css/index.css +3 -0
  50. package/plugins/shopping-cart/admin/views/orders.js +75 -23
  51. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  52. package/plugins/shopping-cart/docs/guide.md +7 -3
  53. package/plugins/shopping-cart/lib/payments.js +15 -2
  54. package/plugins/shopping-cart/lib/refunds.js +51 -0
  55. package/plugins/shopping-cart/lib/sales.js +15 -8
  56. package/plugins/shopping-cart/lib/shop.js +38 -3
  57. package/plugins/shopping-cart/plugin.js +47 -19
  58. package/plugins/shopping-cart/plugin.json +2 -2
  59. package/plugins/shopping-cart/tests/api.test.js +52 -0
  60. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  61. package/plugins/shopping-cart/tests/pure.test.js +16 -0
  62. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  63. package/plugins/support/CLAUDE.md +58 -0
  64. package/plugins/support/admin/css/index.css +16 -1
  65. package/plugins/support/admin/lib/kit.js +192 -8
  66. package/plugins/support/admin/lib/settings.js +123 -0
  67. package/plugins/support/admin/views/desk.js +55 -12
  68. package/plugins/support/admin/views/inbox.js +17 -4
  69. package/plugins/support/admin/views/support.js +4 -1
  70. package/plugins/support/docs/guide.md +7 -9
  71. package/plugins/support/lib/attachments.js +133 -0
  72. package/plugins/support/lib/cache.js +43 -0
  73. package/plugins/support/lib/receiver.js +45 -10
  74. package/plugins/support/lib/sender.js +96 -10
  75. package/plugins/support/lib/shape.js +153 -12
  76. package/plugins/support/plugin.js +159 -29
  77. package/plugins/support/plugin.json +2 -2
  78. package/plugins/support/tests/api.test.js +125 -3
  79. package/plugins/support/tests/settings.test.js +68 -0
  80. package/plugins/support/tests/shape.test.js +76 -2
  81. package/server/routes/outreach-public.js +2 -2
  82. package/server/services/request-log.js +3 -1
@@ -69,10 +69,29 @@ export function createOutbox(dataDir, name = 'outbox.json') {
69
69
  const s = read();
70
70
  s.seen[id] = replyAt;
71
71
  write(s);
72
+ },
73
+ /**
74
+ * The manager's full list of this site's reports: a SENT report it no longer has was deleted there,
75
+ * so this site forgets it too - its copy of the text, and (on the next prune) its files.
76
+ * @returns {number} how many were forgotten
77
+ */
78
+ forgetDropped(remote) {
79
+ if (!Array.isArray(remote)) return 0;
80
+ const s = read();
81
+ const keep = s.items.filter(i => !droppedByManager(i, remote));
82
+ const gone = s.items.length - keep.length;
83
+ if (gone) { s.items = keep; write(s); }
84
+ return gone;
72
85
  }
73
86
  };
74
87
  }
75
88
 
89
+ /** A sent report the manager's list (complete, for this site) no longer holds - deleted there. PURE. */
90
+ export function droppedByManager(item, remote) {
91
+ if (item?.state !== 'sent' || !Array.isArray(remote)) return false;
92
+ return !remote.some(r => r.ref === item.ref);
93
+ }
94
+
76
95
  export const newRef = () => randomUUID();
77
96
 
78
97
  /**
@@ -99,20 +118,81 @@ export function managerApi(manager, fetchImpl = globalThis.fetch) {
99
118
  return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
100
119
  }
101
120
  };
121
+ /** One attached file's bytes, after its report: raw, so the JSON route's 32 KB limit never applies to them. */
122
+ const attach = async (reportId, attId, buffer) => {
123
+ try {
124
+ const res = await fetchImpl(`${manager.url}/api/plugins/feedback/fleet/attach/${encodeURIComponent(reportId)}/${encodeURIComponent(attId)}`, {
125
+ method: 'POST',
126
+ headers: {authorization: `Bearer ${manager.token}`, 'content-type': 'application/octet-stream'},
127
+ body: buffer,
128
+ signal: AbortSignal.timeout(30_000)
129
+ });
130
+ let json = null;
131
+ try { json = await res.json(); } catch { /* not JSON */ }
132
+ if (res.status >= 200 && res.status < 300) return {ok: true, status: res.status, body: json};
133
+ return {ok: false, status: res.status, error: json?.error || `The Domma server answered ${res.status}.`};
134
+ } catch (err) {
135
+ return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
136
+ }
137
+ };
102
138
  return {
103
139
  submit: (payload) => call('POST', 'submit', payload),
104
- mine: () => call('GET', 'mine')
140
+ mine: () => call('GET', 'mine'),
141
+ attach
105
142
  };
106
143
  }
107
144
 
145
+ /** Statuses that mean "try again later"; any other 4xx means the manager will never take it. */
146
+ const RETRY = new Set([401, 408, 429]);
147
+
148
+ /**
149
+ * The files of reports that have arrived but whose files have not all followed.
150
+ * A file the manager refuses for good (400/404/413) is not tried again; a file
151
+ * this site no longer has is given up on too - the report itself is there.
152
+ */
153
+ async function sendFiles(outbox, api, files) {
154
+ let error = '';
155
+ for (const item of outbox.all().filter(i => i.state === 'sent' && i.remoteId && (i.payload?.attachments || []).length)) {
156
+ const done = new Set([...(item.filesSent || []), ...(item.filesRefused || [])]);
157
+ for (const a of item.payload.attachments.filter(x => !done.has(x.id))) {
158
+ const buffer = files?.read(a);
159
+ if (!buffer) {
160
+ item.filesRefused = [...(item.filesRefused || []), a.id];
161
+ outbox.mark(item.ref, {filesRefused: item.filesRefused});
162
+ continue;
163
+ }
164
+ const r = await api.attach(item.remoteId, a.id, buffer);
165
+ if (r.ok) {
166
+ item.filesSent = [...(item.filesSent || []), a.id];
167
+ outbox.mark(item.ref, {filesSent: item.filesSent});
168
+ } else {
169
+ error = r.error || 'A file was not sent.';
170
+ if (r.status >= 400 && r.status < 500 && !RETRY.has(r.status)) {
171
+ item.filesRefused = [...(item.filesRefused || []), a.id];
172
+ outbox.mark(item.ref, {filesRefused: item.filesRefused});
173
+ }
174
+ }
175
+ }
176
+ }
177
+ return error;
178
+ }
179
+
108
180
  /**
109
- * Send whatever is waiting. A report the manager already has (a retry after a
110
- * timeout that did arrive) comes back with the same id, so this is safe to
111
- * repeat.
181
+ * Send whatever is waiting, then any attached files whose report has arrived.
182
+ * A report the manager already has (a retry after a timeout that did arrive)
183
+ * comes back with the same id, and a file it already has answers 200, so this
184
+ * is safe to repeat.
112
185
  *
113
186
  * @returns {Promise<{sent: number, waiting: number, error: string}>}
114
187
  */
115
- export async function flush(outbox, api) {
188
+ export function flush(outbox, api, files = null) {
189
+ // One flush at a time per outbox (as Support's): the timer, /sent and /send can all ask at
190
+ // once, and two flushes would POST the same waiting report - and its files - twice.
191
+ if (!outbox.flushing) outbox.flushing = flushOnce(outbox, api, files).finally(() => { outbox.flushing = null; });
192
+ return outbox.flushing;
193
+ }
194
+
195
+ async function flushOnce(outbox, api, files) {
116
196
  let sent = 0;
117
197
  let error = '';
118
198
  for (const item of outbox.all().filter(i => i.state === 'waiting')) {
@@ -127,7 +207,9 @@ export async function flush(outbox, api) {
127
207
  if (r.status === 400) outbox.mark(item.ref, {state: 'refused'});
128
208
  }
129
209
  }
130
- return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error};
210
+ // Then the files, each on its own request once its report has an id on the manager.
211
+ const fileError = api.attach ? await sendFiles(outbox, api, files) : '';
212
+ return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error: error || fileError};
131
213
  }
132
214
 
133
215
  /**
@@ -152,6 +234,7 @@ export function mergeReports(outboxItems, remote, seen = {}) {
152
234
  const p = i.payload || {};
153
235
  out.push({
154
236
  id: '', ref: i.ref, type: p.type, title: p.title, description: p.description, where: p.where || '', severity: p.severity || '',
237
+ attachments: (p.attachments || []).map(a => ({...a, stored: false})),
155
238
  userName: p.user?.name || '', status: i.state === 'refused' ? 'refused' : 'waiting',
156
239
  statusLabel: i.state === 'refused' ? 'Not accepted' : 'Waiting to send',
157
240
  reply: '', replyAt: '', issueKey: '', issueStatus: '', createdAt: i.createdAt, updatedAt: i.createdAt,
@@ -43,7 +43,69 @@ export const typeLabel = (key) => TYPES.find(t => t.key === key)?.short ?? key;
43
43
  /** Longest a field may be. The route's bodyLimit (32 KB) is the outer wall. */
44
44
  export const LIMITS = {title: 140, description: 8000, where: 300, reply: 8000, notes: 8000};
45
45
 
46
- const text = (v, max) => String(v ?? '').replace(/\u0000/g, '').replace(/\r\n?/g, '\n').trim().slice(0, max);
46
+ // Control characters go (all but tab and newline): each is 6 bytes once JSON-escaped, so 8000 of them
47
+ // would pass here and then be refused by the manager's 32 KB body limit, forever.
48
+ const text = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '').trim().slice(0, max);
49
+
50
+ // ---------------------------------------------------------------------------------
51
+ // Attachments: a screenshot is worth most of a bug report
52
+ // ---------------------------------------------------------------------------------
53
+
54
+ /** How many files a report may carry and how big each may be. The files travel one per request, after the report. */
55
+ export const ATTACHMENTS = {max: 3, maxBytes: 2 * 1024 * 1024};
56
+
57
+ /** What may be attached: mime -> the extension the file is kept under. No SVG or HTML - they can carry scripts. */
58
+ export const ATTACHMENT_TYPES = {
59
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf', 'text/plain': 'txt'
60
+ };
61
+
62
+ /** Ids are made by the server (randomUUID) and become file names, so nothing else gets through. */
63
+ const ATTACHMENT_ID = /^[A-Za-z0-9-]{8,64}$/;
64
+
65
+ export const extOf = (type) => ATTACHMENT_TYPES[String(type || '').toLowerCase()] || 'bin';
66
+
67
+ /**
68
+ * A file name fit to show and to put in a Content-Disposition header: the last path
69
+ * segment, odd characters replaced, never empty.
70
+ */
71
+ export function safeFileName(name, ext = 'bin') {
72
+ const base = String(name ?? '').split(/[\\/]/).pop().replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[. ]+/, '').trim().slice(0, 120);
73
+ return base || `file.${ext}`;
74
+ }
75
+
76
+ /**
77
+ * One attachment's description, cleaned - or null when it is not one we take.
78
+ * The bytes are checked against `size` when they arrive.
79
+ *
80
+ * @param {object} a - {id, name, type, size}
81
+ * @returns {{id: string, name: string, type: string, size: number}|null}
82
+ */
83
+ export function cleanAttachment(a = {}) {
84
+ if (!a || typeof a !== 'object') return null;
85
+ const type = String(a.type || '').toLowerCase().split(';')[0].trim();
86
+ const id = String(a.id || '');
87
+ const size = Number(a.size);
88
+ if (!ATTACHMENT_TYPES[type] || !ATTACHMENT_ID.test(id) || !Number.isInteger(size) || size < 1 || size > ATTACHMENTS.maxBytes) return null;
89
+ return {id, name: safeFileName(a.name, ATTACHMENT_TYPES[type]), type, size};
90
+ }
91
+
92
+ /** A list of them: the bad ones dropped, duplicates once, at most ATTACHMENTS.max. */
93
+ export function cleanAttachments(list) {
94
+ if (!Array.isArray(list)) return [];
95
+ const out = [];
96
+ const seen = new Set();
97
+ for (const a of list) {
98
+ const c = cleanAttachment(a);
99
+ if (!c || seen.has(c.id)) continue;
100
+ seen.add(c.id);
101
+ out.push(c);
102
+ if (out.length >= ATTACHMENTS.max) break;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** The sender-safe view of a stored attachment: what it is and whether the manager has the bytes yet. */
108
+ export const attachmentView = (a) => ({id: a.id, name: a.name, type: a.type, size: a.size, stored: Boolean(a.stored)});
47
109
 
48
110
  /**
49
111
  * What a site admin typed, cleaned. Never carries who sent it - that comes
@@ -59,7 +121,8 @@ export function cleanSubmission(body = {}) {
59
121
  if (!title) return {error: 'Give it a short title.'};
60
122
  if (!description) return {error: 'Say a little more in the description.'};
61
123
  const severity = type === 'bug' && SEVERITY_KEYS.includes(body.severity) ? body.severity : '';
62
- return {report: {type, title, description, where: text(body.where, LIMITS.where), severity}};
124
+ // Descriptions of files, not the files: those follow one per request once the report is in.
125
+ return {report: {type, title, description, where: text(body.where, LIMITS.where), severity, attachments: cleanAttachments(body.attachments)}};
63
126
  }
64
127
 
65
128
  /**
@@ -96,6 +159,8 @@ export function receivedReport(payload = {}, slug) {
96
159
  return {
97
160
  data: {
98
161
  ...report,
162
+ // Announced by the report; `stored` turns true as each file arrives (/fleet/attach).
163
+ attachments: report.attachments.map(a => ({...a, stored: false})),
99
164
  site: slug,
100
165
  ref: text(payload.ref, 64),
101
166
  cmsVersion: text(payload.cmsVersion, 20),
@@ -131,6 +196,7 @@ export function senderView(entry) {
131
196
  description: d.description,
132
197
  where: d.where || '',
133
198
  severity: d.severity || '',
199
+ attachments: (d.attachments || []).map(attachmentView),
134
200
  userName: d.userName || '',
135
201
  status: d.status,
136
202
  statusLabel: statusLabel(d.status),
@@ -181,20 +247,57 @@ export function cleanUpdate(body = {}, current = {}) {
181
247
  * limiter exempts loopback, which is where every site calls from, so this is
182
248
  * the only brake.
183
249
  *
250
+ * The limits may also come with each call (`limits`), so a setting changed in
251
+ * the cog applies to the next request rather than the next restart.
252
+ *
184
253
  * @param {{max?: number, windowMs?: number}} [opts]
185
- * @returns {(key: string, now?: number) => boolean} true when allowed
254
+ * @returns {(key: string, now?: number, limits?: {max?: number, windowMs?: number}) => boolean} true when allowed
186
255
  */
187
256
  export function createThrottle({max = 10, windowMs = 10 * 60_000} = {}) {
188
257
  const seen = new Map();
189
- return (key, now = Date.now()) => {
190
- const recent = (seen.get(key) || []).filter(t => now - t < windowMs);
191
- if (recent.length >= max) { seen.set(key, recent); return false; }
258
+ return (key, now = Date.now(), limits = {}) => {
259
+ const cap = Number(limits.max) > 0 ? Number(limits.max) : max;
260
+ const window = Number(limits.windowMs) > 0 ? Number(limits.windowMs) : windowMs;
261
+ const recent = (seen.get(key) || []).filter(t => now - t < window);
262
+ if (recent.length >= cap) { seen.set(key, recent); return false; }
192
263
  recent.push(now);
193
264
  seen.set(key, recent);
194
265
  return true;
195
266
  };
196
267
  }
197
268
 
269
+ /**
270
+ * The settings each side owns, with the whole-number range each may take.
271
+ * `fallback` repeats config.js so this stays pure (no import of the defaults).
272
+ */
273
+ export const SETTING_RULES = {
274
+ syncMinutes: {min: 5, max: 1440, fallback: 60},
275
+ perSiteMax: {min: 1, max: 1000, fallback: 10},
276
+ perSiteMinutes: {min: 1, max: 1440, fallback: 10}
277
+ };
278
+ export const SIDE_SETTINGS = {sender: ['syncMinutes'], receiver: ['perSiteMax', 'perSiteMinutes']};
279
+
280
+ /**
281
+ * One side's settings from whatever was saved (or sent): only that side's keys,
282
+ * each a whole number in range, else what is current, else the default.
283
+ *
284
+ * @param {object} raw what the cog sent, or the merged config
285
+ * @param {object} [current] the settings as they stand
286
+ * @param {'sender'|'receiver'} [side]
287
+ * @returns {object}
288
+ */
289
+ export function cleanSettings(raw, current = {}, side = 'sender') {
290
+ const r = raw && typeof raw === 'object' ? raw : {};
291
+ const cur = current && typeof current === 'object' ? current : {};
292
+ const out = {};
293
+ for (const k of SIDE_SETTINGS[side] || []) {
294
+ const rule = SETTING_RULES[k];
295
+ const pick = (v) => { const n = Number(v); return v !== '' && v != null && Number.isInteger(n) && n >= rule.min && n <= rule.max ? n : null; };
296
+ out[k] = pick(r[k]) ?? pick(cur[k]) ?? rule.fallback;
297
+ }
298
+ return out;
299
+ }
300
+
198
301
  /**
199
302
  * The description a Waypoint issue gets from a report: the words, then where
200
303
  * they came from.
@@ -207,5 +310,7 @@ export function issueDescription(d = {}) {
207
310
  if (d.cmsVersion) lines.push(`Domma CMS ${d.cmsVersion}`);
208
311
  if (d.where) lines.push(`Where: ${d.where}`);
209
312
  if (d.severity) lines.push(`Severity: ${d.severity}`);
313
+ const files = (d.attachments || []).map(a => a.name);
314
+ if (files.length) lines.push(`Attachments (in the Feedback inbox): ${files.join(', ')}`);
210
315
  return lines.join('\n');
211
316
  }
@@ -10,30 +10,37 @@
10
10
  * GET /sent this site's reports (sends any waiting first)
11
11
  * POST /send a new report: kept here, then sent
12
12
  * PUT /sent/:id/seen a reply has been read
13
+ * POST /attachments a file for the report being written (multipart, kept here)
14
+ * GET /attachments/:id one of this site's attached files
13
15
  *
14
16
  * receiver (the manager - Site Manager is installed beside it)
15
17
  * POST /fleet/submit a site's report (Bearer fleet token)
18
+ * POST /fleet/attach/:id/:att one of its announced files, raw bytes (Bearer fleet token)
16
19
  * GET /fleet/mine that site's reports, sender-safe fields only
17
20
  * GET /mode · GET /count 'new' reports
18
21
  * POST /send · GET /sent · PUT /sent/:id/seen
19
22
  * the manager's OWN feedback (level-0 role only):
20
23
  * written straight into the inbox as site `_manager`
21
24
  * GET /reports · GET /reports/:id · PUT /reports/:id · DELETE /reports/:id
25
+ * GET /reports/:id/attachments/:att a report's file
22
26
  * GET /waypoint is Waypoint Pro here, and its projects
23
27
  * POST /reports/:id/promote make a Waypoint issue from a report
24
28
  *
25
29
  * Nothing the browser sends says who sent a report or which site it came from:
26
30
  * the user is the session's, the site is the one the fleet token names.
27
31
  */
32
+ import {randomUUID} from 'node:crypto';
28
33
  import fs from 'node:fs';
29
34
  import path from 'node:path';
30
35
  import {fileURLToPath} from 'node:url';
31
36
  import defaults from './config.js';
32
37
  import {
33
- SEVERITIES, STATUSES, TYPES, buildPayload, cleanSubmission, cleanUpdate, createThrottle, issueDescription, senderView
38
+ ATTACHMENTS, SEVERITIES, STATUSES, TYPES, buildPayload, cleanAttachment, cleanSubmission, cleanUpdate, cleanSettings, createThrottle, issueDescription, senderView
34
39
  } from './lib/shape.js';
40
+ import {createStore, readUpload, sendFile} from './lib/attachments.js';
35
41
  import {createOutbox, flush, managerApi, managerOf, mergeReports, newRef} from './lib/sender.js';
36
42
  import {getEffectiveLevel} from '../../server/services/userRoles.js';
43
+ import {getPluginSettings, getPluginStates, savePluginState} from '../../server/services/plugins.js';
37
44
 
38
45
  const HERE = path.dirname(fileURLToPath(import.meta.url));
39
46
  const NAME = 'feedback';
@@ -53,9 +60,19 @@ export default async function feedbackPlugin(fastify, options) {
53
60
  const {authenticate, requirePermission} = options.auth;
54
61
  const hooks = options.hooks || {};
55
62
  const can = (action) => ({preHandler: [authenticate, requirePermission(NAME, action)]});
56
- const settings = {...defaults, ...(options.settings || {})};
57
63
  const receiverMod = await import('./lib/receiver.js');
58
64
  const mode = options.mode || (receiverMod.isManager() ? 'receiver' : 'sender');
65
+ // Each side's settings are read when used (1.1.7), not once at boot: the cog's
66
+ // save, and Marketplace > Configure, take effect without a restart. Only this
67
+ // side's keys (lib/shape.js SIDE_SETTINGS). Tests inject a reader and writer.
68
+ const side = mode === 'receiver' ? 'receiver' : 'sender';
69
+ const readSettings = typeof options.readSettings === 'function' ? options.readSettings
70
+ : async () => ({...defaults, ...(await getPluginSettings(NAME).catch(() => options.settings || {}))});
71
+ const writeSettings = typeof options.writeSettings === 'function' ? options.writeSettings
72
+ // Merged over what is stored: the object holds the other side's keys too.
73
+ : (next) => savePluginState(NAME, {settings: {...(getPluginStates()[NAME]?.settings || {}), ...next}});
74
+ const liveSettings = async () => cleanSettings(await readSettings().catch(() => ({})), defaults, side);
75
+ const settingsHooks = {saved: () => {}};
59
76
  const notifyOk = typeof hooks.notify === 'function';
60
77
  if (typeof hooks.registerNotificationSource === 'function') {
61
78
  try {
@@ -69,28 +86,58 @@ export default async function feedbackPlugin(fastify, options) {
69
86
  } catch (err) { fastify.log.warn(`[feedback] notification source: ${err.message}`); }
70
87
  }
71
88
 
72
- fastify.get('/meta', can('send'), async () => ({types: TYPES, statuses: STATUSES, severities: SEVERITIES}));
89
+ // On the manager the inbox needs it, so `manage` is enough there (a manager role with only
90
+ // `manage` used to get 403 here, and the whole screen failed to load).
91
+ fastify.get('/meta', can(mode === 'receiver' ? 'manage' : 'send'), async () => ({types: TYPES, statuses: STATUSES, severities: SEVERITIES,
92
+ attachments: {max: ATTACHMENTS.max, maxBytes: ATTACHMENTS.maxBytes}}));
93
+
94
+ // ---- The settings cog (1.1.7) ----
95
+ // A site's sync interval is the site administrators' (requireAdmin); the manager's
96
+ // per-site limits are the inbox's (`manage`).
97
+ const settingsGuard = mode === 'receiver' ? can('manage')
98
+ : {preHandler: [authenticate, typeof options.auth.requireAdmin === 'function' ? options.auth.requireAdmin : requirePermission(NAME, 'send')]};
99
+ fastify.get('/settings', settingsGuard, async () => ({side, settings: await liveSettings()}));
100
+ fastify.put('/settings', settingsGuard, async (request) => {
101
+ const next = cleanSettings(request.body, await liveSettings(), side);
102
+ await writeSettings(next);
103
+ settingsHooks.saved(next);
104
+ return {side, settings: next};
105
+ });
73
106
 
74
- if (mode === 'receiver') await receiverRoutes(fastify, {can, authenticate, requirePermission, hooks, notifyOk, receiverMod, settings, options});
75
- else await senderRoutes(fastify, {can, hooks, notifyOk, settings, options});
107
+ if (mode === 'receiver') await receiverRoutes(fastify, {can, authenticate, requirePermission, hooks, notifyOk, receiverMod, liveSettings, options});
108
+ else await senderRoutes(fastify, {can, hooks, notifyOk, liveSettings, settingsHooks, options});
76
109
  }
77
110
 
78
111
  // ---------------------------------------------------------------------------------
79
112
  // Sender
80
113
  // ---------------------------------------------------------------------------------
81
114
 
82
- async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options}) {
115
+ async function senderRoutes(fastify, {can, hooks, notifyOk, liveSettings, settingsHooks, options}) {
83
116
  const env = options.env || process.env;
84
117
  const manager = managerOf(env);
85
- const outbox = createOutbox(options.dataDir || path.join(HERE, 'data'));
118
+ const dataDir = options.dataDir || path.join(HERE, 'data');
119
+ const outbox = createOutbox(dataDir);
86
120
  const api = manager ? managerApi(manager, options.fetch) : null;
121
+ // Files attached to reports, kept here as well: they go after their report, and a report
122
+ // waiting to send must still have them. Never under the public media folder - they are
123
+ // screenshots of this site's admin.
124
+ const files = createStore(path.join(dataDir, 'attachments'));
125
+ /** Files picked in a panel that was then closed: gone after a day. Those a report names stay. */
126
+ const pruneFiles = () => {
127
+ try {
128
+ const keep = new Set(outbox.all().flatMap(i => (i.payload?.attachments || []).map(a => a.id)));
129
+ files.prune({keep});
130
+ } catch { /* tidying only */ }
131
+ };
87
132
 
88
133
  /** Send what waits, then read the manager's view; tell admins about replies they have not heard of. */
89
134
  async function sync() {
90
135
  if (!api) return {reports: mergeReports(outbox.all(), null, outbox.seen()), reachable: false, error: ''};
91
- const pushed = await flush(outbox, api);
136
+ const pushed = await flush(outbox, api, files);
92
137
  const mine = await api.mine();
93
138
  const remote = mine.ok ? (mine.body?.reports || []) : null;
139
+ // Deleted on the manager: gone here too, and its screenshots with the next prune (1.1.8).
140
+ if (remote) outbox.forgetDropped(remote);
94
141
  const reports = mergeReports(outbox.all(), remote, outbox.seen());
95
142
  // Keep the last-known reply on the outbox copy: the badge reads that, never the manager.
96
143
  if (remote) for (const r of reports) if (!r.local && r.ref) outbox.mark(r.ref, {reply: r.reply ? '1' : '', replyAt: r.replyAt, remoteId: r.id});
@@ -104,12 +151,24 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options})
104
151
  }
105
152
 
106
153
  // The hourly sync: waiting reports go, new replies are noticed. Off in tests.
154
+ // Each wait asks for syncMinutes again, and a save re-arms it, so a changed
155
+ // interval counts from the save rather than from the next restart.
107
156
  if (api && options.timer !== false) {
108
- const t = setInterval(() => { sync().catch(() => {}); }, Math.max(5, Number(settings.syncMinutes) || 60) * 60_000);
109
- t.unref?.();
157
+ let t = null;
158
+ let closed = false;
159
+ const arm = async () => {
160
+ clearTimeout(t);
161
+ if (closed) return;
162
+ const minutes = Math.max(5, Number((await liveSettings().catch(() => ({}))).syncMinutes) || 60);
163
+ if (closed) return;
164
+ t = setTimeout(() => { pruneFiles(); sync().catch(() => {}).finally(() => arm()); }, minutes * 60_000);
165
+ t.unref?.();
166
+ };
167
+ arm();
168
+ settingsHooks.saved = () => { arm(); };
110
169
  const first = setTimeout(() => { sync().catch(() => {}); }, 60_000);
111
170
  first.unref?.();
112
- fastify.addHook('onClose', async () => { clearInterval(t); clearTimeout(first); });
171
+ fastify.addHook('onClose', async () => { closed = true; clearTimeout(t); clearTimeout(first); });
113
172
  }
114
173
 
115
174
  fastify.get('/mode', can('send'), async () => ({mode: 'sender', connected: Boolean(manager), site: manager?.slug || ''}));
@@ -139,13 +198,32 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options})
139
198
  return {connected: Boolean(manager), reachable: r.reachable, error: r.error, reports: r.reports};
140
199
  });
141
200
 
201
+ fastify.post('/attachments', can('send'), async (request, reply) => {
202
+ if (!manager) return reply.code(409).send({error: 'This site is not connected to Domma, so feedback cannot be sent from here.'});
203
+ const up = await readUpload(request);
204
+ if (up.error) return reply.code(up.code || 400).send({error: up.error});
205
+ const saved = files.save({id: randomUUID(), name: up.file.name, type: up.file.type, size: up.file.size}, up.file.buffer);
206
+ return reply.code(201).send(saved);
207
+ });
208
+
209
+ fastify.get('/attachments/:id', can('send'), async (request, reply) => {
210
+ const a = files.meta(String(request.params.id));
211
+ const buffer = a && files.read(a);
212
+ if (!buffer) return reply.code(404).send({error: 'That file is not here.'});
213
+ return sendFile(reply, a, buffer);
214
+ });
215
+
142
216
  fastify.post('/send', can('send'), async (request, reply) => {
143
217
  if (!manager) return reply.code(409).send({error: 'This site is not connected to Domma, so feedback cannot be sent from here.'});
144
218
  const {report, error} = cleanSubmission(request.body || {});
145
219
  if (error) return reply.code(400).send({error});
220
+ // What a file is comes from what was uploaded here, never from the form's say-so.
221
+ const attachments = report.attachments.map(a => files.meta(a.id));
222
+ if (attachments.some(a => !a)) return reply.code(400).send({error: 'An attached file is no longer here - attach it again.'});
223
+ report.attachments = attachments;
146
224
  const payload = buildPayload({report, user: request.user, cmsVersion: cmsVersion(), site: manager.slug, ref: newRef()});
147
225
  outbox.add(payload);
148
- const r = await flush(outbox, api);
226
+ const r = await flush(outbox, api, files);
149
227
  const item = outbox.all().find(i => i.ref === payload.ref);
150
228
  return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
151
229
  message: item?.state === 'sent' ? 'Sent to Domma. Thank you.' : `Kept here and sent as soon as Domma answers. (${r.error || 'not sent yet'})`});
@@ -165,7 +243,7 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options})
165
243
  /** Level 0 - the most senior role, whatever it is called. Roles are data, never names in code. */
166
244
  const isTopLevel = (user) => { try { return getEffectiveLevel(user) === 0; } catch { return false; } };
167
245
 
168
- async function receiverRoutes(fastify, {can, authenticate, requirePermission, hooks, notifyOk, receiverMod, settings, options}) {
246
+ async function receiverRoutes(fastify, {can, authenticate, requirePermission, hooks, notifyOk, receiverMod, liveSettings, options}) {
169
247
  const rx = receiverMod;
170
248
  const slugOf = options.slugFromToken || rx.slugFromToken;
171
249
  const siteNames = options.siteNames || rx.siteNames;
@@ -176,8 +254,28 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
176
254
  if (!isTopLevel(request.user)) return reply.code(403).send({error: 'Only the most senior role on this server can send feedback from here.'});
177
255
  };
178
256
  const own = {preHandler: [authenticate, requirePermission('feedback', 'send'), topOnly]};
179
- const ownSeen = createOutbox(options.dataDir || path.join(HERE, 'data'), 'own.json');
180
- const allowed = createThrottle({max: Number(settings.perSiteMax) || 10, windowMs: (Number(settings.perSiteMinutes) || 10) * 60_000});
257
+ const dataDir = options.dataDir || path.join(HERE, 'data');
258
+ const ownSeen = createOutbox(dataDir, 'own.json');
259
+ // The per-site brakes take their limits at each call (the cog changes them live).
260
+ const reportThrottle = createThrottle();
261
+ const fileThrottle = createThrottle();
262
+ const limits = async () => {
263
+ const s = await liveSettings().catch(() => ({}));
264
+ return {max: Number(s.perSiteMax) || 10, windowMs: (Number(s.perSiteMinutes) || 10) * 60_000};
265
+ };
266
+ const allowed = async (slug) => reportThrottle(slug, Date.now(), await limits());
267
+ // Files have their own brake: up to ATTACHMENTS.max per report allowed in the same window.
268
+ const allowedFile = async (slug) => {
269
+ const l = await limits();
270
+ return fileThrottle(slug, Date.now(), {max: l.max * ATTACHMENTS.max, windowMs: l.windowMs});
271
+ };
272
+ // A report's files live in data/attachments/<report id>/; the level-0 role's uploads wait in _staging.
273
+ const files = createStore(path.join(dataDir, 'attachments'));
274
+ const STAGING = '_staging';
275
+ // A site sends a file's bytes raw. Only inside this plugin's scope; the engine has no parser for it.
276
+ if (!fastify.hasContentTypeParser('application/octet-stream')) {
277
+ fastify.addContentTypeParser('application/octet-stream', {parseAs: 'buffer', bodyLimit: ATTACHMENTS.maxBytes + 1024}, (req, body, done) => done(null, body));
278
+ }
181
279
  try { await rx.ensureCollection(); } catch (err) { fastify.log.error(`[feedback] collection: ${err.message}`); }
182
280
 
183
281
  // Waypoint Pro, when it is loaded, calls this when a linked issue moves.
@@ -196,7 +294,7 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
196
294
  fastify.post('/fleet/submit', {bodyLimit: 32 * 1024}, async (request, reply) => {
197
295
  const slug = await slugOf(request.headers.authorization);
198
296
  if (!slug) return reply.code(401).send({error: 'Unknown site.'});
199
- if (!allowed(slug)) return reply.code(429).send({error: 'That is a lot of feedback at once - try again in a few minutes.'});
297
+ if (!await allowed(slug)) return reply.code(429).send({error: 'That is a lot of feedback at once - try again in a few minutes.'});
200
298
  const r = await rx.receive(slug, request.body || {});
201
299
  if (r.error) return reply.code(400).send({error: r.error});
202
300
  if (r.created && notifyOk) {
@@ -208,6 +306,24 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
208
306
  return reply.code(r.created ? 201 : 200).send({id: r.entry.id, status: r.entry.data.status});
209
307
  });
210
308
 
309
+ fastify.post('/fleet/attach/:id/:att', {bodyLimit: ATTACHMENTS.maxBytes + 1024}, async (request, reply) => {
310
+ const slug = await slugOf(request.headers.authorization);
311
+ if (!slug) return reply.code(401).send({error: 'Unknown site.'});
312
+ if (!await allowedFile(slug)) return reply.code(429).send({error: 'That is a lot of files at once - try again in a few minutes.'});
313
+ const e = await rx.getReport(String(request.params.id)).catch(() => null);
314
+ // Another site's report is "not found", never "not yours".
315
+ if (!e || e.data?.site !== slug) return reply.code(404).send({error: 'No such report.'});
316
+ // Only a file the report announced, at the size it announced, in the type it announced.
317
+ const a = cleanAttachment((e.data.attachments || []).find(x => x.id === String(request.params.att)));
318
+ if (!a) return reply.code(404).send({error: 'That report has no such file.'});
319
+ if ((e.data.attachments || []).find(x => x.id === a.id)?.stored) return {id: a.id, stored: true};
320
+ const body = request.body;
321
+ if (!Buffer.isBuffer(body) || body.length !== a.size) return reply.code(400).send({error: 'That file is not the size the report said.'});
322
+ files.save(a, body, e.id);
323
+ await rx.markStored(e.id, a.id);
324
+ return reply.code(201).send({id: a.id, stored: true});
325
+ });
326
+
211
327
  fastify.get('/fleet/mine', async (request, reply) => {
212
328
  const slug = await slugOf(request.headers.authorization);
213
329
  if (!slug) return reply.code(401).send({error: 'Unknown site.'});
@@ -244,13 +360,44 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
244
360
 
245
361
  // ---- The manager's own feedback: straight into the inbox, no HTTP round trip ----
246
362
 
363
+ fastify.post('/attachments', own, async (request, reply) => {
364
+ const up = await readUpload(request);
365
+ if (up.error) return reply.code(up.code || 400).send({error: up.error});
366
+ const saved = files.save({id: randomUUID(), name: up.file.name, type: up.file.type, size: up.file.size}, up.file.buffer, STAGING);
367
+ // Tidy anything picked and never filed while we are here.
368
+ try { files.prune({sub: STAGING}); } catch { /* tidying only */ }
369
+ return reply.code(201).send(saved);
370
+ });
371
+
372
+ /** One of the level-0 role's own files: still staged, or already in one of its reports. */
373
+ fastify.get('/attachments/:id', own, async (request, reply) => {
374
+ const id = String(request.params.id);
375
+ let a = files.meta(id, STAGING);
376
+ let buffer = a && files.read(a, STAGING);
377
+ if (!buffer) {
378
+ const e = (await rx.reportsOf(rx.MANAGER_SITE)).find(x => (x.data.attachments || []).some(y => y.id === id));
379
+ a = e && cleanAttachment(e.data.attachments.find(y => y.id === id));
380
+ buffer = a && files.read(a, e.id);
381
+ }
382
+ if (!buffer) return reply.code(404).send({error: 'That file is not here.'});
383
+ return sendFile(reply, a, buffer);
384
+ });
385
+
247
386
  fastify.post('/send', own, async (request, reply) => {
248
387
  const {report, error} = cleanSubmission(request.body || {});
249
388
  if (error) return reply.code(400).send({error});
250
- if (!allowed(rx.MANAGER_SITE)) return reply.code(429).send({error: 'That is a lot of feedback at once - try again in a few minutes.'});
389
+ const attachments = report.attachments.map(a => files.meta(a.id, STAGING));
390
+ if (attachments.some(a => !a)) return reply.code(400).send({error: 'An attached file is no longer here - attach it again.'});
391
+ report.attachments = attachments;
392
+ if (!await allowed(rx.MANAGER_SITE)) return reply.code(429).send({error: 'That is a lot of feedback at once - try again in a few minutes.'});
251
393
  const payload = buildPayload({report, user: request.user, cmsVersion: cmsVersion(), site: rx.MANAGER_SITE, ref: newRef()});
252
394
  const r = await rx.receive(rx.MANAGER_SITE, payload, 'manager');
253
395
  if (r.error) return reply.code(400).send({error: r.error});
396
+ // No travel: the staged files simply move into the report's folder.
397
+ for (const a of attachments) {
398
+ try { files.move(a, STAGING, r.entry.id); await rx.markStored(r.entry.id, a.id); }
399
+ catch (err) { fastify.log.warn(`[feedback] attachment ${a.id}: ${err.message}`); }
400
+ }
254
401
  return reply.code(201).send({id: r.entry.id, ref: payload.ref, state: 'sent', message: 'Filed in the inbox.'});
255
402
  });
256
403
 
@@ -299,10 +446,19 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
299
446
  return {id: next.id, ...next.data, createdAt: next.meta?.createdAt, updatedAt: next.meta?.updatedAt};
300
447
  });
301
448
 
449
+ fastify.get('/reports/:id/attachments/:att', can('manage'), async (request, reply) => {
450
+ const e = await rx.getReport(request.params.id);
451
+ const a = e && cleanAttachment((e.data.attachments || []).find(x => x.id === String(request.params.att)));
452
+ const buffer = a && files.read(a, e.id);
453
+ if (!buffer) return reply.code(404).send({error: 'That file has not arrived, or is gone.'});
454
+ return sendFile(reply, a, buffer);
455
+ });
456
+
302
457
  fastify.delete('/reports/:id', can('manage'), async (request, reply) => {
303
458
  const e = await rx.getReport(request.params.id);
304
459
  if (!e) return reply.code(404).send({error: 'No such report.'});
305
460
  await rx.removeReport(e.id);
461
+ files.removeAll(e.id);
306
462
  return {ok: true};
307
463
  });
308
464
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feedback",
3
3
  "displayName": "Feedback",
4
- "version": "1.1.4",
4
+ "version": "1.1.8",
5
5
  "tier": "free",
6
6
  "description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
7
7
  "author": "Domma CMS",
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "views": {
42
42
  "plugin-feedback": {
43
- "entry": "feedback/admin/views/feedback.js?v=1.1.4",
43
+ "entry": "feedback/admin/views/feedback.js?v=1.1.8",
44
44
  "exportName": "feedbackView"
45
45
  }
46
46
  }