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.
This commit is contained in:
Paul Nothaft
2026-05-10 21:46:20 +02:00
parent c52c1c8419
commit 3a731e7c95
25 changed files with 778 additions and 93 deletions
@@ -0,0 +1,68 @@
import React, { useMemo } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
interface MarkdownContentProps {
/** Raw markdown source. Empty/null → renders nothing. */
source?: string | null;
/** Wrapper className. Default lets the consumer style spacing. */
className?: string;
}
// Sanitization allowlist — kept tight on purpose. The promo slot is
// admin-authored content rendered to gallery visitors, so the surface
// has to be conservative. Markdown source means contributors don't
// hand-write HTML in the first place; this is defense-in-depth.
const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'b', 'i', 'u', 'del', 's',
'a', 'ul', 'ol', 'li', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'code', 'pre',
];
const ALLOWED_ATTR = ['href', 'title', 'target', 'rel'];
// Marked configuration:
// gfm: true → autolinks, strikethrough, tables (we don't allow tables in ALLOWED_TAGS so they sanitize out cleanly)
// breaks: true → newline = <br> (matches what an admin types in a textarea)
// pedantic: false → modern interpretation of edge cases
marked.setOptions({ gfm: true, breaks: true, pedantic: false });
/**
* Render admin-authored markdown safely. Used by the gallery footer
* promotional slot (#440) and any future admin-content surface that
* needs more than plain text but less than a full WYSIWYG editor.
*
* Pipeline: marked.parse → DOMPurify with the allowlist above. Returns
* null when the source is empty so callers can use it inline without
* a wrapper-when-empty problem.
*/
export const MarkdownContent: React.FC<MarkdownContentProps> = ({ source, className }) => {
const html = useMemo(() => {
const md = (source ?? '').trim();
if (!md) return '';
const raw = marked.parse(md, { async: false }) as string;
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true,
// Force external links to noopener/noreferrer so admin-set URLs
// can't tab-nap the gallery context.
ADD_ATTR: ['target', 'rel'],
});
}, [source]);
if (!html) return null;
return (
<div
className={className}
// Add target=_blank + rel safety to all <a> after sanitize. Doing
// this with a hook would be cleaner but DOMPurify lacks a generic
// "set attribute on tag" hook in this version, so post-process.
dangerouslySetInnerHTML={{ __html: html.replace(
/<a /g,
'<a target="_blank" rel="noopener noreferrer nofollow" '
) }}
/>
);
};
+1
View File
@@ -23,3 +23,4 @@ export { ProtectedImage } from './ProtectedImage';
export { ProtectionWarning } from './ProtectionWarning';
export { ReCaptcha } from './ReCaptcha';
export { PasswordGenerator } from './PasswordGenerator';
export { MarkdownContent } from './MarkdownContent';