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.
- 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/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- 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 +62 -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 +89 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +173 -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 +87 -1
- package/plugins/free-tier.lock.json +74 -62
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +35 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +75 -23
- 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/pure.test.js +16 -0
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +58 -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 +96 -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 +76 -2
- package/server/routes/outreach-public.js +2 -2
- 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
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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;
|
|
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
|
-
|
|
109
|
-
|
|
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 () => {
|
|
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,
|
|
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
|
|
180
|
-
const
|
|
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
|
-
|
|
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
|
+
"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.
|
|
43
|
+
"entry": "feedback/admin/views/feedback.js?v=1.1.8",
|
|
44
44
|
"exportName": "feedbackView"
|
|
45
45
|
}
|
|
46
46
|
}
|