diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 7b0ab1ef..b9cc15eb 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -464,14 +464,30 @@ async function ensureGlobalCategories() { table.text('content_en'); table.text('content_de'); 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()); }); - } else if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) { - // Online migration for existing deployments — see issue #324, per-page - // logo override for admin-customisable error pages. - await db.schema.alterTable('cms_pages', (table) => { - table.string('logo_url').nullable(); - }); + } else { + if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) { + // Online migration for existing deployments — see issue #324, per-page + // logo override for admin-customisable error pages. + await db.schema.alterTable('cms_pages', (table) => { + 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(); diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js index 647037b9..3a5c609c 100644 --- a/backend/src/routes/adminCMS.js +++ b/backend/src/routes/adminCMS.js @@ -71,7 +71,9 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ body('title_de').optional().isString(), body('content_en').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) => { try { const errors = validationResult(req); @@ -80,7 +82,26 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ } 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(); if (!page) { @@ -99,6 +120,13 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) { 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); diff --git a/backend/src/routes/publicCMS.js b/backend/src/routes/publicCMS.js index b999ed0f..e354bb01 100644 --- a/backend/src/routes/publicCMS.js +++ b/backend/src/routes/publicCMS.js @@ -25,6 +25,11 @@ router.get('/pages/:slug', async (req, res) => { // Per-page logo override (#324). Null means "fall back to global // branding logo" — the consumer decides. 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 }); } catch (error) {