domma-cms 1.6.37 → 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.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +55 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +70 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +171 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +64 -0
- package/plugins/free-tier.lock.json +73 -61
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +28 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +42 -10
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +51 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +81 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +51 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
|
|
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
|
-
|
|
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,
|
|
75
|
-
else await senderRoutes(fastify, {can, hooks, notifyOk,
|
|
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,
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
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 () => {
|
|
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,
|
|
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
|
|
180
|
-
const
|
|
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
|
-
|
|
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
|
+
"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.
|
|
43
|
+
"entry": "feedback/admin/views/feedback.js?v=1.1.7",
|
|
44
44
|
"exportName": "feedbackView"
|
|
45
45
|
}
|
|
46
46
|
}
|