domma-cms 1.10.16 → 1.10.17
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/CHANGELOG.md +5 -0
- package/admin/js/lib/collection-editor-arrange.js +1 -1
- package/admin/js/lib/collection-entries-arrange.js +1 -1
- package/admin/js/lib/form-submissions-arrange.js +1 -1
- package/admin/js/lib/media-arrange.js +1 -1
- package/admin/js/lib/media-picker.js +17 -5
- package/admin/js/lib/media-upload.js +1 -0
- package/admin/js/templates/collection-entries.html +4 -0
- package/admin/js/templates/form-submissions.html +3 -0
- package/admin/js/views/collection-entries.js +7 -7
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/media.js +3 -3
- package/docs/DommaFeatures.md +5 -4
- package/docs/api-reference.md +6 -1
- package/docs/configuration.md +55 -12
- package/docs/guides/multi-page-forms.md +1 -1
- package/docs/plugin-development.md +3 -0
- package/package.json +1 -1
- package/public/js/form-files.mjs +1 -1
- package/server/routes/api/forms.js +164 -38
- package/server/services/collections.js +3 -0
- package/server/services/email.js +15 -4
- package/server/services/forms.js +11 -3
- package/server/services/markdown.js +20 -9
|
@@ -27,7 +27,9 @@ import {deleteForm, ensureCollectionForForm, ensureFormsDir, listForms, readForm
|
|
|
27
27
|
import {executeAction} from '../../services/actions.js';
|
|
28
28
|
// The same rules the browser draws the buttons by (a Cancel link must be safe).
|
|
29
29
|
import {buttonSettingsProblems, formClassProblems, publicButtonSettings} from '../../../public/js/form-submit.mjs';
|
|
30
|
-
import {cleanUploadName, fileRefusal} from '../../../public/js/form-files.mjs';
|
|
30
|
+
import {cleanUploadName, countRefusal, fileRefusal, fileRules, fileValueText, isFileValue} from '../../../public/js/form-files.mjs';
|
|
31
|
+
import {sanitiseCsv} from '../../../public/js/collection-export.mjs';
|
|
32
|
+
import {originOf, trustedOrigin} from '../../services/passwordReset.js';
|
|
31
33
|
import {createTransport, mailFailure, sendFormEmail, TEST_EMAIL_FAILED} from '../../services/email.js';
|
|
32
34
|
import {formActionFailed, formTriggerNotice} from '../../services/notification-sources.js';
|
|
33
35
|
import {
|
|
@@ -44,7 +46,7 @@ import {getConfig} from '../../config.js';
|
|
|
44
46
|
import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
|
|
45
47
|
import {hooks} from '../../services/hooks.js';
|
|
46
48
|
import {pluginDenialFor} from '../../services/collectionAccess.js';
|
|
47
|
-
import {saveMedia} from '../../services/content.js';
|
|
49
|
+
import {deleteMedia, saveMedia} from '../../services/content.js';
|
|
48
50
|
import {servedType} from '../../services/mediaTypes.js';
|
|
49
51
|
import {v4 as uuidv4} from 'uuid';
|
|
50
52
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
@@ -146,79 +148,171 @@ function validateUpload(fieldCfg, part, size) {
|
|
|
146
148
|
|| servedTypeRefusal(fieldCfg, part.filename || '');
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
/** What one submission's files may hold in memory together when server.json sets nothing: 50 MB. */
|
|
152
|
+
export const DEFAULT_MAX_SUBMISSION_BYTES = 50 * 1024 * 1024;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The most bytes one submission's files may hold in memory, all fields together:
|
|
156
|
+
* server.json `uploads.maxSubmissionBytes` (read fresh) when set; otherwise 50 MB or
|
|
157
|
+
* `uploads.maxFileSize`, whichever is larger, so one file the site takes is never
|
|
158
|
+
* refused for the total.
|
|
159
|
+
*
|
|
160
|
+
* @returns {number}
|
|
161
|
+
*/
|
|
162
|
+
export function maxSubmissionBytes() {
|
|
163
|
+
let uploads = {};
|
|
164
|
+
try { uploads = getConfig('server')?.uploads || {}; } catch { /* no server.json */ }
|
|
165
|
+
const set = Number(uploads.maxSubmissionBytes);
|
|
166
|
+
if (set > 0) return set;
|
|
167
|
+
return Math.max(DEFAULT_MAX_SUBMISSION_BYTES, Number(uploads.maxFileSize) || 0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The refusal for a submission whose files together pass maxSubmissionBytes(). */
|
|
171
|
+
export function submissionTooLarge(limit) {
|
|
172
|
+
const mb = limit / (1024 * 1024);
|
|
173
|
+
const size = mb >= 1 ? `${Math.round(mb * 10) / 10} MB` : `${Math.round(limit / 1024)} KB`;
|
|
174
|
+
return `The files sent are too large together (at most ${size} in one submission). Send fewer or smaller files.`;
|
|
175
|
+
}
|
|
176
|
+
|
|
149
177
|
/**
|
|
150
178
|
* Drain a multipart request into a JSON-shaped body. Text parts become string
|
|
151
|
-
* values; file parts are
|
|
152
|
-
*
|
|
179
|
+
* values; file parts are checked against the matching form field and held in
|
|
180
|
+
* memory, and the field's value becomes its reference - `{url, name, size, mime}`
|
|
181
|
+
* for a single field, a list of them for a `multiple` one - with the url filled
|
|
182
|
+
* in by saveFormFiles() once the whole submission has passed.
|
|
153
183
|
*
|
|
154
|
-
* Throws on validation failure (caller handles HTTP 400)
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
184
|
+
* Throws on validation failure (caller handles HTTP 400): any one file a field
|
|
185
|
+
* would refuse, or more files than a field takes (countRefusal), refuses the
|
|
186
|
+
* submission. Files are held in memory up to fastify-multipart's configured
|
|
187
|
+
* limit, at most fileRules().maxFiles per field, and at most maxSubmissionBytes()
|
|
188
|
+
* for all of them together - the request is refused the moment the running
|
|
189
|
+
* total passes it, before the rest is read. Fine for the resume-pdf /
|
|
190
|
+
* portrait-photo scale; large uploads should still use the admin media flow.
|
|
158
191
|
*
|
|
159
192
|
* @param {import('fastify').FastifyRequest} request
|
|
160
193
|
* @param {object} form
|
|
161
|
-
* @returns {Promise<object>}
|
|
194
|
+
* @returns {Promise<{body: object, pending: Map<string, {buffer: Buffer, filename: string}[]>}>}
|
|
162
195
|
*/
|
|
163
196
|
async function parseMultipartForm(request, form) {
|
|
164
197
|
const out = {};
|
|
165
|
-
const pending = new Map(); // field name -> {buffer, filename}, saved by saveFormFiles()
|
|
198
|
+
const pending = new Map(); // field name -> [{buffer, filename}], saved by saveFormFiles()
|
|
166
199
|
const fileFields = new Map(
|
|
167
200
|
(form.fields || []).filter(f => f.type === 'file').map(f => [f.name, f])
|
|
168
201
|
);
|
|
202
|
+
const drain = async (part) => { for await (const _ of part.file) { /* drain */ } };
|
|
203
|
+
const limit = maxSubmissionBytes();
|
|
204
|
+
let held = 0; // bytes of every file part so far, all fields
|
|
169
205
|
|
|
170
206
|
for await (const part of request.parts()) {
|
|
171
207
|
if (part.type === 'file') {
|
|
172
208
|
const fieldCfg = fileFields.get(part.fieldname);
|
|
209
|
+
const list = pending.get(part.fieldname) || [];
|
|
210
|
+
const rules = fieldCfg ? fileRules(fieldCfg) : null;
|
|
173
211
|
// An unknown file field, or a second file for one that holds one: drain and
|
|
174
212
|
// skip rather than 400, so a form that gains a file input later doesn't reject
|
|
175
213
|
// older submissions in-flight from a stale page.
|
|
176
|
-
if (!fieldCfg ||
|
|
177
|
-
|
|
214
|
+
if (!fieldCfg || (!rules.multiple && list.length)) {
|
|
215
|
+
await drain(part);
|
|
178
216
|
continue;
|
|
179
217
|
}
|
|
218
|
+
// More than a multiple field takes refuses the submission, before the
|
|
219
|
+
// extra file is read into memory.
|
|
220
|
+
if (list.length >= rules.maxFiles) {
|
|
221
|
+
await drain(part);
|
|
222
|
+
throw new Error(countRefusal(fieldCfg, list.length + 1));
|
|
223
|
+
}
|
|
180
224
|
const chunks = [];
|
|
181
|
-
|
|
225
|
+
let over = false;
|
|
226
|
+
for await (const chunk of part.file) {
|
|
227
|
+
if (over) continue; // the rest of this part is drained, not held
|
|
228
|
+
held += chunk.length;
|
|
229
|
+
if (held > limit) {
|
|
230
|
+
// Stop holding at once: drop what is read, then refuse
|
|
231
|
+
over = true;
|
|
232
|
+
chunks.length = 0;
|
|
233
|
+
pending.clear();
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
chunks.push(chunk);
|
|
237
|
+
}
|
|
238
|
+
if (over) throw new Error(submissionTooLarge(limit));
|
|
182
239
|
const buffer = Buffer.concat(chunks);
|
|
183
240
|
// Over the server's own upload cap the part arrives cut short, not refused
|
|
184
241
|
const size = part.file.truncated ? Infinity : buffer.length;
|
|
185
242
|
const err = validateUpload(fieldCfg, part, size);
|
|
186
243
|
if (err) throw new Error(err);
|
|
187
244
|
if (buffer.length === 0) continue; // empty file input - skip
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// Text field - last value wins on duplicates (browser-standard); a file
|
|
197
|
-
// already sent under the name keeps it.
|
|
245
|
+
list.push({buffer, filename: part.filename});
|
|
246
|
+
pending.set(part.fieldname, list);
|
|
247
|
+
const ref = {url: '', name: part.filename, size: buffer.length, mime: part.mimetype};
|
|
248
|
+
if (rules.multiple) (Array.isArray(out[part.fieldname]) ? out[part.fieldname] : (out[part.fieldname] = [])).push(ref);
|
|
249
|
+
else out[part.fieldname] = ref;
|
|
250
|
+
} else if (!fileFields.has(part.fieldname)) {
|
|
251
|
+
// Text field - last value wins on duplicates (browser-standard). A file
|
|
252
|
+
// field's value only ever comes from its file parts (fileFieldsOnly).
|
|
198
253
|
out[part.fieldname] = part.value;
|
|
199
254
|
}
|
|
200
255
|
}
|
|
201
256
|
return {body: out, pending};
|
|
202
257
|
}
|
|
203
258
|
|
|
259
|
+
/**
|
|
260
|
+
* A file field's value is only ever what this server made of the files it was
|
|
261
|
+
* sent: whatever a JSON body (or a text part) says one holds - a reference to
|
|
262
|
+
* any address - is dropped, so an entry never links to a file nobody uploaded.
|
|
263
|
+
*
|
|
264
|
+
* @param {object} form
|
|
265
|
+
* @param {object} body - mutated
|
|
266
|
+
* @param {Map<string, unknown>|null} pending
|
|
267
|
+
*/
|
|
268
|
+
function fileFieldsOnly(form, body, pending) {
|
|
269
|
+
if (!body || typeof body !== 'object') return;
|
|
270
|
+
for (const f of form.fields || []) {
|
|
271
|
+
if (f.type !== 'file' || !f.name || pending?.has(f.name)) continue;
|
|
272
|
+
if (body[f.name] !== undefined && body[f.name] !== '') body[f.name] = '';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
204
276
|
/**
|
|
205
277
|
* Write the files of a submission that passed every check, into the reference objects
|
|
206
278
|
* parseMultipartForm() left in `body`. Only the fields that will be kept are written -
|
|
207
279
|
* nothing a bot, a rate-limited or refused submission or a switched-off field sent
|
|
208
280
|
* reaches content/media/.
|
|
209
281
|
*
|
|
210
|
-
*
|
|
282
|
+
* If one cannot be written, the ones already written for this submission are removed
|
|
283
|
+
* and the error is thrown - a submission keeps all its files or none.
|
|
284
|
+
*
|
|
285
|
+
* @param {Map<string, {buffer: Buffer, filename: string}[]>} pending
|
|
211
286
|
* @param {object} body
|
|
212
287
|
* @param {Set<string>} keep - field names the submission keeps
|
|
288
|
+
* @returns {Promise<string[]>} the media file names written (for removeFormFiles)
|
|
213
289
|
*/
|
|
214
290
|
async function saveFormFiles(pending, body, keep) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
291
|
+
const written = [];
|
|
292
|
+
try {
|
|
293
|
+
for (const [name, files] of pending) {
|
|
294
|
+
if (!keep.has(name)) {
|
|
295
|
+
delete body[name];
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const refs = Array.isArray(body[name]) ? body[name] : [body[name]];
|
|
299
|
+
for (let i = 0; i < files.length; i++) {
|
|
300
|
+
const saved = await saveMedia(safeFilename(files[i].filename), files[i].buffer);
|
|
301
|
+
written.push(saved.name);
|
|
302
|
+
refs[i].url = saved.url;
|
|
303
|
+
}
|
|
219
304
|
}
|
|
220
|
-
|
|
221
|
-
|
|
305
|
+
} catch (err) {
|
|
306
|
+
await removeFormFiles(written);
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
return written;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Remove a submission's written files again (it was not stored after all). Never throws. */
|
|
313
|
+
async function removeFormFiles(names) {
|
|
314
|
+
for (const name of names || []) {
|
|
315
|
+
try { await deleteMedia(name); } catch { /* already gone */ }
|
|
222
316
|
}
|
|
223
317
|
}
|
|
224
318
|
|
|
@@ -244,17 +338,34 @@ function isRateLimited(slug, ip, limitPerMinute) {
|
|
|
244
338
|
return false;
|
|
245
339
|
}
|
|
246
340
|
|
|
247
|
-
|
|
341
|
+
/** A stored answer as plain text: lists joined with "; ", objects as JSON. */
|
|
342
|
+
function answerCsvText(raw) {
|
|
343
|
+
if (raw == null) return '';
|
|
344
|
+
if (Array.isArray(raw)) return raw.map(v => (v && typeof v === 'object' ? JSON.stringify(v) : String(v))).join('; ');
|
|
345
|
+
if (typeof raw === 'object') return JSON.stringify(raw);
|
|
346
|
+
return String(raw);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* One CSV cell: quoted, inner quotes doubled, and a leading =, +, -, @, TAB or CR
|
|
351
|
+
* neutralised (sanitiseCsv, as the public collection export does) - a visitor's
|
|
352
|
+
* answer must not run as a formula in the spreadsheet an admin opens.
|
|
353
|
+
*/
|
|
354
|
+
function csvCell(text) {
|
|
355
|
+
return `"${sanitiseCsv(String(text ?? '')).replace(/"/g, '""')}"`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function submissionsToCSV(form, entries) {
|
|
248
359
|
const fields = (form.fields || []).filter(f => f.type !== 'page-break' && f.type !== 'spacer');
|
|
249
|
-
const headers = [...fields.map(f =>
|
|
360
|
+
const headers = [...fields.map(f => csvCell(f.label || f.name)), csvCell('Date')];
|
|
250
361
|
const rows = entries.map(e => {
|
|
251
362
|
const cols = fields.map(f => {
|
|
252
363
|
const raw = e.data?.[f.name] ?? '';
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return
|
|
364
|
+
// Files: "cv.pdf (/media/…)", several joined with "; " like any list. Anything
|
|
365
|
+
// else in the column (an older text answer) is written as it is.
|
|
366
|
+
return csvCell(isFileValue(raw) ? fileValueText(raw) : answerCsvText(raw));
|
|
256
367
|
});
|
|
257
|
-
cols.push(
|
|
368
|
+
cols.push(csvCell(e.meta?.createdAt || ''));
|
|
258
369
|
return cols.join(',');
|
|
259
370
|
});
|
|
260
371
|
return [headers.join(','), ...rows].join('\n');
|
|
@@ -812,6 +923,7 @@ export async function formsRoutes(fastify) {
|
|
|
812
923
|
return reply.status(400).send({ error: err.message });
|
|
813
924
|
}
|
|
814
925
|
}
|
|
926
|
+
fileFieldsOnly(form, body, pendingFiles);
|
|
815
927
|
const settings = form.settings || {};
|
|
816
928
|
|
|
817
929
|
// Best-effort auth - never reject, just enrich.
|
|
@@ -915,7 +1027,15 @@ export async function formsRoutes(fastify) {
|
|
|
915
1027
|
}
|
|
916
1028
|
|
|
917
1029
|
// The checks are done: only now do a submission's files reach content/media/
|
|
918
|
-
|
|
1030
|
+
let writtenFiles = [];
|
|
1031
|
+
if (pendingFiles?.size) {
|
|
1032
|
+
try {
|
|
1033
|
+
writtenFiles = await saveFormFiles(pendingFiles, body, record ? visibleFieldNames : new Set());
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
fastify.log.warn(`[forms] Saving the files of a "${slug}" submission failed: ${err.message}`);
|
|
1036
|
+
return reply.status(500).send({error: 'Your files could not be saved, so the submission was not sent. Please try again.'});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
919
1039
|
|
|
920
1040
|
// Build submission data - only include visible fields
|
|
921
1041
|
const data = {};
|
|
@@ -970,12 +1090,15 @@ export async function formsRoutes(fastify) {
|
|
|
970
1090
|
// storage is explicitly disabled with no slug. Fail loudly
|
|
971
1091
|
// rather than silently lose submissions.
|
|
972
1092
|
fastify.log.warn(`[forms] Submission for "${slug}" had no target collection "${targetSlug}"`);
|
|
1093
|
+
await removeFormFiles(writtenFiles);
|
|
973
1094
|
return reply.status(500).send({
|
|
974
1095
|
error: `Submission not saved - target collection "${targetSlug}" does not exist. Please contact the site administrator.`
|
|
975
1096
|
});
|
|
976
1097
|
}
|
|
977
1098
|
} catch (err) {
|
|
978
1099
|
fastify.log.warn(`[forms] Collection write failed for "${slug}" → "${targetSlug}": ${err.message}`);
|
|
1100
|
+
// Not stored, so its files are not kept either
|
|
1101
|
+
await removeFormFiles(writtenFiles);
|
|
979
1102
|
// Distinguish validation errors (user-actionable) from other
|
|
980
1103
|
// failures (admin needs to look) so the message helps the right
|
|
981
1104
|
// person. Validation errors typically start with "Validation failed:".
|
|
@@ -999,7 +1122,10 @@ export async function formsRoutes(fastify) {
|
|
|
999
1122
|
subject: `${emailAction.subjectPrefix || `[${form.title}]`} New submission`,
|
|
1000
1123
|
formTitle: form.title,
|
|
1001
1124
|
fields: form.fields,
|
|
1002
|
-
data
|
|
1125
|
+
data,
|
|
1126
|
+
// File links in the email are absolute: site.json baseUrl, else an address an
|
|
1127
|
+
// admin has signed in at (trustedOrigin - never just this request's Host)
|
|
1128
|
+
baseUrl: await trustedOrigin(originOf(request)).catch(() => '')
|
|
1003
1129
|
});
|
|
1004
1130
|
} catch (err) {
|
|
1005
1131
|
fastify.log.warn(`[forms] Email send failed for "${slug}": ${err.message}`);
|
|
@@ -271,6 +271,9 @@ export function validateEntryData(schema, data, excused = []) {
|
|
|
271
271
|
const skip = new Set(excused);
|
|
272
272
|
for (const field of (schema.fields || [])) {
|
|
273
273
|
if (skip.has(field.name)) continue;
|
|
274
|
+
// Files only arrive through a form, which enforces its own `required`; the entry
|
|
275
|
+
// editor cannot add one, so a required file field must not stop an entry saving.
|
|
276
|
+
if (field.type === 'file') continue;
|
|
274
277
|
const val = data[field.name];
|
|
275
278
|
const isEmpty = val === undefined || val === null || val === '';
|
|
276
279
|
if (field.required && isEmpty) {
|
package/server/services/email.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import nodemailer from 'nodemailer';
|
|
6
6
|
import {signedOnly} from './codePolicy.js';
|
|
7
7
|
import {normaliseMailHost, resolveMailHost} from './mailHost.js';
|
|
8
|
+
import {fileRefs, fileUrl, fileValueText} from '../../public/js/form-files.mjs';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* What a person is told when mail could not be sent. The mail server's own
|
|
@@ -283,14 +284,24 @@ export async function sendEmail(transport, {from, fromName, to, subject, html, t
|
|
|
283
284
|
* Builds a generic table of field→value pairs from the submitted data.
|
|
284
285
|
*
|
|
285
286
|
* @param {import('nodemailer').Transporter} transport
|
|
286
|
-
*
|
|
287
|
+
* A file field's value (one reference, or a list from a `multiple` field) is shown
|
|
288
|
+
* as a link per file, made absolute with `baseUrl` (site.json) when the site has one.
|
|
289
|
+
*
|
|
290
|
+
* @param {import('nodemailer').Transporter} transport
|
|
291
|
+
* @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string, type?: string}>, data: Record<string, unknown>, baseUrl?: string }} opts
|
|
287
292
|
* @returns {Promise<void>}
|
|
288
293
|
* @throws {Error} If sending the email fails.
|
|
289
294
|
*/
|
|
290
|
-
export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
|
|
295
|
+
export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data, baseUrl = '' }) {
|
|
296
|
+
const isFile = (field) => field.type === 'file' && fileRefs(data[field.name]).length > 0;
|
|
297
|
+
const fileLinks = (val) => fileRefs(val).map((f) => {
|
|
298
|
+
const url = fileUrl(f, baseUrl);
|
|
299
|
+
const name = escapeHtml(f.name || url);
|
|
300
|
+
return /^(https?:\/\/|\/)/i.test(url) ? `<a href="${escapeHtml(url)}">${name}</a>` : name;
|
|
301
|
+
}).join('<br>');
|
|
291
302
|
const rows = fields.map(field => {
|
|
292
303
|
const val = data[field.name] ?? '';
|
|
293
|
-
const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
|
|
304
|
+
const safe = isFile(field) ? fileLinks(val) : escapeHtml(String(val)).replace(/\n/g, '<br>');
|
|
294
305
|
const safeLabel = escapeHtml(field.label || field.name);
|
|
295
306
|
return `
|
|
296
307
|
<tr>
|
|
@@ -301,7 +312,7 @@ export async function sendFormEmail(transport, { from, fromName, to, subject, fo
|
|
|
301
312
|
|
|
302
313
|
const plainRows = fields.map(field => {
|
|
303
314
|
const val = data[field.name] ?? '';
|
|
304
|
-
return `${field.label || field.name}: ${val}`;
|
|
315
|
+
return `${field.label || field.name}: ${isFile(field) ? fileValueText(val, {base: baseUrl}) : val}`;
|
|
305
316
|
}).join('\n');
|
|
306
317
|
|
|
307
318
|
const html = `
|
package/server/services/forms.js
CHANGED
|
@@ -198,7 +198,9 @@ function toFormFieldType(type) {
|
|
|
198
198
|
string: 'string', text: 'string', email: 'email', tel: 'tel',
|
|
199
199
|
number: 'number', textarea: 'textarea', select: 'select', radio: 'radio',
|
|
200
200
|
checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
|
|
201
|
-
date: 'date', time: 'time', url: 'url', hidden: 'hidden'
|
|
201
|
+
date: 'date', time: 'time', url: 'url', hidden: 'hidden',
|
|
202
|
+
// {url, name, size, mime} - a list of them when the field is `multiple`
|
|
203
|
+
file: 'file'
|
|
202
204
|
};
|
|
203
205
|
return map[type] || 'string';
|
|
204
206
|
}
|
|
@@ -223,6 +225,7 @@ function buildFormFromCollection(schema) {
|
|
|
223
225
|
};
|
|
224
226
|
if (f.options) field.options = f.options;
|
|
225
227
|
if (f.validation) field.validation = f.validation;
|
|
228
|
+
if (f.type === 'file' && f.multiple) field.multiple = true;
|
|
226
229
|
return field;
|
|
227
230
|
});
|
|
228
231
|
|
|
@@ -286,7 +289,9 @@ function toCollectionFieldType(type) {
|
|
|
286
289
|
string: 'string', email: 'email', tel: 'tel', number: 'number',
|
|
287
290
|
textarea: 'textarea', select: 'select', radio: 'radio',
|
|
288
291
|
checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
|
|
289
|
-
date: 'date', time: 'time', url: 'url', hidden: 'hidden'
|
|
292
|
+
date: 'date', time: 'time', url: 'url', hidden: 'hidden',
|
|
293
|
+
// {url, name, size, mime} - a list of them when the field is `multiple`
|
|
294
|
+
file: 'file'
|
|
290
295
|
};
|
|
291
296
|
return map[type] || 'string';
|
|
292
297
|
}
|
|
@@ -329,9 +334,12 @@ export async function ensureCollectionForForm(form) {
|
|
|
329
334
|
name: f.name,
|
|
330
335
|
type: toCollectionFieldType(f.type),
|
|
331
336
|
label: f.label || f.name,
|
|
332
|
-
required
|
|
337
|
+
// A file field's `required` is the form's to enforce (it sees the upload);
|
|
338
|
+
// copied here it would stop the entry editor saving an entry without one.
|
|
339
|
+
required: f.type === 'file' ? false : !!f.required
|
|
333
340
|
};
|
|
334
341
|
if (f.options) field.options = f.options;
|
|
342
|
+
if (f.type === 'file' && f.multiple) field.multiple = true;
|
|
335
343
|
if (f.validation) field.validation = f.validation;
|
|
336
344
|
return field;
|
|
337
345
|
});
|
|
@@ -631,13 +631,29 @@ function isFileRef(v) {
|
|
|
631
631
|
return v && typeof v === 'object' && typeof v.url === 'string' && typeof v.name === 'string';
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
+
/** A `multiple` file field's value: a non-empty list of nothing but file references. */
|
|
635
|
+
function isFileRefList(v) {
|
|
636
|
+
return Array.isArray(v) && v.length > 0 && v.every(isFileRef);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** One file reference as HTML: a thumbnail link for an image, a download link otherwise. */
|
|
640
|
+
function fileRefHtml(ref) {
|
|
641
|
+
const url = escapeAttr(ref.url);
|
|
642
|
+
const name = escapeHtmlText(ref.name);
|
|
643
|
+
if (String(ref.mime || '').startsWith('image/')) {
|
|
644
|
+
return `<a href="${url}" target="_blank" rel="noopener"><img src="${url}" alt="${name}" loading="lazy" class="dm-file-thumb"></a>`;
|
|
645
|
+
}
|
|
646
|
+
return `<a href="${url}" download>${name}</a>`;
|
|
647
|
+
}
|
|
648
|
+
|
|
634
649
|
/**
|
|
635
650
|
* Resolve the display value for one field on one entry.
|
|
636
651
|
*
|
|
637
652
|
* For a `type: reference` field, returns the resolved label from `_refs`
|
|
638
653
|
* (set by `references.resolveReferences()`); arrays join with ", ", missing
|
|
639
654
|
* targets render as "<id> (missing)". For a `type: file` field (or any field
|
|
640
|
-
* holding a stored file ref), returns the filename
|
|
655
|
+
* holding a stored file ref), returns the filename - names joined with ", " for a
|
|
656
|
+
* `multiple` field's list. Everything else returns
|
|
641
657
|
* the raw value with `null`/`undefined` collapsed to '' and arrays joined.
|
|
642
658
|
*
|
|
643
659
|
* @param {object} entry
|
|
@@ -654,6 +670,7 @@ function displayValue(entry, field) {
|
|
|
654
670
|
}
|
|
655
671
|
const raw = entry.data?.[field.name];
|
|
656
672
|
if (isFileRef(raw)) return raw.name;
|
|
673
|
+
if (isFileRefList(raw)) return raw.map(f => f.name).join(', ');
|
|
657
674
|
if (raw == null) return '';
|
|
658
675
|
if (Array.isArray(raw)) return raw.join(', ');
|
|
659
676
|
return String(raw);
|
|
@@ -679,14 +696,8 @@ function displayValueHtml(entry, field) {
|
|
|
679
696
|
}
|
|
680
697
|
}
|
|
681
698
|
const raw = entry.data?.[field.name];
|
|
682
|
-
if (isFileRef(raw))
|
|
683
|
-
|
|
684
|
-
const name = escapeHtmlText(raw.name);
|
|
685
|
-
if (String(raw.mime || '').startsWith('image/')) {
|
|
686
|
-
return `<a href="${url}" target="_blank" rel="noopener"><img src="${url}" alt="${name}" loading="lazy" class="dm-file-thumb"></a>`;
|
|
687
|
-
}
|
|
688
|
-
return `<a href="${url}" download>${name}</a>`;
|
|
689
|
-
}
|
|
699
|
+
if (isFileRef(raw)) return fileRefHtml(raw);
|
|
700
|
+
if (isFileRefList(raw)) return raw.map(fileRefHtml).join(' ');
|
|
690
701
|
return escapeHtmlText(displayValue(entry, field));
|
|
691
702
|
}
|
|
692
703
|
|