Combined footer overhaul: - Per-CMS-page show_in_footer toggle (#441) — admins can hide Impressum / Datenschutz from the gallery footer when an external privacy / imprint URL is enough. - Five social-media URL fields in branding settings (#441) — Facebook, Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each icon individually; the row is omitted when none are set. - Promotional banner slot above or below the gallery footer (#440) — global default authored as markdown in branding settings, plus a three-way per-event override on the Edit Event form (inherit / custom / off). Backend nulls promo_markdown automatically when mode != 'custom' so stale text never persists. Sanitization: marked with gfm/breaks → DOMPurify with a tight allowlist (no img, no tables, no inline html). Post-process forces target=_blank rel="noopener noreferrer nofollow" on every link so admin-set URLs can't tab-nap the gallery context. i18n covers all six locales (en/de/nl/pt/ru/fr). Targets the beta branch.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Migration 089: Footer overhaul (#441 + #440).
|
||||
*
|
||||
* Three concerns, all in the gallery footer area:
|
||||
*
|
||||
* 1. Per-CMS-page "Show in footer" toggle (#441 part a). Lets admins
|
||||
* hide legal-link entries (Impressum, Datenschutz, etc.) when the
|
||||
* target jurisdiction doesn't require them. Default TRUE so existing
|
||||
* installs see no change.
|
||||
*
|
||||
* 2. Social links in the footer (#441 part b). Five branding settings
|
||||
* for the canonical photographer-relevant networks: Facebook,
|
||||
* Instagram, WhatsApp, X/Twitter, YouTube. All optional strings.
|
||||
*
|
||||
* 3. Promotional markdown slot above/below the footer (#440). Global
|
||||
* default + per-event override with a three-way mode switch:
|
||||
* - inherit (default): use the global, render nothing if global empty
|
||||
* - custom: render the per-event markdown
|
||||
* - off: suppress entirely for this event regardless of global
|
||||
*
|
||||
* Markdown only (no raw HTML) — the rendering pipeline is
|
||||
* `marked → DOMPurify` on the frontend so admins can format text
|
||||
* without opening an XSS surface.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 089_footer_overhaul');
|
||||
|
||||
// 1. cms_pages.show_in_footer
|
||||
const hasShowInFooter = await knex.schema.hasColumn('cms_pages', 'show_in_footer');
|
||||
if (!hasShowInFooter) {
|
||||
await knex.schema.alterTable('cms_pages', (table) => {
|
||||
table.boolean('show_in_footer').notNullable().defaultTo(true);
|
||||
});
|
||||
console.log(' added cms_pages.show_in_footer (default true)');
|
||||
} else {
|
||||
console.log(' cms_pages.show_in_footer already exists, skipping');
|
||||
}
|
||||
|
||||
// 2. events.promo_mode + events.promo_markdown
|
||||
const hasPromoMode = await knex.schema.hasColumn('events', 'promo_mode');
|
||||
if (!hasPromoMode) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('promo_mode', 16).notNullable().defaultTo('inherit');
|
||||
});
|
||||
console.log(' added events.promo_mode (default "inherit")');
|
||||
} else {
|
||||
console.log(' events.promo_mode already exists, skipping');
|
||||
}
|
||||
|
||||
const hasPromoMarkdown = await knex.schema.hasColumn('events', 'promo_markdown');
|
||||
if (!hasPromoMarkdown) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.text('promo_markdown').nullable();
|
||||
});
|
||||
console.log(' added events.promo_markdown (nullable text)');
|
||||
} else {
|
||||
console.log(' events.promo_markdown already exists, skipping');
|
||||
}
|
||||
|
||||
// 3. New branding settings rows. Use the same `branding_<name>`
|
||||
// convention as the existing 21 branding rows (verified via
|
||||
// SELECT setting_key FROM app_settings WHERE setting_key LIKE 'branding_%').
|
||||
const newSettings = [
|
||||
{ setting_key: 'branding_facebook_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_instagram_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_whatsapp_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_twitter_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_youtube_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_promo_markdown', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
// 'above_footer' | 'below_footer' (string instead of enum so we can
|
||||
// expand without a schema change later).
|
||||
{ setting_key: 'branding_promo_position', setting_value: JSON.stringify('above_footer'), setting_type: 'branding' },
|
||||
];
|
||||
|
||||
for (const setting of newSettings) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
|
||||
}
|
||||
}
|
||||
console.log(` ensured ${newSettings.length} branding rows`);
|
||||
|
||||
console.log('Migration 089_footer_overhaul completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 089_footer_overhaul');
|
||||
|
||||
if (await knex.schema.hasColumn('cms_pages', 'show_in_footer')) {
|
||||
await knex.schema.alterTable('cms_pages', (table) => {
|
||||
table.dropColumn('show_in_footer');
|
||||
});
|
||||
}
|
||||
|
||||
if (await knex.schema.hasColumn('events', 'promo_mode')) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('promo_mode');
|
||||
});
|
||||
}
|
||||
|
||||
if (await knex.schema.hasColumn('events', 'promo_markdown')) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('promo_markdown');
|
||||
});
|
||||
}
|
||||
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_facebook_url',
|
||||
'branding_instagram_url',
|
||||
'branding_whatsapp_url',
|
||||
'branding_twitter_url',
|
||||
'branding_youtube_url',
|
||||
'branding_promo_markdown',
|
||||
'branding_promo_position',
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -73,7 +73,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
body('content_de').optional().isString(),
|
||||
body('logo_url').optional({ nullable: true }).isString(),
|
||||
body('use_external_url').optional().isBoolean(),
|
||||
body('external_url').optional({ nullable: true }).isString()
|
||||
body('external_url').optional({ nullable: true }).isString(),
|
||||
body('show_in_footer').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -127,6 +128,9 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
const trimmed = typeof external_url === 'string' ? external_url.trim() : '';
|
||||
updateFields.external_url = trimmed || null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'show_in_footer')) {
|
||||
updateFields.show_in_footer = !!req.body.show_in_footer;
|
||||
}
|
||||
|
||||
await db('cms_pages').where('slug', slug).update(updateFields);
|
||||
|
||||
|
||||
@@ -393,7 +393,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
'upload_date_desc', 'upload_date_asc',
|
||||
'capture_date_desc', 'capture_date_asc',
|
||||
'filename_asc', 'filename_desc'
|
||||
])
|
||||
]),
|
||||
// Per-event promotional override (#440). Three-way mode:
|
||||
// inherit → fall back to global branding_promo_markdown
|
||||
// custom → render this event's promo_markdown verbatim
|
||||
// off → suppress entirely for this event
|
||||
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('promo_markdown').optional({ nullable: true }).isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -1100,7 +1106,13 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
||||
'upload_date_desc', 'upload_date_asc',
|
||||
'capture_date_desc', 'capture_date_asc',
|
||||
'filename_asc', 'filename_desc'
|
||||
])
|
||||
]),
|
||||
// Per-event promotional override (#440). Three-way mode:
|
||||
// inherit → fall back to global branding_promo_markdown
|
||||
// custom → render this event's promo_markdown verbatim
|
||||
// off → suppress entirely for this event
|
||||
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('promo_markdown').optional({ nullable: true }).isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -1251,6 +1263,21 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
||||
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||
}
|
||||
|
||||
// Per-event promotional override (#440). Normalize promo_markdown to
|
||||
// NULL when mode is anything other than 'custom' so we don't carry
|
||||
// stale text after the admin switches modes. Empty markdown also
|
||||
// becomes NULL.
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'promo_mode')
|
||||
|| Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) {
|
||||
const mode = updates.promo_mode;
|
||||
if (mode && mode !== 'custom') {
|
||||
updates.promo_markdown = null;
|
||||
} else if (Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) {
|
||||
const md = typeof updates.promo_markdown === 'string' ? updates.promo_markdown.trim() : '';
|
||||
updates.promo_markdown = md || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync header_style / hero_divider_style from color_theme JSON when not
|
||||
// explicitly provided in the request body (#158). This ensures the
|
||||
// database columns stay in sync even if the frontend only sends the
|
||||
|
||||
@@ -220,7 +220,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
logo_display_hero,
|
||||
logo_display_mode,
|
||||
hide_powered_by,
|
||||
force_color_mode
|
||||
force_color_mode,
|
||||
// Footer overhaul (#441 + #440). Socials are URL strings (empty
|
||||
// hides the icon). Promo content is markdown (rendered via
|
||||
// marked → DOMPurify on the frontend, no raw HTML accepted).
|
||||
facebook_url,
|
||||
instagram_url,
|
||||
whatsapp_url,
|
||||
twitter_url,
|
||||
youtube_url,
|
||||
promo_markdown,
|
||||
promo_position
|
||||
} = req.body;
|
||||
|
||||
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
|
||||
@@ -233,6 +243,11 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
// Get current watermark settings hash for change detection
|
||||
const oldSettingsHash = await watermarkService.getSettingsHash();
|
||||
|
||||
// Normalize promo_position: only 'above_footer' | 'below_footer' valid.
|
||||
const normalizedPromoPosition = promo_position === 'below_footer'
|
||||
? 'below_footer'
|
||||
: 'above_footer';
|
||||
|
||||
const brandingSettings = {
|
||||
company_name,
|
||||
company_tagline,
|
||||
@@ -252,7 +267,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
logo_display_hero,
|
||||
logo_display_mode,
|
||||
hide_powered_by,
|
||||
force_color_mode: normalizedForceColorMode
|
||||
force_color_mode: normalizedForceColorMode,
|
||||
// Footer overhaul (#441 + #440). String fields normalize empty/
|
||||
// undefined → '' so the column is always a known type. Only persist
|
||||
// when the request actually included the key (partial PUTs).
|
||||
...(facebook_url !== undefined && { facebook_url: String(facebook_url || '').trim() }),
|
||||
...(instagram_url !== undefined && { instagram_url: String(instagram_url || '').trim() }),
|
||||
...(whatsapp_url !== undefined && { whatsapp_url: String(whatsapp_url || '').trim() }),
|
||||
...(twitter_url !== undefined && { twitter_url: String(twitter_url || '').trim() }),
|
||||
...(youtube_url !== undefined && { youtube_url: String(youtube_url || '').trim() }),
|
||||
...(promo_markdown !== undefined && { promo_markdown: typeof promo_markdown === 'string' ? promo_markdown : '' }),
|
||||
...(promo_position !== undefined && { promo_position: normalizedPromoPosition })
|
||||
};
|
||||
|
||||
// Handle favicon deletion if empty string or null is provided
|
||||
|
||||
@@ -127,7 +127,12 @@ router.get('/:slug/info', async (req, res) => {
|
||||
'hero_divider_style',
|
||||
'hero_image_anchor',
|
||||
'is_draft',
|
||||
'default_photo_sort'
|
||||
'default_photo_sort',
|
||||
// Per-event promotional override (#440). Resolution into a
|
||||
// ready-to-render markdown string happens below so the
|
||||
// frontend doesn't have to know about modes.
|
||||
'promo_mode',
|
||||
'promo_markdown'
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -187,7 +192,11 @@ router.get('/:slug/info', async (req, res) => {
|
||||
header_style: event.header_style || 'standard',
|
||||
hero_divider_style: event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc'
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
|
||||
// Per-event promotional override (#440). Frontend resolves
|
||||
// 'inherit' against branding_promo_markdown from public settings.
|
||||
promo_mode: event.promo_mode || 'inherit',
|
||||
promo_markdown: event.promo_markdown || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
|
||||
@@ -33,6 +33,11 @@ router.get('/pages/:slug', async (req, res) => {
|
||||
// toggle can be flipped back on, but it shouldn't leak via the API).
|
||||
use_external_url: !!page.use_external_url,
|
||||
external_url: page.use_external_url && page.external_url ? page.external_url : null,
|
||||
// Footer visibility (#441). Default true for legacy rows; admins
|
||||
// can hide a CMS page from the gallery footer when their
|
||||
// jurisdiction doesn't require it (e.g. impressum / datenschutz
|
||||
// outside DE/AT).
|
||||
show_in_footer: page.show_in_footer !== false,
|
||||
updated_at: page.updated_at
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -65,6 +65,18 @@ router.get('/', async (req, res) => {
|
||||
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
|
||||
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
|
||||
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true,
|
||||
// Footer overhaul (#441 + #440). Empty strings hide each social
|
||||
// icon individually; promo_markdown empty hides the slot for
|
||||
// events in 'inherit' mode.
|
||||
branding_facebook_url: settingsObject.branding_facebook_url || '',
|
||||
branding_instagram_url: settingsObject.branding_instagram_url || '',
|
||||
branding_whatsapp_url: settingsObject.branding_whatsapp_url || '',
|
||||
branding_twitter_url: settingsObject.branding_twitter_url || '',
|
||||
branding_youtube_url: settingsObject.branding_youtube_url || '',
|
||||
branding_promo_markdown: settingsObject.branding_promo_markdown || '',
|
||||
branding_promo_position: settingsObject.branding_promo_position === 'below_footer'
|
||||
? 'below_footer'
|
||||
: 'above_footer',
|
||||
// Force a specific color mode site-wide. When set, the user toggle
|
||||
// is hidden and the value overrides per-theme/system preference.
|
||||
// Allowed values: 'dark' | 'light' | null (null = no force).
|
||||
|
||||
Reference in New Issue
Block a user