feat(cms): add per-page external URL override — backend

This commit is contained in:
Luca
2026-05-03 23:05:18 +02:00
parent d4d8833d82
commit 66423bb65e
3 changed files with 57 additions and 8 deletions
+17 -1
View File
@@ -464,15 +464,31 @@ async function ensureGlobalCategories() {
table.text('content_en'); table.text('content_en');
table.text('content_de'); table.text('content_de');
table.string('logo_url').nullable(); table.string('logo_url').nullable();
table.boolean('use_external_url').notNullable().defaultTo(false);
table.string('external_url').nullable();
table.timestamp('updated_at').defaultTo(db.fn.now()); table.timestamp('updated_at').defaultTo(db.fn.now());
}); });
} else if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) { } else {
if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) {
// Online migration for existing deployments — see issue #324, per-page // Online migration for existing deployments — see issue #324, per-page
// logo override for admin-customisable error pages. // logo override for admin-customisable error pages.
await db.schema.alterTable('cms_pages', (table) => { await db.schema.alterTable('cms_pages', (table) => {
table.string('logo_url').nullable(); table.string('logo_url').nullable();
}); });
} }
if (!(await db.schema.hasColumn('cms_pages', 'use_external_url'))) {
// Per-page toggle to redirect visitors to an external imprint /
// privacy-policy URL instead of rendering the internal CMS content.
await db.schema.alterTable('cms_pages', (table) => {
table.boolean('use_external_url').notNullable().defaultTo(false);
});
}
if (!(await db.schema.hasColumn('cms_pages', 'external_url'))) {
await db.schema.alterTable('cms_pages', (table) => {
table.string('external_url').nullable();
});
}
}
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first(); const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0; const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0;
+30 -2
View File
@@ -71,7 +71,9 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('title_de').optional().isString(), body('title_de').optional().isString(),
body('content_en').optional().isString(), body('content_en').optional().isString(),
body('content_de').optional().isString(), body('content_de').optional().isString(),
body('logo_url').optional({ nullable: true }).isString() body('logo_url').optional({ nullable: true }).isString(),
body('use_external_url').optional().isBoolean(),
body('external_url').optional({ nullable: true }).isString()
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -80,7 +82,26 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
} }
const { slug } = req.params; const { slug } = req.params;
const { title_en, title_de, content_en, content_de, logo_url } = req.body; const { title_en, title_de, content_en, content_de, logo_url, use_external_url, external_url } = req.body;
// When the external-URL toggle is on, the URL must parse and use https://.
// express-validator's isURL() is too permissive (allows http:, ftp:, etc.) —
// an explicit protocol check is the security-relevant gate.
if (use_external_url === true) {
const candidate = typeof external_url === 'string' ? external_url.trim() : '';
if (!candidate) {
return res.status(400).json({ error: 'external_url is required when use_external_url is true' });
}
let parsed;
try {
parsed = new URL(candidate);
} catch (_err) {
return res.status(400).json({ error: 'external_url must be a valid URL' });
}
if (parsed.protocol !== 'https:') {
return res.status(400).json({ error: 'external_url must use https://' });
}
}
const page = await db('cms_pages').where('slug', slug).first(); const page = await db('cms_pages').where('slug', slug).first();
if (!page) { if (!page) {
@@ -99,6 +120,13 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) { if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) {
updateFields.logo_url = logo_url || null; updateFields.logo_url = logo_url || null;
} }
if (Object.prototype.hasOwnProperty.call(req.body, 'use_external_url')) {
updateFields.use_external_url = !!use_external_url;
}
if (Object.prototype.hasOwnProperty.call(req.body, 'external_url')) {
const trimmed = typeof external_url === 'string' ? external_url.trim() : '';
updateFields.external_url = trimmed || null;
}
await db('cms_pages').where('slug', slug).update(updateFields); await db('cms_pages').where('slug', slug).update(updateFields);
+5
View File
@@ -25,6 +25,11 @@ router.get('/pages/:slug', async (req, res) => {
// Per-page logo override (#324). Null means "fall back to global // Per-page logo override (#324). Null means "fall back to global
// branding logo" — the consumer decides. // branding logo" — the consumer decides.
logo_url: page.logo_url || null, logo_url: page.logo_url || null,
// Per-page external-URL override. When use_external_url is true and
// external_url is set, consumers should redirect / link out instead
// of rendering the internal title/content.
use_external_url: !!page.use_external_url,
external_url: page.external_url || null,
updated_at: page.updated_at updated_at: page.updated_at
}); });
} catch (error) { } catch (error) {