domma-cms 1.6.36 → 1.6.38

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 (84) 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/compose-window.js +4 -0
  5. package/plugins/_lib/admin/mail/mail.css +1 -1
  6. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  7. package/plugins/_lib/mail/compose.js +7 -2
  8. package/plugins/_lib/mail/message-action.js +56 -0
  9. package/plugins/_lib/mail/scheduler.js +2 -0
  10. package/plugins/_lib/mail/send.js +12 -3
  11. package/plugins/_lib/mail/store.js +16 -6
  12. package/plugins/_lib/outreach/index.js +1 -1
  13. package/plugins/_lib/outreach/ledger.js +10 -3
  14. package/plugins/_lib/outreach/sending.js +9 -3
  15. package/plugins/_lib/outreach/suppression.js +14 -3
  16. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  17. package/plugins/blog/CLAUDE.md +11 -1
  18. package/plugins/blog/lib/cache.js +43 -0
  19. package/plugins/blog/lib/page.js +28 -9
  20. package/plugins/blog/plugin.js +14 -13
  21. package/plugins/blog/plugin.json +1 -1
  22. package/plugins/blog/plugin.public.js +1 -1
  23. package/plugins/blog/tests/cache.test.js +65 -0
  24. package/plugins/booking/CLAUDE.md +14 -2
  25. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  26. package/plugins/booking/admin/views/diary.js +153 -152
  27. package/plugins/booking/admin/views/people.js +64 -66
  28. package/plugins/booking/admin/views/settings.js +31 -32
  29. package/plugins/booking/plugin.json +1 -1
  30. package/plugins/booking/tests/admin-diary.test.js +9 -1
  31. package/plugins/feedback/CLAUDE.md +55 -0
  32. package/plugins/feedback/admin/css/index.css +16 -1
  33. package/plugins/feedback/admin/lib/kit.js +106 -0
  34. package/plugins/feedback/admin/lib/settings.js +107 -0
  35. package/plugins/feedback/admin/views/feedback.js +4 -1
  36. package/plugins/feedback/admin/views/inbox.js +11 -2
  37. package/plugins/feedback/admin/views/send.js +74 -7
  38. package/plugins/feedback/docs/guide.md +9 -9
  39. package/plugins/feedback/lib/attachments.js +133 -0
  40. package/plugins/feedback/lib/cache.js +43 -0
  41. package/plugins/feedback/lib/receiver.js +36 -6
  42. package/plugins/feedback/lib/sender.js +70 -6
  43. package/plugins/feedback/lib/shape.js +111 -6
  44. package/plugins/feedback/plugin.js +171 -17
  45. package/plugins/feedback/plugin.json +2 -2
  46. package/plugins/feedback/tests/api.test.js +125 -3
  47. package/plugins/feedback/tests/settings.test.js +59 -0
  48. package/plugins/feedback/tests/shape.test.js +64 -0
  49. package/plugins/free-tier.lock.json +73 -61
  50. package/plugins/mail-reader/CLAUDE.md +15 -4
  51. package/plugins/mail-reader/plugin.json +1 -1
  52. package/plugins/shopping-cart/CLAUDE.md +28 -2
  53. package/plugins/shopping-cart/admin/css/index.css +3 -0
  54. package/plugins/shopping-cart/admin/views/orders.js +42 -10
  55. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  56. package/plugins/shopping-cart/docs/guide.md +7 -3
  57. package/plugins/shopping-cart/lib/payments.js +15 -2
  58. package/plugins/shopping-cart/lib/refunds.js +51 -0
  59. package/plugins/shopping-cart/lib/sales.js +15 -8
  60. package/plugins/shopping-cart/lib/shop.js +38 -3
  61. package/plugins/shopping-cart/plugin.js +47 -19
  62. package/plugins/shopping-cart/plugin.json +2 -2
  63. package/plugins/shopping-cart/tests/api.test.js +52 -0
  64. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  65. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  66. package/plugins/support/CLAUDE.md +51 -0
  67. package/plugins/support/admin/css/index.css +16 -1
  68. package/plugins/support/admin/lib/kit.js +192 -8
  69. package/plugins/support/admin/lib/settings.js +123 -0
  70. package/plugins/support/admin/views/desk.js +55 -12
  71. package/plugins/support/admin/views/inbox.js +17 -4
  72. package/plugins/support/admin/views/support.js +4 -1
  73. package/plugins/support/docs/guide.md +7 -9
  74. package/plugins/support/lib/attachments.js +133 -0
  75. package/plugins/support/lib/cache.js +43 -0
  76. package/plugins/support/lib/receiver.js +45 -10
  77. package/plugins/support/lib/sender.js +81 -10
  78. package/plugins/support/lib/shape.js +153 -12
  79. package/plugins/support/plugin.js +159 -29
  80. package/plugins/support/plugin.json +2 -2
  81. package/plugins/support/tests/api.test.js +125 -3
  82. package/plugins/support/tests/settings.test.js +68 -0
  83. package/plugins/support/tests/shape.test.js +51 -1
  84. package/server/routes/outreach-public.js +54 -10
@@ -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,26 +86,54 @@ 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;
94
139
  const reports = mergeReports(outbox.all(), remote, outbox.seen());
@@ -104,12 +149,24 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options})
104
149
  }
105
150
 
106
151
  // The hourly sync: waiting reports go, new replies are noticed. Off in tests.
152
+ // Each wait asks for syncMinutes again, and a save re-arms it, so a changed
153
+ // interval counts from the save rather than from the next restart.
107
154
  if (api && options.timer !== false) {
108
- const t = setInterval(() => { sync().catch(() => {}); }, Math.max(5, Number(settings.syncMinutes) || 60) * 60_000);
109
- t.unref?.();
155
+ let t = null;
156
+ let closed = false;
157
+ const arm = async () => {
158
+ clearTimeout(t);
159
+ if (closed) return;
160
+ const minutes = Math.max(5, Number((await liveSettings().catch(() => ({}))).syncMinutes) || 60);
161
+ if (closed) return;
162
+ t = setTimeout(() => { pruneFiles(); sync().catch(() => {}).finally(() => arm()); }, minutes * 60_000);
163
+ t.unref?.();
164
+ };
165
+ arm();
166
+ settingsHooks.saved = () => { arm(); };
110
167
  const first = setTimeout(() => { sync().catch(() => {}); }, 60_000);
111
168
  first.unref?.();
112
- fastify.addHook('onClose', async () => { clearInterval(t); clearTimeout(first); });
169
+ fastify.addHook('onClose', async () => { closed = true; clearTimeout(t); clearTimeout(first); });
113
170
  }
114
171
 
115
172
  fastify.get('/mode', can('send'), async () => ({mode: 'sender', connected: Boolean(manager), site: manager?.slug || ''}));
@@ -139,13 +196,32 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options})
139
196
  return {connected: Boolean(manager), reachable: r.reachable, error: r.error, reports: r.reports};
140
197
  });
141
198
 
199
+ fastify.post('/attachments', can('send'), async (request, reply) => {
200
+ if (!manager) return reply.code(409).send({error: 'This site is not connected to Domma, so feedback cannot be sent from here.'});
201
+ const up = await readUpload(request);
202
+ if (up.error) return reply.code(up.code || 400).send({error: up.error});
203
+ const saved = files.save({id: randomUUID(), name: up.file.name, type: up.file.type, size: up.file.size}, up.file.buffer);
204
+ return reply.code(201).send(saved);
205
+ });
206
+
207
+ fastify.get('/attachments/:id', can('send'), async (request, reply) => {
208
+ const a = files.meta(String(request.params.id));
209
+ const buffer = a && files.read(a);
210
+ if (!buffer) return reply.code(404).send({error: 'That file is not here.'});
211
+ return sendFile(reply, a, buffer);
212
+ });
213
+
142
214
  fastify.post('/send', can('send'), async (request, reply) => {
143
215
  if (!manager) return reply.code(409).send({error: 'This site is not connected to Domma, so feedback cannot be sent from here.'});
144
216
  const {report, error} = cleanSubmission(request.body || {});
145
217
  if (error) return reply.code(400).send({error});
218
+ // What a file is comes from what was uploaded here, never from the form's say-so.
219
+ const attachments = report.attachments.map(a => files.meta(a.id));
220
+ if (attachments.some(a => !a)) return reply.code(400).send({error: 'An attached file is no longer here - attach it again.'});
221
+ report.attachments = attachments;
146
222
  const payload = buildPayload({report, user: request.user, cmsVersion: cmsVersion(), site: manager.slug, ref: newRef()});
147
223
  outbox.add(payload);
148
- const r = await flush(outbox, api);
224
+ const r = await flush(outbox, api, files);
149
225
  const item = outbox.all().find(i => i.ref === payload.ref);
150
226
  return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
151
227
  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 +241,7 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options})
165
241
  /** Level 0 - the most senior role, whatever it is called. Roles are data, never names in code. */
166
242
  const isTopLevel = (user) => { try { return getEffectiveLevel(user) === 0; } catch { return false; } };
167
243
 
168
- async function receiverRoutes(fastify, {can, authenticate, requirePermission, hooks, notifyOk, receiverMod, settings, options}) {
244
+ async function receiverRoutes(fastify, {can, authenticate, requirePermission, hooks, notifyOk, receiverMod, liveSettings, options}) {
169
245
  const rx = receiverMod;
170
246
  const slugOf = options.slugFromToken || rx.slugFromToken;
171
247
  const siteNames = options.siteNames || rx.siteNames;
@@ -176,8 +252,28 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
176
252
  if (!isTopLevel(request.user)) return reply.code(403).send({error: 'Only the most senior role on this server can send feedback from here.'});
177
253
  };
178
254
  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});
255
+ const dataDir = options.dataDir || path.join(HERE, 'data');
256
+ const ownSeen = createOutbox(dataDir, 'own.json');
257
+ // The per-site brakes take their limits at each call (the cog changes them live).
258
+ const reportThrottle = createThrottle();
259
+ const fileThrottle = createThrottle();
260
+ const limits = async () => {
261
+ const s = await liveSettings().catch(() => ({}));
262
+ return {max: Number(s.perSiteMax) || 10, windowMs: (Number(s.perSiteMinutes) || 10) * 60_000};
263
+ };
264
+ const allowed = async (slug) => reportThrottle(slug, Date.now(), await limits());
265
+ // Files have their own brake: up to ATTACHMENTS.max per report allowed in the same window.
266
+ const allowedFile = async (slug) => {
267
+ const l = await limits();
268
+ return fileThrottle(slug, Date.now(), {max: l.max * ATTACHMENTS.max, windowMs: l.windowMs});
269
+ };
270
+ // A report's files live in data/attachments/<report id>/; the level-0 role's uploads wait in _staging.
271
+ const files = createStore(path.join(dataDir, 'attachments'));
272
+ const STAGING = '_staging';
273
+ // A site sends a file's bytes raw. Only inside this plugin's scope; the engine has no parser for it.
274
+ if (!fastify.hasContentTypeParser('application/octet-stream')) {
275
+ fastify.addContentTypeParser('application/octet-stream', {parseAs: 'buffer', bodyLimit: ATTACHMENTS.maxBytes + 1024}, (req, body, done) => done(null, body));
276
+ }
181
277
  try { await rx.ensureCollection(); } catch (err) { fastify.log.error(`[feedback] collection: ${err.message}`); }
182
278
 
183
279
  // Waypoint Pro, when it is loaded, calls this when a linked issue moves.
@@ -196,7 +292,7 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
196
292
  fastify.post('/fleet/submit', {bodyLimit: 32 * 1024}, async (request, reply) => {
197
293
  const slug = await slugOf(request.headers.authorization);
198
294
  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.'});
295
+ 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
296
  const r = await rx.receive(slug, request.body || {});
201
297
  if (r.error) return reply.code(400).send({error: r.error});
202
298
  if (r.created && notifyOk) {
@@ -208,6 +304,24 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
208
304
  return reply.code(r.created ? 201 : 200).send({id: r.entry.id, status: r.entry.data.status});
209
305
  });
210
306
 
307
+ fastify.post('/fleet/attach/:id/:att', {bodyLimit: ATTACHMENTS.maxBytes + 1024}, async (request, reply) => {
308
+ const slug = await slugOf(request.headers.authorization);
309
+ if (!slug) return reply.code(401).send({error: 'Unknown site.'});
310
+ if (!await allowedFile(slug)) return reply.code(429).send({error: 'That is a lot of files at once - try again in a few minutes.'});
311
+ const e = await rx.getReport(String(request.params.id)).catch(() => null);
312
+ // Another site's report is "not found", never "not yours".
313
+ if (!e || e.data?.site !== slug) return reply.code(404).send({error: 'No such report.'});
314
+ // Only a file the report announced, at the size it announced, in the type it announced.
315
+ const a = cleanAttachment((e.data.attachments || []).find(x => x.id === String(request.params.att)));
316
+ if (!a) return reply.code(404).send({error: 'That report has no such file.'});
317
+ if ((e.data.attachments || []).find(x => x.id === a.id)?.stored) return {id: a.id, stored: true};
318
+ const body = request.body;
319
+ if (!Buffer.isBuffer(body) || body.length !== a.size) return reply.code(400).send({error: 'That file is not the size the report said.'});
320
+ files.save(a, body, e.id);
321
+ await rx.markStored(e.id, a.id);
322
+ return reply.code(201).send({id: a.id, stored: true});
323
+ });
324
+
211
325
  fastify.get('/fleet/mine', async (request, reply) => {
212
326
  const slug = await slugOf(request.headers.authorization);
213
327
  if (!slug) return reply.code(401).send({error: 'Unknown site.'});
@@ -244,13 +358,44 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
244
358
 
245
359
  // ---- The manager's own feedback: straight into the inbox, no HTTP round trip ----
246
360
 
361
+ fastify.post('/attachments', own, async (request, reply) => {
362
+ const up = await readUpload(request);
363
+ if (up.error) return reply.code(up.code || 400).send({error: up.error});
364
+ const saved = files.save({id: randomUUID(), name: up.file.name, type: up.file.type, size: up.file.size}, up.file.buffer, STAGING);
365
+ // Tidy anything picked and never filed while we are here.
366
+ try { files.prune({sub: STAGING}); } catch { /* tidying only */ }
367
+ return reply.code(201).send(saved);
368
+ });
369
+
370
+ /** One of the level-0 role's own files: still staged, or already in one of its reports. */
371
+ fastify.get('/attachments/:id', own, async (request, reply) => {
372
+ const id = String(request.params.id);
373
+ let a = files.meta(id, STAGING);
374
+ let buffer = a && files.read(a, STAGING);
375
+ if (!buffer) {
376
+ const e = (await rx.reportsOf(rx.MANAGER_SITE)).find(x => (x.data.attachments || []).some(y => y.id === id));
377
+ a = e && cleanAttachment(e.data.attachments.find(y => y.id === id));
378
+ buffer = a && files.read(a, e.id);
379
+ }
380
+ if (!buffer) return reply.code(404).send({error: 'That file is not here.'});
381
+ return sendFile(reply, a, buffer);
382
+ });
383
+
247
384
  fastify.post('/send', own, async (request, reply) => {
248
385
  const {report, error} = cleanSubmission(request.body || {});
249
386
  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.'});
387
+ const attachments = report.attachments.map(a => files.meta(a.id, STAGING));
388
+ if (attachments.some(a => !a)) return reply.code(400).send({error: 'An attached file is no longer here - attach it again.'});
389
+ report.attachments = attachments;
390
+ 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
391
  const payload = buildPayload({report, user: request.user, cmsVersion: cmsVersion(), site: rx.MANAGER_SITE, ref: newRef()});
252
392
  const r = await rx.receive(rx.MANAGER_SITE, payload, 'manager');
253
393
  if (r.error) return reply.code(400).send({error: r.error});
394
+ // No travel: the staged files simply move into the report's folder.
395
+ for (const a of attachments) {
396
+ try { files.move(a, STAGING, r.entry.id); await rx.markStored(r.entry.id, a.id); }
397
+ catch (err) { fastify.log.warn(`[feedback] attachment ${a.id}: ${err.message}`); }
398
+ }
254
399
  return reply.code(201).send({id: r.entry.id, ref: payload.ref, state: 'sent', message: 'Filed in the inbox.'});
255
400
  });
256
401
 
@@ -299,10 +444,19 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
299
444
  return {id: next.id, ...next.data, createdAt: next.meta?.createdAt, updatedAt: next.meta?.updatedAt};
300
445
  });
301
446
 
447
+ fastify.get('/reports/:id/attachments/:att', can('manage'), async (request, reply) => {
448
+ const e = await rx.getReport(request.params.id);
449
+ const a = e && cleanAttachment((e.data.attachments || []).find(x => x.id === String(request.params.att)));
450
+ const buffer = a && files.read(a, e.id);
451
+ if (!buffer) return reply.code(404).send({error: 'That file has not arrived, or is gone.'});
452
+ return sendFile(reply, a, buffer);
453
+ });
454
+
302
455
  fastify.delete('/reports/:id', can('manage'), async (request, reply) => {
303
456
  const e = await rx.getReport(request.params.id);
304
457
  if (!e) return reply.code(404).send({error: 'No such report.'});
305
458
  await rx.removeReport(e.id);
459
+ files.removeAll(e.id);
306
460
  return {ok: true};
307
461
  });
308
462
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feedback",
3
3
  "displayName": "Feedback",
4
- "version": "1.1.4",
4
+ "version": "1.1.7",
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.7",
44
44
  "exportName": "feedbackView"
45
45
  }
46
46
  }