feat(cms): add per-page external URL override — backend
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user