domma-cms 0.35.0 → 0.36.0
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/CLAUDE.md +2 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/embed-builder.js +1 -0
- package/admin/js/lib/markdown-toolbar.js +17 -17
- package/admin/js/lib/shortcode-context-menu.js +9 -0
- package/admin/js/lib/shortcode-registry.js +45 -0
- package/admin/js/lib/shortcode-tags.js +1 -0
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/page-editor.js +14 -14
- package/bin/lib/config-merge.js +44 -44
- package/config/connections.json.bak +9 -0
- package/config/embeds.json +9 -0
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/settings.js +141 -141
- package/server/services/email.js +167 -167
- package/server/services/markdown.js +188 -1
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
package/server/services/email.js
CHANGED
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core Email Utility
|
|
3
|
-
* Nodemailer transport factory and generic form email sending utility.
|
|
4
|
-
*/
|
|
5
|
-
import nodemailer from 'nodemailer';
|
|
6
|
-
|
|
7
|
-
let lastSendResult = null;
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Return the most recent email send result, or null if no send has occurred.
|
|
11
|
-
*
|
|
12
|
-
* @returns {{ ok: boolean, at: string, info: string|null } | null}
|
|
13
|
-
*/
|
|
14
|
-
export function getLastSendResult() {
|
|
15
|
-
return lastSendResult;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Record the outcome of an email send for health reporting.
|
|
20
|
-
*
|
|
21
|
-
* @param {boolean} ok
|
|
22
|
-
* @param {string|null} info
|
|
23
|
-
* @returns {void}
|
|
24
|
-
*/
|
|
25
|
-
function recordSendResult(ok, info) {
|
|
26
|
-
lastSendResult = {
|
|
27
|
-
ok,
|
|
28
|
-
at: new Date().toISOString(),
|
|
29
|
-
info: info || null
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Escape HTML special characters for safe use in email bodies.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} str
|
|
37
|
-
* @returns {string}
|
|
38
|
-
*/
|
|
39
|
-
export function escapeHtml(str) {
|
|
40
|
-
return String(str)
|
|
41
|
-
.replace(/&/g, '&')
|
|
42
|
-
.replace(/</g, '<')
|
|
43
|
-
.replace(/>/g, '>')
|
|
44
|
-
.replace(/"/g, '"')
|
|
45
|
-
.replace(/'/g, ''');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Create a nodemailer transport.
|
|
50
|
-
* Falls back to an Ethereal test account when no SMTP host is configured.
|
|
51
|
-
*
|
|
52
|
-
* @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
|
|
53
|
-
* @returns {Promise<import('nodemailer').Transporter>}
|
|
54
|
-
* @throws {Error} If Ethereal test account creation fails.
|
|
55
|
-
*/
|
|
56
|
-
export async function createTransport(smtp) {
|
|
57
|
-
if (smtp?.host) {
|
|
58
|
-
return nodemailer.createTransport({
|
|
59
|
-
host: smtp.host,
|
|
60
|
-
port: smtp.port || 587,
|
|
61
|
-
secure: smtp.secure || false,
|
|
62
|
-
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
|
|
63
|
-
tls: { rejectUnauthorized: false }
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// No SMTP configured — use Ethereal for dev/demo
|
|
68
|
-
const testAccount = await nodemailer.createTestAccount();
|
|
69
|
-
console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
|
|
70
|
-
return nodemailer.createTransport({
|
|
71
|
-
host: 'smtp.ethereal.email',
|
|
72
|
-
port: 587,
|
|
73
|
-
secure: false,
|
|
74
|
-
auth: { user: testAccount.user, pass: testAccount.pass }
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Send a generic transactional email.
|
|
80
|
-
*
|
|
81
|
-
* @param {import('nodemailer').Transporter} transport
|
|
82
|
-
* @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
|
|
83
|
-
* @returns {Promise<void>}
|
|
84
|
-
* @throws {Error} If sending the email fails.
|
|
85
|
-
*/
|
|
86
|
-
export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
|
|
87
|
-
try {
|
|
88
|
-
const info = await transport.sendMail({
|
|
89
|
-
from: `"${fromName}" <${from}>`,
|
|
90
|
-
to,
|
|
91
|
-
subject,
|
|
92
|
-
text,
|
|
93
|
-
html
|
|
94
|
-
});
|
|
95
|
-
recordSendResult(true, info.messageId);
|
|
96
|
-
|
|
97
|
-
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
98
|
-
if (previewUrl) {
|
|
99
|
-
console.log('[email] Preview URL:', previewUrl);
|
|
100
|
-
}
|
|
101
|
-
return info;
|
|
102
|
-
} catch (err) {
|
|
103
|
-
recordSendResult(false, err.message);
|
|
104
|
-
throw err;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Send an HTML + plain-text form submission notification email.
|
|
110
|
-
* Builds a generic table of field→value pairs from the submitted data.
|
|
111
|
-
*
|
|
112
|
-
* @param {import('nodemailer').Transporter} transport
|
|
113
|
-
* @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
|
|
114
|
-
* @returns {Promise<void>}
|
|
115
|
-
* @throws {Error} If sending the email fails.
|
|
116
|
-
*/
|
|
117
|
-
export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
|
|
118
|
-
const rows = fields.map(field => {
|
|
119
|
-
const val = data[field.name] ?? '';
|
|
120
|
-
const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
|
|
121
|
-
const safeLabel = escapeHtml(field.label || field.name);
|
|
122
|
-
return `
|
|
123
|
-
<tr>
|
|
124
|
-
<td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
|
|
125
|
-
<td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
|
|
126
|
-
</tr>`.trim();
|
|
127
|
-
}).join('\n');
|
|
128
|
-
|
|
129
|
-
const plainRows = fields.map(field => {
|
|
130
|
-
const val = data[field.name] ?? '';
|
|
131
|
-
return `${field.label || field.name}: ${val}`;
|
|
132
|
-
}).join('\n');
|
|
133
|
-
|
|
134
|
-
const html = `
|
|
135
|
-
<!DOCTYPE html>
|
|
136
|
-
<html>
|
|
137
|
-
<body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
|
|
138
|
-
<h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
|
|
139
|
-
<p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
|
|
140
|
-
<table style="width:100%;border-collapse:collapse;margin-top:16px;">
|
|
141
|
-
${rows}
|
|
142
|
-
</table>
|
|
143
|
-
</body>
|
|
144
|
-
</html>`.trim();
|
|
145
|
-
|
|
146
|
-
const text = `New form submission: ${formTitle}\n\n${plainRows}`;
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
const info = await transport.sendMail({
|
|
150
|
-
from: `"${fromName}" <${from}>`,
|
|
151
|
-
to,
|
|
152
|
-
subject,
|
|
153
|
-
text,
|
|
154
|
-
html
|
|
155
|
-
});
|
|
156
|
-
recordSendResult(true, info.messageId);
|
|
157
|
-
|
|
158
|
-
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
159
|
-
if (previewUrl) {
|
|
160
|
-
console.log('[email] Preview URL:', previewUrl);
|
|
161
|
-
}
|
|
162
|
-
return info;
|
|
163
|
-
} catch (err) {
|
|
164
|
-
recordSendResult(false, err.message);
|
|
165
|
-
throw err;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Core Email Utility
|
|
3
|
+
* Nodemailer transport factory and generic form email sending utility.
|
|
4
|
+
*/
|
|
5
|
+
import nodemailer from 'nodemailer';
|
|
6
|
+
|
|
7
|
+
let lastSendResult = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Return the most recent email send result, or null if no send has occurred.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ ok: boolean, at: string, info: string|null } | null}
|
|
13
|
+
*/
|
|
14
|
+
export function getLastSendResult() {
|
|
15
|
+
return lastSendResult;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Record the outcome of an email send for health reporting.
|
|
20
|
+
*
|
|
21
|
+
* @param {boolean} ok
|
|
22
|
+
* @param {string|null} info
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
function recordSendResult(ok, info) {
|
|
26
|
+
lastSendResult = {
|
|
27
|
+
ok,
|
|
28
|
+
at: new Date().toISOString(),
|
|
29
|
+
info: info || null
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Escape HTML special characters for safe use in email bodies.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} str
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function escapeHtml(str) {
|
|
40
|
+
return String(str)
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/"/g, '"')
|
|
45
|
+
.replace(/'/g, ''');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a nodemailer transport.
|
|
50
|
+
* Falls back to an Ethereal test account when no SMTP host is configured.
|
|
51
|
+
*
|
|
52
|
+
* @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
|
|
53
|
+
* @returns {Promise<import('nodemailer').Transporter>}
|
|
54
|
+
* @throws {Error} If Ethereal test account creation fails.
|
|
55
|
+
*/
|
|
56
|
+
export async function createTransport(smtp) {
|
|
57
|
+
if (smtp?.host) {
|
|
58
|
+
return nodemailer.createTransport({
|
|
59
|
+
host: smtp.host,
|
|
60
|
+
port: smtp.port || 587,
|
|
61
|
+
secure: smtp.secure || false,
|
|
62
|
+
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
|
|
63
|
+
tls: { rejectUnauthorized: false }
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// No SMTP configured — use Ethereal for dev/demo
|
|
68
|
+
const testAccount = await nodemailer.createTestAccount();
|
|
69
|
+
console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
|
|
70
|
+
return nodemailer.createTransport({
|
|
71
|
+
host: 'smtp.ethereal.email',
|
|
72
|
+
port: 587,
|
|
73
|
+
secure: false,
|
|
74
|
+
auth: { user: testAccount.user, pass: testAccount.pass }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Send a generic transactional email.
|
|
80
|
+
*
|
|
81
|
+
* @param {import('nodemailer').Transporter} transport
|
|
82
|
+
* @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
* @throws {Error} If sending the email fails.
|
|
85
|
+
*/
|
|
86
|
+
export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
|
|
87
|
+
try {
|
|
88
|
+
const info = await transport.sendMail({
|
|
89
|
+
from: `"${fromName}" <${from}>`,
|
|
90
|
+
to,
|
|
91
|
+
subject,
|
|
92
|
+
text,
|
|
93
|
+
html
|
|
94
|
+
});
|
|
95
|
+
recordSendResult(true, info.messageId);
|
|
96
|
+
|
|
97
|
+
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
98
|
+
if (previewUrl) {
|
|
99
|
+
console.log('[email] Preview URL:', previewUrl);
|
|
100
|
+
}
|
|
101
|
+
return info;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
recordSendResult(false, err.message);
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Send an HTML + plain-text form submission notification email.
|
|
110
|
+
* Builds a generic table of field→value pairs from the submitted data.
|
|
111
|
+
*
|
|
112
|
+
* @param {import('nodemailer').Transporter} transport
|
|
113
|
+
* @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
* @throws {Error} If sending the email fails.
|
|
116
|
+
*/
|
|
117
|
+
export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
|
|
118
|
+
const rows = fields.map(field => {
|
|
119
|
+
const val = data[field.name] ?? '';
|
|
120
|
+
const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
|
|
121
|
+
const safeLabel = escapeHtml(field.label || field.name);
|
|
122
|
+
return `
|
|
123
|
+
<tr>
|
|
124
|
+
<td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
|
|
125
|
+
<td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
|
|
126
|
+
</tr>`.trim();
|
|
127
|
+
}).join('\n');
|
|
128
|
+
|
|
129
|
+
const plainRows = fields.map(field => {
|
|
130
|
+
const val = data[field.name] ?? '';
|
|
131
|
+
return `${field.label || field.name}: ${val}`;
|
|
132
|
+
}).join('\n');
|
|
133
|
+
|
|
134
|
+
const html = `
|
|
135
|
+
<!DOCTYPE html>
|
|
136
|
+
<html>
|
|
137
|
+
<body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
|
|
138
|
+
<h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
|
|
139
|
+
<p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
|
|
140
|
+
<table style="width:100%;border-collapse:collapse;margin-top:16px;">
|
|
141
|
+
${rows}
|
|
142
|
+
</table>
|
|
143
|
+
</body>
|
|
144
|
+
</html>`.trim();
|
|
145
|
+
|
|
146
|
+
const text = `New form submission: ${formTitle}\n\n${plainRows}`;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const info = await transport.sendMail({
|
|
150
|
+
from: `"${fromName}" <${from}>`,
|
|
151
|
+
to,
|
|
152
|
+
subject,
|
|
153
|
+
text,
|
|
154
|
+
html
|
|
155
|
+
});
|
|
156
|
+
recordSendResult(true, info.messageId);
|
|
157
|
+
|
|
158
|
+
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
159
|
+
if (previewUrl) {
|
|
160
|
+
console.log('[email] Preview URL:', previewUrl);
|
|
161
|
+
}
|
|
162
|
+
return info;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
recordSendResult(false, err.message);
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -3084,6 +3084,177 @@ function processIconBlocks(markdown) {
|
|
|
3084
3084
|
));
|
|
3085
3085
|
}
|
|
3086
3086
|
|
|
3087
|
+
// ─── [embed] — video / iframe embeds ───────────────────────────────────────
|
|
3088
|
+
|
|
3089
|
+
/**
|
|
3090
|
+
* Classify an embed URL into one of: youtube, vimeo, video (self-hosted),
|
|
3091
|
+
* iframe (generic) or invalid. Pure — no allow-list logic here; the host
|
|
3092
|
+
* allow-list is applied at render time for the generic-iframe case only.
|
|
3093
|
+
*
|
|
3094
|
+
* @param {string} url
|
|
3095
|
+
* @returns {{type:('youtube'|'vimeo'|'video'|'iframe'|'invalid'), src?:string, id?:string, host?:string, mime?:string}}
|
|
3096
|
+
*/
|
|
3097
|
+
export function classifyEmbedUrl(url) {
|
|
3098
|
+
const u = String(url ?? '').trim();
|
|
3099
|
+
if (!u) return {type: 'invalid'};
|
|
3100
|
+
|
|
3101
|
+
let m;
|
|
3102
|
+
// YouTube — watch?v=, youtu.be/, /embed/, /shorts/, /live/. Any non-empty
|
|
3103
|
+
// id token counts so a YouTube host never falls through to the generic
|
|
3104
|
+
// iframe path (which would show a misleading "not allow-listed" message).
|
|
3105
|
+
if ((m = u.match(/(?:youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^#]*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([\w-]+)/i))) {
|
|
3106
|
+
return {type: 'youtube', id: m[1], src: `https://www.youtube-nocookie.com/embed/${m[1]}`};
|
|
3107
|
+
}
|
|
3108
|
+
// Vimeo — vimeo.com/ID or player.vimeo.com/video/ID
|
|
3109
|
+
if ((m = u.match(/vimeo\.com\/(?:video\/)?(\d+)/i))) {
|
|
3110
|
+
return {type: 'vimeo', id: m[1], src: `https://player.vimeo.com/video/${m[1]}`};
|
|
3111
|
+
}
|
|
3112
|
+
// Self-hosted video — by file extension (optional query/hash suffix)
|
|
3113
|
+
if ((m = u.match(/\.(mp4|webm|ogg|ogv)(?:\?[^#]*)?(?:#.*)?$/i))) {
|
|
3114
|
+
const ext = m[1].toLowerCase();
|
|
3115
|
+
const mime = ext === 'mp4' ? 'video/mp4' : ext === 'webm' ? 'video/webm' : 'video/ogg';
|
|
3116
|
+
return {type: 'video', src: u, mime};
|
|
3117
|
+
}
|
|
3118
|
+
// Generic http(s) — host decides allow/deny at render time
|
|
3119
|
+
if (/^https?:\/\//i.test(u)) {
|
|
3120
|
+
try {
|
|
3121
|
+
return {type: 'iframe', src: u, host: new URL(u).hostname};
|
|
3122
|
+
} catch {
|
|
3123
|
+
return {type: 'invalid'};
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
return {type: 'invalid'};
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
const EMBED_RATIOS = {
|
|
3130
|
+
'16:9': '16 / 9', '4:3': '4 / 3', '1:1': '1 / 1',
|
|
3131
|
+
'21:9': '21 / 9', '9:16': '9 / 16', '3:4': '3 / 4',
|
|
3132
|
+
};
|
|
3133
|
+
|
|
3134
|
+
function _embedRatio(raw) {
|
|
3135
|
+
if (!raw || raw === true) return '16 / 9';
|
|
3136
|
+
const key = String(raw).trim();
|
|
3137
|
+
if (EMBED_RATIOS[key]) return EMBED_RATIOS[key];
|
|
3138
|
+
const m = key.match(/^(\d{1,2}):(\d{1,2})$/); // bounded arbitrary W:H
|
|
3139
|
+
return m ? `${m[1]} / ${m[2]}` : '16 / 9';
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
function _embedBlocked(message) {
|
|
3143
|
+
return `<div class="dm-embed-blocked" role="note">⚠ ${message}</div>`;
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
const _embedFlag = (v) => v === true || v === '' || v === 'true';
|
|
3147
|
+
|
|
3148
|
+
/**
|
|
3149
|
+
* Render a single [embed] shortcode into sanitiser-safe HTML. YouTube/Vimeo
|
|
3150
|
+
* and self-hosted video always render; a generic iframe renders only when its
|
|
3151
|
+
* host is in `allowedHosts`, otherwise a visible placeholder is returned.
|
|
3152
|
+
*
|
|
3153
|
+
* @param {object} attrs - Parsed shortcode attributes (`url` required).
|
|
3154
|
+
* @param {string[]} [allowedHosts] - Extra iframe hostnames permitted for the
|
|
3155
|
+
* generic-iframe case (YouTube/Vimeo hosts are always permitted).
|
|
3156
|
+
* @returns {string}
|
|
3157
|
+
*/
|
|
3158
|
+
export function renderEmbed(attrs, allowedHosts = []) {
|
|
3159
|
+
const info = classifyEmbedUrl(attrs.url);
|
|
3160
|
+
if (info.type === 'invalid') return _embedBlocked('Invalid embed URL.');
|
|
3161
|
+
|
|
3162
|
+
const ratio = _embedRatio(attrs.ratio);
|
|
3163
|
+
const title = attrs.title ? escapeAttr(attrs.title) : '';
|
|
3164
|
+
const autoplay = _embedFlag(attrs.autoplay);
|
|
3165
|
+
const loop = _embedFlag(attrs.loop);
|
|
3166
|
+
const muted = _embedFlag(attrs.muted);
|
|
3167
|
+
|
|
3168
|
+
// Outer <figure> — width + alignment.
|
|
3169
|
+
const figStyles = ['margin:1.5rem 0'];
|
|
3170
|
+
if (attrs.width) {
|
|
3171
|
+
const w = /^\d+%$/.test(attrs.width) ? attrs.width : `${parseInt(attrs.width, 10) || 0}px`;
|
|
3172
|
+
if (w !== '0px') figStyles.push(`max-width:${w}`);
|
|
3173
|
+
}
|
|
3174
|
+
const align = String(attrs.align || '').toLowerCase();
|
|
3175
|
+
if (align === 'center') figStyles.push('margin-left:auto', 'margin-right:auto');
|
|
3176
|
+
else if (align === 'right') figStyles.push('margin-left:auto');
|
|
3177
|
+
|
|
3178
|
+
let inner;
|
|
3179
|
+
if (info.type === 'video') {
|
|
3180
|
+
const flags = ['controls', 'playsinline'];
|
|
3181
|
+
if (autoplay) flags.push('autoplay', 'muted'); // browsers block un-muted autoplay
|
|
3182
|
+
else if (muted) flags.push('muted');
|
|
3183
|
+
if (loop) flags.push('loop');
|
|
3184
|
+
inner = `<video ${flags.join(' ')} preload="metadata"`
|
|
3185
|
+
+ ` style="position:absolute;inset:0;width:100%;height:100%;display:block;background:#000">`
|
|
3186
|
+
+ `<source src="${escapeAttr(info.src)}" type="${escapeAttr(info.mime)}"></video>`;
|
|
3187
|
+
} else {
|
|
3188
|
+
if (info.type === 'iframe') {
|
|
3189
|
+
const allowed = allowedHosts.map(h => String(h).toLowerCase());
|
|
3190
|
+
if (!allowed.includes(String(info.host).toLowerCase())) {
|
|
3191
|
+
return _embedBlocked(
|
|
3192
|
+
`Embedded content from <code>${escapeAttr(info.host)}</code> is not in the allow-list (config/embeds.json).`);
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
let src = info.src;
|
|
3196
|
+
if (info.type === 'youtube' || info.type === 'vimeo') {
|
|
3197
|
+
const muteKey = info.type === 'youtube' ? 'mute=1' : 'muted=1';
|
|
3198
|
+
const params = [];
|
|
3199
|
+
if (autoplay) params.push('autoplay=1', muteKey);
|
|
3200
|
+
else if (muted) params.push(muteKey);
|
|
3201
|
+
if (loop) info.type === 'youtube' ? params.push('loop=1', `playlist=${info.id}`) : params.push('loop=1');
|
|
3202
|
+
if (params.length) src += (src.includes('?') ? '&' : '?') + params.join('&');
|
|
3203
|
+
}
|
|
3204
|
+
inner = `<iframe src="${escapeAttr(src)}"${title ? ` title="${title}"` : ''}`
|
|
3205
|
+
+ ` loading="lazy" referrerpolicy="strict-origin-when-cross-origin"`
|
|
3206
|
+
+ ` allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"`
|
|
3207
|
+
+ ` allowfullscreen style="position:absolute;inset:0;width:100%;height:100%;border:0"></iframe>`;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
const caption = title ? `\n<figcaption class="dm-embed-caption">${title}</figcaption>` : '';
|
|
3211
|
+
return `<figure class="dm-embed" style="${figStyles.join(';')}">`
|
|
3212
|
+
+ `<div class="dm-embed-frame" style="position:relative;width:100%;aspect-ratio:${ratio}">${inner}</div>`
|
|
3213
|
+
+ `${caption}</figure>`;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
/**
|
|
3217
|
+
* Read the generic-iframe host allow-list from config/embeds.json. A missing
|
|
3218
|
+
* file yields no extra hosts — YouTube/Vimeo and self-hosted video still work.
|
|
3219
|
+
*
|
|
3220
|
+
* @returns {string[]}
|
|
3221
|
+
*/
|
|
3222
|
+
export function getEmbedAllowedHosts() {
|
|
3223
|
+
try {
|
|
3224
|
+
const cfg = getConfig('embeds');
|
|
3225
|
+
return Array.isArray(cfg?.allowedHosts) ? cfg.allowedHosts : [];
|
|
3226
|
+
} catch {
|
|
3227
|
+
return [];
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
/**
|
|
3232
|
+
* Pre-process [embed] self-closing shortcodes before running through marked.
|
|
3233
|
+
*
|
|
3234
|
+
* Syntax:
|
|
3235
|
+
* [embed url="https://youtu.be/ID" /]
|
|
3236
|
+
* [embed url="/media/clip.mp4" ratio="16:9" title="..." autoplay muted loop /]
|
|
3237
|
+
* [embed url="https://maps.example.com/..." width="640" align="center" /]
|
|
3238
|
+
*
|
|
3239
|
+
* Supported attributes:
|
|
3240
|
+
* url - YouTube/Vimeo link, self-hosted video path, or generic iframe URL (required)
|
|
3241
|
+
* ratio - Aspect ratio: 16:9 (default), 4:3, 1:1, 21:9, 9:16, 3:4, or W:H
|
|
3242
|
+
* title - Accessibility title + visible caption
|
|
3243
|
+
* width - Max width (px or %); align - left | center | right
|
|
3244
|
+
* autoplay / loop / muted - Playback flags (autoplay forces muted)
|
|
3245
|
+
*
|
|
3246
|
+
* @param {string} markdown
|
|
3247
|
+
* @returns {string}
|
|
3248
|
+
*/
|
|
3249
|
+
function processEmbedBlocks(markdown) {
|
|
3250
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
3251
|
+
const allowedHosts = getEmbedAllowedHosts();
|
|
3252
|
+
return restore(scrubbed.replace(
|
|
3253
|
+
/\[embed([^\]]*?)\/?\]/gi,
|
|
3254
|
+
(_, attrStr) => '\n' + renderEmbed(parseShortcodeAttrs(attrStr || ''), allowedHosts) + '\n'
|
|
3255
|
+
));
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3087
3258
|
/**
|
|
3088
3259
|
* Pre-process [form] self-closing shortcodes before running through marked.
|
|
3089
3260
|
*
|
|
@@ -3575,7 +3746,8 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
3575
3746
|
const withSpacer = processSpacerBlocks(withListGroup);
|
|
3576
3747
|
const withCenter = processCenterBlocks(withSpacer);
|
|
3577
3748
|
const withIcon = processIconBlocks(withCenter);
|
|
3578
|
-
|
|
3749
|
+
const withEmbed = processEmbedBlocks(withIcon);
|
|
3750
|
+
const withForm = await processFormBlocks(withEmbed, tagSet);
|
|
3579
3751
|
const withHero = processHeroBlocks(withForm);
|
|
3580
3752
|
const withTable = processTableBlocks(withHero);
|
|
3581
3753
|
const withBadge = processBadgeBlocks(withTable);
|
|
@@ -3594,10 +3766,20 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
3594
3766
|
// reach the page. Admins write block templates; same trust model as
|
|
3595
3767
|
// content/custom.css. This flag silences sanitize-html's warning.
|
|
3596
3768
|
allowVulnerableTags: true,
|
|
3769
|
+
// [embed] emits <iframe>/<video>; hostnames are gated by
|
|
3770
|
+
// allowedIframeHostnames below (defence in depth over the render-time
|
|
3771
|
+
// host allow-list).
|
|
3772
|
+
allowedIframeHostnames: [
|
|
3773
|
+
'www.youtube-nocookie.com', 'www.youtube.com', 'youtube.com',
|
|
3774
|
+
'player.vimeo.com',
|
|
3775
|
+
...getEmbedAllowedHosts(),
|
|
3776
|
+
],
|
|
3597
3777
|
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
|
3598
3778
|
'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
3599
3779
|
'form', 'input', 'textarea', 'select', 'option', 'optgroup',
|
|
3600
3780
|
'button', 'label', 'fieldset', 'legend',
|
|
3781
|
+
// [embed] shortcode output
|
|
3782
|
+
'iframe', 'video', 'source', 'figure', 'figcaption',
|
|
3601
3783
|
// <style> tags are emitted by block rendering (buildBlockStyleTag) so
|
|
3602
3784
|
// per-block custom CSS can reach the page. Admin-trust: block authors
|
|
3603
3785
|
// are admins, same security model as site-wide content/custom.css.
|
|
@@ -3624,6 +3806,11 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
3624
3806
|
button: ['type', 'disabled', 'aria-label', 'data-action', 'data-entry', 'data-confirm'],
|
|
3625
3807
|
label: ['for'],
|
|
3626
3808
|
fieldset: ['disabled'],
|
|
3809
|
+
iframe: ['src', 'title', 'width', 'height', 'allow', 'allowfullscreen',
|
|
3810
|
+
'loading', 'referrerpolicy', 'frameborder', 'style'],
|
|
3811
|
+
video: ['controls', 'autoplay', 'muted', 'loop', 'playsinline',
|
|
3812
|
+
'preload', 'poster', 'width', 'height', 'style'],
|
|
3813
|
+
source: ['src', 'type'],
|
|
3627
3814
|
// Allow any attribute on every registered dm-* tag. Props are
|
|
3628
3815
|
// author-defined and enumerating them at sanitise time has no security
|
|
3629
3816
|
// benefit — custom-element attributes cannot execute JS on their own,
|