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.
- package/CHANGELOG.md +14 -0
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +2 -2
- package/admin/js/api.js +1 -1
- 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/hotkeys-ui.js +7 -16
- package/admin/js/lib/hotkeys.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/lib/palette-commands.js +1 -0
- package/admin/js/templates/collection-entries.html +4 -0
- package/admin/js/templates/form-submissions.html +3 -0
- package/admin/js/templates/media.html +19 -10
- 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 +57 -10
- package/docs/guides/multi-page-forms.md +1 -1
- package/docs/markdown-shortcodes.md +3 -2
- package/docs/plugin-development.md +3 -0
- package/package.json +2 -2
- package/public/css/forms.css +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +2 -2
- package/public/js/collection-range.mjs +1 -0
- package/public/js/form-files.mjs +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/site.js +1 -1
- package/server/routes/api/forms.js +211 -60
- package/server/routes/api/media.js +25 -2
- package/server/server.js +8 -2
- 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
- package/server/services/mediaTypes.js +22 -0
- 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 >
|
|
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) {
|
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
|
|
|
@@ -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=
|
|
29
|
-
<link rel="stylesheet" href="/public/css/forms.css?v=
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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>
|