Files
picpeak/backend/migrations/core/089_footer_overhaul.js
T
Paul Nothaft 3a731e7c95 feat(footer): hideable legal links + socials + promo banner (#441 + #440)
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.
2026-05-10 21:46:20 +02:00

120 lines
4.8 KiB
JavaScript

/**
* 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();
};