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) { diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index db3c769e..d034818a 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { Calendar, Clock, Download, LogOut } from 'lucide-react'; import { parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; @@ -9,6 +10,7 @@ import { DynamicFavicon } from '../common/DynamicFavicon'; import { useTheme } from '../../contexts/ThemeContext'; import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; import { buildResourceUrl } from '../../utils/url'; +import { cmsService, type PublicCMSPage } from '../../services/cms.service'; import type { HeaderStyleType } from '../../types/theme.types'; interface GalleryLayoutProps { @@ -62,6 +64,22 @@ export const GalleryLayout: React.FC = ({ const { theme } = useTheme(); const guestIdentity = useGuestIdentityOptional(); + // Footer legal-link config. Cached aggressively because the toggle state + // changes rarely and the gallery footer renders on every page view. + // Failures fall back to the internal /impressum and /datenschutz routes. + const { data: impressumPage } = useQuery({ + queryKey: ['public-cms', 'impressum'], + queryFn: () => cmsService.getPublicPage('impressum'), + staleTime: 5 * 60 * 1000, + retry: false, + }); + const { data: datenschutzPage } = useQuery({ + queryKey: ['public-cms', 'datenschutz'], + queryFn: () => cmsService.getPublicPage('datenschutz'), + staleTime: 5 * 60 * 1000, + retry: false, + }); + // Determine header style - use prop first (from event data), then theme, then fall back to 'standard' const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard'; const isHeroHeader = headerStyle === 'hero'; @@ -592,19 +610,41 @@ export const GalleryLayout: React.FC = ({ )} {/* Legal Links */}
- - {t('legal.impressum')} - + {impressumPage?.use_external_url && impressumPage.external_url ? ( + + {t('legal.impressum')} + + ) : ( + + {t('legal.impressum')} + + )} | - - {t('legal.datenschutz')} - + {datenschutzPage?.use_external_url && datenschutzPage.external_url ? ( + + {t('legal.datenschutz')} + + ) : ( + + {t('legal.datenschutz')} + + )} {guestIdentity?.identity && ( <> | diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f0cc2ded..53a8641a 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2210,7 +2210,13 @@ "lastUpdated": "Zuletzt aktualisiert:", "impressum": "Impressum", "datenschutz": "Datenschutzerklärung", - "pageUpdated": "Seite erfolgreich aktualisiert" + "pageUpdated": "Seite erfolgreich aktualisiert", + "useExternalUrl": "Externe URL verwenden", + "useExternalUrlHelp": "Besucher werden auf eine externe Seite weitergeleitet, anstatt die internen Inhalte anzuzeigen. Titel und Inhalt bleiben als Fallback gespeichert.", + "externalUrl": "Externe URL", + "externalUrlPlaceholder": "https://example.com/impressum", + "externalUrlInvalid": "Muss eine gültige https://-URL sein", + "externalUrlActive": "Externe URL ist aktiv — interne Inhalte bleiben gespeichert, werden Besuchern aber nicht angezeigt." }, "validation": { "eventNameRequired": "Veranstaltungsname ist erforderlich", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e03fe273..59da8cfb 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1759,7 +1759,13 @@ "lastUpdated": "Last updated:", "impressum": "Legal Notice", "datenschutz": "Privacy Policy", - "pageUpdated": "Page updated successfully" + "pageUpdated": "Page updated successfully", + "useExternalUrl": "Use external URL", + "useExternalUrlHelp": "Redirect visitors to an external page instead of showing the internal content. The internal title and content stay saved as a fallback.", + "externalUrl": "External URL", + "externalUrlPlaceholder": "https://example.com/impressum", + "externalUrlInvalid": "Must be a valid https:// URL", + "externalUrlActive": "External URL is active — internal content is preserved but not shown to visitors." }, "eventTypes": { "title": "Event Types", diff --git a/frontend/src/pages/admin/CMSPage.tsx b/frontend/src/pages/admin/CMSPage.tsx index 40c75fbe..227c7f73 100644 --- a/frontend/src/pages/admin/CMSPage.tsx +++ b/frontend/src/pages/admin/CMSPage.tsx @@ -182,6 +182,31 @@ export const CMSPage: React.FC = () => { setHasUnsavedChanges(true); }; + const handleUseExternalUrlChange = (val: boolean) => { + setEditForm(prev => ({ ...prev, use_external_url: val })); + setHasUnsavedChanges(true); + }; + + const handleExternalUrlChange = (val: string) => { + setEditForm(prev => ({ ...prev, external_url: val })); + setHasUnsavedChanges(true); + }; + + // Inline URL validation. Empty input while typing is not an error + // (avoid flagging mid-edit). Backend re-validates on save regardless. + const externalUrlError = useMemo(() => { + if (!editForm.use_external_url) return null; + const v = (editForm.external_url || '').trim(); + if (!v) return null; + try { + const u = new URL(v); + if (u.protocol !== 'https:') return t('cms.externalUrlInvalid'); + return null; + } catch { + return t('cms.externalUrlInvalid'); + } + }, [editForm.use_external_url, editForm.external_url, t]); + // Per-page logo upload (#324). Only meaningful for the customisable // error pages right now, but harmless if exposed for any slug. const logoInputRef = useRef(null); @@ -607,6 +632,41 @@ export const CMSPage: React.FC = () => {
+ {/* External URL override */} +
+ + {editForm.use_external_url && ( +
+ handleExternalUrlChange(e.target.value)} + placeholder={t('cms.externalUrlPlaceholder')} + error={externalUrlError || undefined} + /> +

+ {t('cms.externalUrlActive')} +

+
+ )} +
+ {/* Title */}