domma-cms 1.10.15 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/index.html +2 -2
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/lib/collection-editor-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/form-submissions-arrange.js +1 -1
  10. package/admin/js/lib/hotkeys-ui.js +7 -16
  11. package/admin/js/lib/hotkeys.js +1 -1
  12. package/admin/js/lib/media-arrange.js +1 -1
  13. package/admin/js/lib/media-picker.js +17 -5
  14. package/admin/js/lib/media-upload.js +1 -0
  15. package/admin/js/lib/palette-commands.js +1 -0
  16. package/admin/js/templates/collection-entries.html +4 -0
  17. package/admin/js/templates/form-submissions.html +3 -0
  18. package/admin/js/templates/media.html +19 -10
  19. package/admin/js/views/collection-entries.js +7 -7
  20. package/admin/js/views/form-editor.js +6 -6
  21. package/admin/js/views/form-submissions.js +2 -2
  22. package/admin/js/views/media.js +3 -3
  23. package/docs/DommaFeatures.md +5 -4
  24. package/docs/api-reference.md +6 -1
  25. package/docs/configuration.md +57 -10
  26. package/docs/guides/multi-page-forms.md +1 -1
  27. package/docs/markdown-shortcodes.md +3 -2
  28. package/docs/plugin-development.md +3 -0
  29. package/package.json +2 -2
  30. package/public/css/forms.css +1 -1
  31. package/public/css/site.css +1 -1
  32. package/public/js/collection-browser.js +2 -2
  33. package/public/js/collection-range.mjs +1 -0
  34. package/public/js/form-files.mjs +1 -0
  35. package/public/js/form-logic-engine.js +1 -1
  36. package/public/js/forms.js +2 -2
  37. package/public/js/site.js +1 -1
  38. package/server/routes/api/forms.js +211 -60
  39. package/server/routes/api/media.js +25 -2
  40. package/server/server.js +8 -2
  41. package/server/services/collections.js +3 -0
  42. package/server/services/email.js +15 -4
  43. package/server/services/forms.js +11 -3
  44. package/server/services/markdown.js +20 -9
  45. package/server/services/mediaTypes.js +22 -0
  46. package/server/templates/page.html +8 -6
@@ -2,6 +2,7 @@
2
2
  * Media API
3
3
  * GET /api/media - list media files
4
4
  * GET /api/media/_count - the sidebar badge (plain text; see mediaBadge)
5
+ * GET /api/media/_limits - what an upload may be (types, size, name length)
5
6
  * POST /api/media - upload a file
6
7
  * DELETE /api/media/:name - delete a file
7
8
  */
@@ -27,6 +28,22 @@ const ALLOWED_MIME_TYPES = new Set([
27
28
  'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', 'audio/webm',
28
29
  ]);
29
30
 
31
+ /** The longest a sanitised file name may be. */
32
+ const MAX_NAME_LENGTH = 200;
33
+
34
+ /**
35
+ * What POST /media accepts, for the admin to refuse a file before sending it.
36
+ * `maxFileSize` is the multipart limit server.js registered at boot (config/server.json
37
+ * uploads.maxFileSize), so the browser and the server cannot disagree.
38
+ *
39
+ * @param {number|null|undefined} maxFileSize bytes
40
+ * @returns {{types: string[], maxFileSize: number|null, maxNameLength: number}}
41
+ */
42
+ export function uploadLimits(maxFileSize) {
43
+ const n = Number(maxFileSize);
44
+ return {types: [...ALLOWED_MIME_TYPES], maxFileSize: n > 0 ? n : null, maxNameLength: MAX_NAME_LENGTH};
45
+ }
46
+
30
47
  // Safe filename: strip path traversal and restrict to alphanumeric + safe chars
31
48
  function sanitiseFilename(name) {
32
49
  return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
@@ -76,7 +93,7 @@ export function mediaBadge(files = [], now = Date.now()) {
76
93
  };
77
94
  }
78
95
 
79
- export async function mediaRoutes(fastify) {
96
+ export async function mediaRoutes(fastify, opts = {}) {
80
97
  const canRead = {preHandler: [authenticate, requirePermission('media', 'read')]};
81
98
  const canCreate = {preHandler: [authenticate, requirePermission('media', 'create')]};
82
99
  const canUpdate = {preHandler: [authenticate, requirePermission('media', 'update')]};
@@ -88,12 +105,13 @@ export async function mediaRoutes(fastify) {
88
105
 
89
106
  // `_count` is not a file route: the only GET under /media/:name is /media/:name/info.
90
107
  fastify.get('/media/_count', canRead, async () => mediaBadge(await listMedia()));
108
+ fastify.get('/media/_limits', canCreate, async () => uploadLimits(opts.maxFileSize));
91
109
 
92
110
  fastify.post('/media', canCreate, async (request, reply) => {
93
111
  const results = [];
94
112
  for await (const data of request.files()) {
95
113
  const filename = sanitiseFilename(data.filename);
96
- if (filename.length > 200) {
114
+ if (filename.length > MAX_NAME_LENGTH) {
97
115
  for await (const _ of data.file) { /* drain */
98
116
  }
99
117
  return reply.code(400).send({error: 'Filename is too long (max 200 characters).'});
@@ -108,6 +126,11 @@ export async function mediaRoutes(fastify) {
108
126
  const chunks = [];
109
127
  for await (const chunk of data.file) {
110
128
  chunks.push(chunk);
129
+ }
130
+ // Past uploads.maxFileSize the stream just stops: what arrived is the start of the
131
+ // file, and saving it left a broken file behind the 413.
132
+ if (data.file.truncated) {
133
+ return reply.code(413).send({error: 'request file too large'});
111
134
  }
112
135
  try {
113
136
  results.push(await saveMedia(filename, Buffer.concat(chunks)));
package/server/server.js CHANGED
@@ -34,6 +34,7 @@ import {parseTrustProxy} from './services/network.js';
34
34
  import {requestSerializer} from './services/request-log.js';
35
35
  import {rateLimitOptions} from './services/rate-limit.js';
36
36
  import {registerAdminAssets} from './services/adminAssets.js';
37
+ import {isActiveType, servedType} from './services/mediaTypes.js';
37
38
  import {connectSources, frameSources, imageSources} from './services/embedCsp.js';
38
39
  import {cspSources as gaSources} from './services/googleAnalytics.js';
39
40
 
@@ -419,8 +420,13 @@ await app.register(staticPlugin, {
419
420
  root: mediaDir,
420
421
  prefix: '/media/',
421
422
  decorateReply: false,
422
- setHeaders: (reply) => {
423
+ setHeaders: (reply, filePath) => {
423
424
  reply.header('X-Content-Type-Options', 'nosniff');
425
+ // A file a browser would run as a document (HTML, any XML, SVG, script) is
426
+ // served sandboxed: shown, never able to script this site's address.
427
+ if (isActiveType(servedType(filePath))) {
428
+ reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
429
+ }
424
430
  }
425
431
  });
426
432
 
@@ -563,7 +569,7 @@ await app.register(contentPublicRoutes, {prefix: '/api'});
563
569
  await app.register(contentApiRoutes, {prefix: '/api'});
564
570
  await app.register(sidebarRoutes, {prefix: '/api'});
565
571
  await app.register(documentationRoutes, {prefix: '/api'});
566
- await app.register(mediaRoutes, { prefix: '/api' });
572
+ await app.register(mediaRoutes, { prefix: '/api', maxFileSize: serverConfig.uploads.maxFileSize });
567
573
  await app.register(usersRoutes, { prefix: '/api' });
568
574
  await app.register(pluginsRoutes, { prefix: '/api' });
569
575
  await app.register(pluginMarketplaceRoutes, { prefix: '/api' });
@@ -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
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The type /media/ serves a stored file as, and whether a browser would run it.
3
+ *
4
+ * Uploads are served by @fastify/static from the site's own address, so a file a browser
5
+ * renders as a document - HTML, any XML (an XHTML-namespace <script> runs in one), SVG,
6
+ * script - could act with an admin's session. The lookup is the one @fastify/static makes
7
+ * (its own copy of `mime`), so what is checked is what is served.
8
+ */
9
+ import {createRequire} from 'module';
10
+
11
+ const requireHere = createRequire(import.meta.url);
12
+ const mime = requireHere(requireHere.resolve('mime', {paths: [requireHere.resolve('@fastify/send')]}));
13
+
14
+ /** The Content-Type /media/ would send for a file name, or null when it has none. */
15
+ export function servedType(name) {
16
+ return mime.getType(String(name || '')) || null;
17
+ }
18
+
19
+ /** Whether a served type is one a browser runs as a document or script. */
20
+ export function isActiveType(type) {
21
+ return /^(text\/html|text\/xml|application\/xml|image\/svg)|\+xml$|javascript|ecmascript/i.test(String(type || ''));
22
+ }
@@ -25,8 +25,8 @@
25
25
  <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
26
26
 
27
27
  <!-- Site CSS -->
28
- <link rel="stylesheet" href="/public/css/site.css?v=20261024-menu-highlight-fix">
29
- <link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
28
+ <link rel="stylesheet" href="/public/css/site.css?v=20261025-range-slider-css">
29
+ <link rel="stylesheet" href="/public/css/forms.css?v=20261025-dropzone-css">
30
30
  <!-- Menu item hover / current-page effects. Loaded after site.css so a
31
31
  menu's own highlight wins over the blanket link styling there. -->
32
32
  <link rel="stylesheet" href="/public/css/menu-highlight.css?v=20261024-menu-highlight-fix">
@@ -178,12 +178,14 @@
178
178
  reads window.FormLogicEngine and window.DommaCollectionBrowser. -->
179
179
  <!-- Interactive Collection Browser (used by [collection] shortcodes with
180
180
  searchable / filterable / sortable / paginate attributes) -->
181
- <script src="/public/js/collection-browser.js?v=20260927-scope-mine" defer></script>
181
+ <!-- Its range-filter arithmetic; a module that runs first and sets window.DommaCollectionRange -->
182
+ <script src="/public/js/collection-range.mjs?v=20261025-range-slider" type="module"></script>
183
+ <script src="/public/js/collection-browser.js?v=20261025-range-slider" defer></script>
182
184
  <!-- Core Forms logic engine (must run before forms.js and site.js) -->
183
- <script src="/public/js/form-logic-engine.js?v=20260927-submit-btn" defer></script>
185
+ <script src="/public/js/form-logic-engine.js?v=20261025-dropzone-disable" defer></script>
184
186
 
185
187
  <!-- Site initialisation -->
186
- <script src="/public/js/site.js?v=20261022-cookie-settings" type="module"></script>
188
+ <script src="/public/js/site.js?v=20261025-dropzone-range" type="module"></script>
187
189
  <script src="/public/js/collection-context.js?v=20261010-utf8-payloads" type="module"></script>
188
190
  {{ctxMenusModule}}
189
191
 
@@ -193,7 +195,7 @@
193
195
  <script src="/public/js/menu-float.js?v=20260927-float" type="module"></script>
194
196
 
195
197
  <!-- Core Forms renderer -->
196
- <script src="/public/js/forms.js?v=20261015-smooth-caret" type="module"></script>
198
+ <script src="/public/js/forms.js?v=20261025-form-dropzone" type="module"></script>
197
199
 
198
200
  <!-- Core effects runtime -->
199
201
  <script src="/public/js/effects.js?v=20261015-occasions" defer></script>