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.
@@ -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 validated against the matching form field, saved to
152
- * `content/media/`, and replaced by a `{ url, name, size, mime }` reference.
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). Files are streamed
155
- * into memory up to fastify-multipart's configured limit - fine for the
156
- * resume-pdf / portrait-photo scale; large uploads should still use the admin
157
- * media flow.
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 || pending.has(part.fieldname)) {
177
- for await (const _ of part.file) { /* drain */ }
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
- for await (const chunk of part.file) chunks.push(chunk);
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
- pending.set(part.fieldname, {buffer, filename: part.filename});
189
- out[part.fieldname] = {
190
- url: '',
191
- name: part.filename,
192
- size: buffer.length,
193
- mime: part.mimetype
194
- };
195
- } else if (!pending.has(part.fieldname)) {
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
- * @param {Map<string, {buffer: Buffer, filename: string}>} pending
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
- for (const [name, file] of pending) {
216
- if (!keep.has(name)) {
217
- delete body[name];
218
- continue;
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
- const saved = await saveMedia(safeFilename(file.filename), file.buffer);
221
- body[name].url = saved.url;
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
- function submissionsToCSV(form, entries) {
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 => `"${f.label || f.name}"`), '"Date"'];
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
- const str = Array.isArray(raw) ? raw.join('; ') : String(raw);
254
- const val = str.replace(/"/g, '""');
255
- return `"${val}"`;
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(`"${e.meta?.createdAt || ''}"`);
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
- if (pendingFiles?.size) await saveFormFiles(pendingFiles, body, record ? visibleFieldNames : new Set());
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) {
@@ -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
- * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
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 = `
@@ -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: !!f.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. Everything else returns
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
- const url = escapeAttr(raw.url);
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