Files
tsv08kulmbach-website/app/helpers.php
fs 913c058c93 send_mail: Erfolg als INFO ins mail-Log
Bisher schrieb nur der Fehlerfall. Damit war ein funktionierender Versand von
einem stummen nicht zu unterscheiden: beim Deploy am 31.07.2026 zeigte
bin/logs.php gar keinen mail-Kanal, was "nichts abgeschickt" und "einwandfrei
versandt" gleichzeitig bedeuten konnte.

Geloggt wird nur der Kontext (welches Formular) — die Werte sind feste Labels
wie "Kontaktanfrage" oder "Sportheimbuchung", nie Empfänger, Absender oder
Inhalt. Das verbietet die Logging-Regel, und für "läuft der Versand?" braucht es
davon auch nichts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:07:51 +02:00

1724 lines
68 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Config-Wert holen: config('smtp.host') oder config() für das ganze Array.
*/
function config(?string $key = null, mixed $default = null): mixed
{
$value = $GLOBALS['__config'];
if ($key === null) {
return $value;
}
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
/**
* Deploy-Selbstprüfung: harte Konfigurationsprobleme, die den Betrieb kaputt oder
* unsicher machen. Leeres Array = alles gesetzt. Genutzt von bin/preflight.php und
* als Not-Aus in den Formular-Actions: fehlt config/config.php, läuft bootstrap.php
* auf config.example.php weiter — deren app_secret steht öffentlich im Repo, die
* Time-Trap wäre also wertlos und der Mailversand würde ohnehin scheitern.
*/
function config_problems(): array
{
$problems = [];
if (!is_file(ROOT_PATH . '/config/config.php')) {
$problems[] = 'config/config.php fehlt — die Seite läuft auf der Vorlage config.example.php.';
}
$secret = (string) config('app_secret');
if ($secret === '' || $secret === 'CHANGE_ME' || strlen($secret) < 32) {
$problems[] = 'app_secret fehlt oder ist zu kurz/Platzhalter (neu: php -r "echo bin2hex(random_bytes(32));").';
}
$smtp = (array) config('smtp', []);
foreach (['host', 'port', 'username', 'password', 'from', 'to'] as $key) {
if (empty($smtp[$key])) {
$problems[] = "smtp.{$key} ist nicht gesetzt.";
}
}
if (in_array((string) ($smtp['password'] ?? ''), ['POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
$problems[] = 'smtp.password ist noch der Platzhalter aus der Vorlage.';
}
return $problems;
}
/**
* HTML-Escaping — für JEDE dynamische Ausgabe verwenden.
*/
function e(string|int|float|null $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/**
* Interner Link aus Slug: url('fussball') → '/fussball', url('') → '/'.
*/
function url(string $slug = ''): string
{
return '/' . trim($slug, '/');
}
/**
* Absolute URL für Canonical, OG und Sitemap.
*/
function abs_url(string $slug = ''): string
{
return rtrim((string) config('base_url'), '/') . url($slug);
}
/**
* Absolute URL auf der ÖFFENTLICHEN Vereinsadresse (`club.website`), unabhängig
* davon, wo dieser Code gerade läuft.
*
* Nicht dasselbe wie abs_url(): `base_url` ist bewusst umgebungsabhängig, weil
* Canonical, OG und Sitemap zur ausliefernden Adresse passen müssen. Gedruckte
* Dinge kennen dagegen kein „lokal" — ein QR-Code auf Papier und die
* Internet-Zeile im Heft-Impressum zeigen immer auf die echte Domain. Vorher
* liefen sie über base_url und trugen deshalb `http://localhost:8000` ins
* Druckmaterial.
*
* Fällt `club.website` weg, greift abs_url() — ein fehlendes Feld darf keinen
* kaputten Link erzeugen.
*/
function club_url(string $slug = ''): string
{
$website = (string) (json_load('club')['website'] ?? '');
return $website !== '' ? rtrim($website, '/') . url($slug) : abs_url($slug);
}
/**
* Die öffentliche Adresse ohne Schema und ohne „www", zum Hinschreiben
* (Cover, Impressum, Plakate): `tsv08kulmbach.de/stadionzeitung`.
*/
function club_domain(string $slug = ''): string
{
$ohneSchema = (string) preg_replace('~^https?://(www\.)?~', '', club_url($slug));
// Ohne Pfad soll „tsv08kulmbach.de" stehen, nicht „tsv08kulmbach.de/" —
// url('') liefert den Wurzel-Slash, der hier nur Lärm wäre.
return $slug === '' ? rtrim($ohneSchema, '/') : $ohneSchema;
}
/**
* Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
*
* $bust = false gibt den Pfad ohne ?v= zurück. Nötig für Ressourcen, die auch
* aus statischem CSS heraus angefordert werden (Fonts via @font-face in
* base.css): eine abweichende Query macht daraus für den Browser eine zweite
* URL, der Preload passt dann nicht zum @font-face-Request und läuft ins Leere
* („preloaded but not used"). Geänderte Font-Dateien bekommen deshalb einen
* neuen Dateinamen statt einer neuen Version.
*/
function asset(string $path, bool $bust = true): string
{
$path = ltrim($path, '/');
if (!$bust) {
return '/assets/' . $path;
}
$file = PUBLIC_PATH . '/assets/' . $path;
$version = is_file($file) ? (string) filemtime($file) : '0';
return '/assets/' . $path . '?v=' . $version;
}
/**
* Bootstrap-Icon als Inline-SVG ausgeben (lokal aus public/assets/icons/<name>.svg).
* Standard: dekorativ (aria-hidden). Mit $label wird es als beschriftetes Bild
* (role="img") ausgegeben. Größe/Farbe steuert CSS über die Klasse .icon.
* Quelle: Bootstrap Icons (MIT) — neue Icons via bin/icons-add.php hinzufügen.
*/
function icon(string $name, string $class = '', ?string $label = null): string
{
static $cache = [];
if (!array_key_exists($name, $cache)) {
$file = PUBLIC_PATH . '/assets/icons/' . basename($name) . '.svg';
$cache[$name] = is_file($file) ? trim((string) file_get_contents($file)) : '';
}
if ($cache[$name] === '') {
return '';
}
$attrs = 'class="icon' . ($class !== '' ? ' ' . e($class) : '') . '"';
$attrs .= $label !== null && $label !== ''
? ' role="img" aria-label="' . e($label) . '"'
: ' aria-hidden="true" focusable="false"';
return preg_replace('/<svg\b/', '<svg ' . $attrs, $cache[$name], 1);
}
/**
* JSON-Datendatei laden (data/<name>.json) mit Request-weitem Cache.
* Wirft bei kaputtem JSON — Datenfehler sollen laut scheitern, nicht leise.
*/
function json_load(string $name): array
{
static $cache = [];
if (!array_key_exists($name, $cache)) {
$file = DATA_PATH . '/' . $name . '.json';
if (!is_file($file)) {
return [];
}
$cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
}
return $cache[$name];
}
/**
* Intrinsische Bildmaße als ' width="…" height="…"' für CLS-freie <img>-Tags.
* $rel = Pfad relativ zu public/assets/. PNG/JPG via getimagesize, SVG via
* viewBox (Fallback: width/height-Attribute, Einheiten werden ignoriert —
* fürs Seitenverhältnis reicht die Zahl). Liefert '' wenn nicht bestimmbar.
*/
function img_intrinsic_attrs(string $rel): string
{
static $cache = [];
if (!isset($cache[$rel])) {
$file = PUBLIC_PATH . '/assets/' . ltrim($rel, '/');
$w = $h = 0;
if (is_file($file)) {
if (str_ends_with(strtolower($file), '.svg')) {
$svg = (string) file_get_contents($file);
if (preg_match('/viewBox="\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)/', $svg, $m)) {
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
} elseif (preg_match('/<svg\b[^>]*\bwidth="([\d.]+)[a-z%]*"[^>]*\bheight="([\d.]+)[a-z%]*"/s', $svg, $m)) {
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
}
} else {
[$w, $h] = (getimagesize($file) ?: [0, 0]);
}
}
$cache[$rel] = ($w > 0 && $h > 0) ? ' width="' . $w . '" height="' . $h . '"' : '';
}
return $cache[$rel];
}
/**
* Responsive Bild-Varianten erzeugen (GD, läuft auch auf Shared Hosting) —
* schreibt <destBase>-<breite>.jpg je Breite, skaliert nie hoch. Geteilte
* Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php
* (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten).
* Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], …].
*
* $alpha = true schreibt stattdessen <destBase>-<breite>.png und erhält die
* Transparenz — nötig für freigestellte Motive (Spielerfoto auf dem
* Stadionzeitungs-Cover), bei denen JPG die Freistellung gegen Schwarz wegrechnen
* würde. $quality gilt dann nicht (PNG ist verlustfrei). Der Default bleibt JPG,
* weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern und deren
* Pfade in den data/*.json auf .jpg zeigen.
*/
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78, bool $alpha = false): array
{
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => imagecreatefromjpeg($src),
'png' => imagecreatefrompng($src),
'webp' => imagecreatefromwebp($src),
default => null,
};
if (!$image) {
throw new RuntimeException("Kann Quelle nicht lesen: {$src}");
}
$srcW = imagesx($image);
$srcH = imagesy($image);
$out = [];
foreach ($widths as $width) {
$w = min((int) $width, $srcW); // nie hochskalieren
$h = (int) round($srcH * $w / $srcW);
$frame = imagecreatetruecolor($w, $h);
if ($alpha) {
// Transparenz retten: ohne Blending kopieren (sonst rechnet GD den
// Alphakanal gegen Schwarz weg) und die Fläche vorher transparent füllen.
imagealphablending($frame, false);
imagesavealpha($frame, true);
$leer = (int) imagecolorallocatealpha($frame, 0, 0, 0, 127);
imagefilledrectangle($frame, 0, 0, $w, $h, $leer);
}
imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
if ($alpha) {
$file = "{$destBase}-{$w}.png";
imagepng($frame, $file, 9);
} else {
$file = "{$destBase}-{$w}.jpg";
imagejpeg($frame, $file, $quality);
}
$out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)];
}
return $out;
}
/**
* Freigestelltes PNG auf den Inhalt zuschneiden (Alpha-Bounding-Box + kleiner
* Rand) — Stadionzeitung-Cover: Export-Tools lassen um eine Freistellung oft
* viel leeren, transparenten Rand stehen (z. B. 17% oben, ~1820% seitlich),
* dadurch wirkt das Spielerfoto auf dem Cover kleiner als nötig. $padding =
* Rand um die erkannte Fläche, Anteil der jeweiligen Kantenlänge (0.02 = 2%).
* Liefert false bei Fehlern (kein lesbares PNG, komplett transparent) statt
* zu werfen — das Spielerfoto ist ein "nice to have" fürs Cover, ein
* Fehlschlag darf die Ausgabe nicht scheitern lassen.
*/
function crop_transparent_png_to_content(string $src, string $dest, float $padding = 0.02): bool
{
$im = @imagecreatefrompng($src);
if (!$im) {
return false;
}
imagealphablending($im, false);
imagesavealpha($im, true);
$w = imagesx($im);
$h = imagesy($im);
$minX = $w;
$minY = $h;
$maxX = 0;
$maxY = 0;
$found = false;
for ($y = 0; $y < $h; $y++) {
for ($x = 0; $x < $w; $x++) {
$alpha = (imagecolorat($im, $x, $y) >> 24) & 0x7F; // 0=opak, 127=transparent
if ($alpha < 110) {
$found = true;
$minX = min($minX, $x);
$maxX = max($maxX, $x);
$minY = min($minY, $y);
$maxY = max($maxY, $y);
}
}
}
if (!$found) {
return false;
}
$padX = (int) round(($maxX - $minX) * $padding);
$padY = (int) round(($maxY - $minY) * $padding);
$cropX = max(0, $minX - $padX);
$cropY = max(0, $minY - $padY);
$cropW = min($w - $cropX, $maxX - $minX + 1 + 2 * $padX);
$cropH = min($h - $cropY, $maxY - $minY + 1 + 2 * $padY);
$out = imagecreatetruecolor($cropW, $cropH);
imagealphablending($out, false);
imagesavealpha($out, true);
$transparent = imagecolorallocatealpha($out, 0, 0, 0, 127);
imagefill($out, 0, 0, $transparent);
imagecopy($out, $im, 0, 0, $cropX, $cropY, $cropW, $cropH);
return imagepng($out, $dest) && is_file($dest);
}
/**
* QR-Code als PNG erzeugen (Stadionzeitung-Cover: Link zur digitalen Ausgabe).
* Nutzt die manuell vendorte "PHP QR Code"-Bibliothek (vendor-manual/phpqrcode,
* GD-basiert, kein neues PHP-Modul, kein Composer) statt eines Laufzeit-Diensts.
* Die Bibliothek ist alter PHP-5-Code und meldet auf PHP 8 pro Aufruf ein paar
* Deprecation-Notices zur Parameter-Reihenfolge — schon beim `require` (nicht
* erst beim Aufruf), deshalb error_reporting() um beides herum runtergesetzt.
* Genau dasselbe Muster wie bei der vendorten Files-Gallery (siehe
* public/dateien/_filesconfig.php, dort `display_errors = 0`).
* QR ist ein "nice to have" auf dem Cover: Rückgabe false statt Exception,
* ein Fehlschlag darf die Ausgabe nicht scheitern lassen.
*
* $scale = Pixel pro QR-Modul. Default 6 ergibt gut 200px — richtig für die
* Stadionzeitungs-Seiten, viel zu klein für Druckmaterial. Für Plakate und
* Aushänge deutlich höher gehen (siehe bin/qr.php).
*/
function generate_qr_png(string $data, string $destPath, int $scale = 6): bool
{
$lib = ROOT_PATH . '/vendor-manual/phpqrcode/phpqrcode.php';
if (!is_file($lib)) {
return false;
}
$previous = error_reporting(E_ALL & ~E_DEPRECATED);
try {
require_once $lib;
QRcode::png($data, $destPath, QR_ECLEVEL_M, max(1, $scale), 2);
} catch (Throwable) {
return false;
} finally {
error_reporting($previous);
}
return is_file($destPath);
}
/**
* Anzeigewerte zu einem Anstoß. $kickoff = Zeit ohne Zonenangabe aus
* data/matchcenter.json (z. B. 2026-08-01T14:00), gelesen als Europe/Berlin —
* PHP läuft hier global auf UTC, deshalb explizit. Liefert:
* iso maschinenlesbar mit Zone (für <time datetime> und den JS-Ticker)
* countdown „heute, 14:00 Uhr" | „morgen, 14:00 Uhr" | „in 6 Tagen" | „in 3 Wochen"
* Unlesbarer Wert → beide Felder leer; bereits angestoßenes Spiel → countdown leer.
*
* $with_time = false lässt die Uhrzeit im Countdown weg („heute" statt
* „heute, 00:00 Uhr") — für ganztägige Vereins-Events aus data/events.json, die
* keine time-Angabe haben (siehe event-feature-Komponente). Spiele haben immer
* einen Anstoß, für sie bleibt der Default.
*/
function match_when(string $kickoff, bool $with_time = true): array
{
if ($kickoff === '') {
return ['iso' => '', 'countdown' => ''];
}
$tz = new DateTimeZone('Europe/Berlin');
try {
$start = new DateTimeImmutable($kickoff, $tz);
} catch (Exception) {
return ['iso' => '', 'countdown' => ''];
}
$now = new DateTimeImmutable('now', $tz);
if ($start <= $now) {
return ['iso' => $start->format('c'), 'countdown' => ''];
}
// Kalendertage zählen, nicht 24-Stunden-Blöcke: ein Anstoß morgen um 14 Uhr
// ist „morgen", auch wenn es nur 20 Stunden hin sind.
$days = (int) $now->setTime(0, 0)->diff($start->setTime(0, 0))->format('%a');
$time = $with_time ? ', ' . $start->format('H:i') . ' Uhr' : '';
return [
'iso' => $start->format('c'),
'countdown' => match (true) {
$days === 0 => 'heute' . $time,
$days === 1 => 'morgen' . $time,
$days < 14 => 'in ' . $days . ' Tagen',
default => 'in ' . (int) round($days / 7) . ' Wochen',
},
];
}
/**
* Deutsches Langdatum aus einem ISO-Datum (YYYY-MM-DD), z. B. "13. September 2025".
* Kein IntlDateFormatter im Projekt (kein ext-intl-Vorbild) — feste Monatsnamen
* reichen für einsprachiges Deutsch. Unlesbares Datum → Rohwert zurück.
*/
function german_date(string $isoDate): string
{
static $months = [
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni',
7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
];
$date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
if ($date === false) {
return $isoDate;
}
return (int) $date->format('j') . '. ' . $months[(int) $date->format('n')] . ' ' . $date->format('Y');
}
/**
* Wie german_date(), aber für mehrtägige Events (data/events.json: date_end).
* Gleicher Monat/Jahr → "4.6. September 2026"; unterschiedlicher Monat →
* "28. August 3. September 2026"; unterschiedliches Jahr → volles Datum beidseitig.
* $end === null oder === $start → einfaches german_date().
*/
function german_date_range(string $start, ?string $end): string
{
if ($end === null || $end === '' || $end === $start) {
return german_date($start);
}
$startDate = DateTimeImmutable::createFromFormat('Y-m-d', $start);
$endDate = DateTimeImmutable::createFromFormat('Y-m-d', $end);
if ($startDate === false || $endDate === false) {
return german_date($start);
}
if ($startDate->format('Y') !== $endDate->format('Y')) {
return german_date($start) . ' ' . german_date($end);
}
if ($startDate->format('n') !== $endDate->format('n')) {
static $months = [
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni',
7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
];
return (int) $startDate->format('j') . '. ' . $months[(int) $startDate->format('n')]
. ' ' . german_date($end);
}
return (int) $startDate->format('j') . '.' . german_date($end);
}
/**
* Kompaktes Datums-Chip fürs termine-list (Tag groß + Monat kurz), z. B. für
* den 4. September: ['day' => '4', 'month' => 'Sep']. Unlesbares Datum → leere
* Strings (Aufrufer zeigt dann einfach nichts an, kein Fataler Fehler).
*/
function german_date_chip(string $isoDate): array
{
static $months = [
1 => 'Jan', 2 => 'Feb', 3 => 'Mär', 4 => 'Apr', 5 => 'Mai', 6 => 'Jun',
7 => 'Jul', 8 => 'Aug', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Dez',
];
$date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
if ($date === false) {
return ['day' => '', 'month' => ''];
}
return ['day' => $date->format('j'), 'month' => $months[(int) $date->format('n')]];
}
/**
* Deutscher Wochentag aus einem ISO-Datum, z. B. "Freitag". Ergänzt
* german_date()/german_date_chip() um die eine Angabe, die ein Tagesprogramm
* braucht (Veranstaltungs-Programm: „Freitag, 4. September"). Kein
* IntlDateFormatter im Projekt, deshalb feste Namen wie bei den Monaten.
* Unlesbares Datum → leerer String, der Aufrufer lässt es dann weg.
*/
function german_weekday(string $isoDate): string
{
static $days = [
1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag',
5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag',
];
$date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
if ($date === false) {
return '';
}
return $days[(int) $date->format('N')];
}
/**
* Anzeigename eines Wettbewerbs aus data/matchcenter.json. Die BFV-API kennt nur
* „Liga" und „Freundschaft": „Liga" wird durch den echten Liganamen ersetzt — ohne
* $league bleibt sie leer, damit Ligaspiele in den Mannschafts-Sektionen nicht
* redundant beschriftet werden (die Sektion nennt die Liga schon). „Freundschaft"
* wird ausgeschrieben, damit erkennbar ist, warum ein Testspiel nicht in der
* Tabelle zählt.
*/
function match_competition_label(string $competition, string $league = ''): string
{
return match ($competition) {
'Liga' => $league,
'Freundschaft' => 'Freundschaftsspiel',
default => $competition,
};
}
/**
* Alle last_results über sämtliche Teams gemergt, mit team_key + team_name
* angereichert (last_results[] hat das anders als upcoming[] nicht schon
* eingebacken). Genutzt von /termine (Ergebnis-Zeilen) und /news
* (Ergebnis-Karten) — derselbe Datenschnitt, zwei Darstellungen, nie doppelt
* gepflegt. $mc = json_load('matchcenter').
*/
function matchcenter_recent_results(array $mc): array
{
$results = [];
foreach ($mc['teams'] ?? [] as $team) {
foreach ($team['last_results'] ?? [] as $result) {
$result['team_key'] = $team['key'] ?? '';
$result['team_name'] = $team['name'] ?? '';
$results[] = $result;
}
}
return $results;
}
/**
* Anzeigename eines Vereinsbereichs (department-Schlüssel aus data/events.json
* bzw. der normalisierten Termine-Form). Genutzt von der termine-list-Komponente
* (Badge) und vom Hero-Aufmacher (Slide-Beschriftung) — dieselben Labels, eine
* Stelle. Unbekannter Schlüssel → Rohwert, damit ein neuer Bereich sichtbar
* bleibt statt zu verschwinden.
*/
function department_label(string $department): string
{
return match ($department) {
'fussball' => 'Fußball',
'turnen' => 'Turnen',
'verein' => 'Verein',
default => $department,
};
}
/**
* Ist eine Veranstaltung veröffentlicht? Gleiche Semantik wie
* stadionzeitung_is_published(): ein fehlendes status-Feld gilt als
* veröffentlicht, damit ein Eintrag ohne das Feld nie unsichtbar wird.
*/
function veranstaltung_is_published(array $veranstaltung): bool
{
return ($veranstaltung['status'] ?? 'veroeffentlicht') !== 'entwurf';
}
/**
* Alle veröffentlichten Veranstaltungen (data/veranstaltungen.json), neueste
* zuerst. EINZIGE Ladestelle für die Übersicht /veranstaltungen, den
* Termine-Merge und die Sitemap — so fallen Entwürfe an einer Stelle heraus
* statt an drei. Die Detailseite (app/pages/veranstaltung.php) lädt bewusst
* ungefiltert: ein Entwurf muss unter seiner direkten URL lesbar bleiben,
* sonst könnte ihn niemand gegenlesen.
*/
function veranstaltungen_load(): array
{
$items = array_values(array_filter(
json_load('veranstaltungen')['veranstaltungen'] ?? [],
'veranstaltung_is_published'
));
usort(
$items,
static fn (array $a, array $b): int => (string) ($b['date_start'] ?? '') <=> (string) ($a['date_start'] ?? '')
);
return $items;
}
/**
* Steht ein Termin zum Bezugsdatum noch an? Maßgeblich ist das ENDE, nicht der
* Beginn: ein mehrtägiges Fest (Sportfest, Freitag bis Sonntag) gehört am
* Samstag noch zu den kommenden Terminen und nicht schon zu den vergangenen.
* Gemeinsame Regel für events.json, veranstaltungen.json und die Entscheidung
* „Ankündigung oder Rückblick" auf der Veranstaltungs-Detailseite.
*/
function termin_is_upcoming(array $entry, string $today): bool
{
$end = (string) ($entry['date_end'] ?? '');
$reference = $end !== '' ? $end : (string) ($entry['date_start'] ?? '');
return $reference >= $today;
}
/**
* Baut die zusammengeführten Termine-Listen (Fußball-Spiele + manuelle
* Vereins-Events + Veranstaltungen mit eigener Detailseite) — gemeinsame
* Grundlage für app/pages/termine.php UND die Stadionzeitungs-Live-Seite
* „termine" (Phase 3), damit nie zwei Merge-Implementierungen auseinanderlaufen.
*
* DREI Quellen, jede genau einmal (siehe CLAUDE.md): Spiele aus
* data/matchcenter.json, Termine ohne eigene Seite aus data/events.json,
* Termine MIT eigener Seite aus data/veranstaltungen.json. Ein Fest darf
* niemals in beiden JSON-Dateien stehen, sonst steht es doppelt in der Liste.
* Rückgabe:
* upcoming normalisierte Termine-Form, aufsteigend sortiert
* past normalisierte Termine-Form, absteigend sortiert
* upcoming_matches rohe $mc['upcoming'] (für sportsevent_nodes())
* events rohe data/events.json (für event_schema())
* veranstaltungen rohe data/veranstaltungen.json (für event_schema() mit
* Slug, damit der Knoten seine eigene URL trägt)
*
* $asOf (YYYY-MM-DD, leer = heute): Bezugsdatum für „kommend"/„vergangen".
* Für die Website immer heute (Default). Die Stadionzeitung braucht dagegen
* einen FESTEN Stand zum Zeitpunkt des Ausgaben-Anlegens (bin/stadionzeitung-
* add.php ruft mit dem Spieltag auf und friert das Ergebnis in
* `termine_snapshot` ein) — eine spätere, viel später aufgerufene Ausgabe
* soll weiter zeigen, was am Spieltag anstand, nicht was heute ansteht.
*/
function termine_build_lists(string $asOf = ''): array
{
$events = json_load('events')['events'] ?? [];
$veranstaltungen = veranstaltungen_load();
$mc = json_load('matchcenter');
$upcomingMatches = $mc['upcoming'] ?? [];
$teams = $mc['teams'] ?? [];
$today = $asOf !== '' ? $asOf : date('Y-m-d');
// Bei einem Bezugsdatum in der Vergangenheit (Stadionzeitungs-Snapshot)
// auch Spiele rausfiltern, die zu dem Zeitpunkt schon gelaufen wären —
// $mc['upcoming'] selbst kennt nur den AKTUELLEN Sync-Stand, nicht den
// historischen Stand am Spieltag der Ausgabe.
if ($asOf !== '') {
$upcomingMatches = array_values(array_filter(
$upcomingMatches,
static fn (array $m): bool => substr((string) ($m['kickoff'] ?? ''), 0, 10) >= $asOf
));
}
// team_key wird gebraucht, damit normalizeMatch() den Liganamen nachschlagen
// kann (upcoming[]-Einträge haben das schon eingebacken, last_results[] nicht).
$leagueByTeamKey = [];
foreach ($teams as $team) {
$leagueByTeamKey[$team['key'] ?? ''] = $team['league'] ?? '';
}
$pastMatches = matchcenter_recent_results($mc);
$normalizeMatch = static function (array $m) use ($leagueByTeamKey): array {
$isHome = !empty($m['is_home']);
$league = $leagueByTeamKey[$m['team_key'] ?? ''] ?? '';
$competition = match_competition_label((string) ($m['competition'] ?? ''), $league);
$meta = trim(($isHome ? 'Heimspiel' : 'Auswärtsspiel') . ($competition !== '' ? ' · ' . $competition : ''));
if (isset($m['goals_home'], $m['goals_away'])) {
$meta .= ' · ' . (int) $m['goals_home'] . ':' . (int) $m['goals_away'];
}
return [
'date' => substr((string) ($m['kickoff'] ?? ''), 0, 10),
'date_end' => null,
'title' => trim(($m['home'] ?? '') . ' ' . ($m['away'] ?? '')),
'department' => 'fussball',
'type' => 'spiel',
'meta' => $meta,
'href' => url('matchcenter'),
// Zusatzfelder für Darstellungen, die mehr als die Zeile brauchen
// (Hero-Aufmacher rendert daraus die match-feature-Karte mit Wappen).
// termine-list ignoriert sie.
'kickoff' => (string) ($m['kickoff'] ?? ''),
'team_name' => (string) ($m['team_name'] ?? ''),
'league' => $league,
'raw' => $m,
];
};
// $href bleibt für data/events.json leer (kein Ziel, die termine-list rendert
// die Zeile dann als <div>). Veranstaltungen aus data/veranstaltungen.json
// haben eine Detailseite und bekommen sie hier gesetzt — dieselbe
// Normalisierung, nur mit Ziel.
$normalizeEvent = static function (array $e, ?string $href = null): array {
$meta = trim(
(!empty($e['time']) ? $e['time'] . ' Uhr' : '')
. (!empty($e['time']) && !empty($e['location']) ? ' · ' : '')
. (string) ($e['location'] ?? '')
);
return [
'date' => (string) ($e['date_start'] ?? ''),
'date_end' => $e['date_end'] ?? null,
'title' => (string) ($e['title'] ?? ''),
'department' => (string) ($e['department'] ?? 'verein'),
'type' => 'event',
'meta' => $meta,
'href' => $href,
// s. Kommentar bei $normalizeMatch — Zusatzfelder für reichere
// Darstellungen (Hero-Aufmacher), von termine-list ignoriert.
'time' => (string) ($e['time'] ?? ''),
'location' => (string) ($e['location'] ?? ''),
'raw' => $e,
];
};
$normalizeVeranstaltung = static fn (array $v): array => $normalizeEvent(
$v,
url('veranstaltungen/' . (string) ($v['slug'] ?? ''))
);
$isUpcoming = static fn (array $e): bool => termin_is_upcoming($e, $today);
$isPast = static fn (array $e): bool => !termin_is_upcoming($e, $today);
$upcoming = array_merge(
array_map($normalizeMatch, $upcomingMatches),
array_map($normalizeEvent, array_filter($events, $isUpcoming)),
array_map($normalizeVeranstaltung, array_filter($veranstaltungen, $isUpcoming))
);
usort($upcoming, static fn (array $a, array $b): int => $a['date'] <=> $b['date']);
$past = array_merge(
array_map($normalizeMatch, $pastMatches),
array_map($normalizeEvent, array_filter($events, $isPast)),
array_map($normalizeVeranstaltung, array_filter($veranstaltungen, $isPast))
);
usort($past, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);
return [
'upcoming' => $upcoming,
'past' => $past,
'upcoming_matches' => $upcomingMatches,
// Roh-Quellen für event_schema(): getrennt, obwohl beide dieselben
// Feldnamen tragen. Nur eine Veranstaltung hat eine eigene URL, und die
// muss als @id/url in den Knoten — sonst beschreiben /termine und die
// Detailseite dasselbe Fest als zwei unabhängige Events.
'events' => $events,
'veranstaltungen' => $veranstaltungen,
];
}
/**
* Die nächsten Termine als Aufmacher für den Startseiten-Hero — Spiele und
* Vereins-Events chronologisch gemischt, in genau der Reihenfolge, in der sie
* anstehen. Baut auf termine_build_lists() auf, damit es weiterhin nur EINE
* Merge-Logik für Termine gibt (siehe CLAUDE.md); hier kommt nur die
* Aufbereitung für die Karte dazu:
* type 'spiel' | 'event' → bestimmt die Innenvariante der Karte
* label Beschriftung des Slides („Nächstes Spiel · 1. Mannschaft").
* Nur der erste Slide sagt „nächstes", danach würde es bei zwei
* Spielen hintereinander schief klingen.
* href Ziel des Slides: Spiel → /matchcenter, Veranstaltung mit eigener
* Seite → /veranstaltungen/<slug>, sonstiger Termin → /termine
* (data/events.json-Einträge haben keine Detailseite)
* link_text visually-hidden Zusatz, damit der Linktext das Ziel nennt
* match roher Matchcenter-Eintrag (für die match-feature-Komponente)
* event roher events.json-Eintrag (für die event-feature-Komponente)
* Keine Termine → leeres Array; der Hero rendert dann ohne Karte.
*/
function upcoming_highlights(int $limit = 3): array
{
$items = [];
foreach (array_slice(termine_build_lists()['upcoming'], 0, max(0, $limit)) as $i => $item) {
$isMatch = ($item['type'] ?? '') === 'spiel';
$lead = $isMatch
? ($i === 0 ? 'Nächstes Spiel' : 'Spiel')
: ($i === 0 ? 'Nächster Termin' : 'Termin');
$suffix = $isMatch
? (string) ($item['team_name'] ?? '')
: department_label((string) ($item['department'] ?? 'verein'));
// Termine mit eigener Detailseite bringen ihr Ziel schon aus
// termine_build_lists() mit — dann zeigt die Karte dorthin statt auf die
// Sammelseite. Spiele bleiben beim Matchcenter.
$detailHref = !$isMatch ? (string) ($item['href'] ?? '') : '';
$items[] = [
'type' => $isMatch ? 'spiel' : 'event',
'label' => $suffix !== '' ? $lead . ' · ' . $suffix : $lead,
'href' => $isMatch ? url('matchcenter') : ($detailHref !== '' ? $detailHref : url('termine')),
'link_text' => $isMatch
? 'zum Matchcenter'
: ($detailHref !== '' ? 'zur Veranstaltung' : 'zu allen Terminen'),
'match' => $isMatch ? ($item['raw'] ?? []) : null,
'event' => $isMatch ? null : ($item['raw'] ?? []),
'team_name' => (string) ($item['team_name'] ?? ''),
'league' => (string) ($item['league'] ?? ''),
];
}
return $items;
}
/**
* Ist eine Meldung veröffentlicht? Gleiche Semantik wie
* veranstaltung_is_published()/stadionzeitung_is_published(): ein fehlendes
* status-Feld gilt als veröffentlicht (Bestandsschutz).
*/
function news_is_published(array $item): bool
{
return ($item['status'] ?? 'veroeffentlicht') !== 'entwurf';
}
/**
* Hat eine Meldung eine eigene Detailseite (/news/<slug>)? DIE EINZIGE STELLE,
* die das entscheidet — Feed-Verlinkung, Archiv, Sitemap und Preflight fragen
* ausschließlich hier, damit die Antwort nicht an vier Orten auseinanderläuft.
*
* Maßgeblich ist der INHALT (`artikel`), nicht der `slug`: einen Slug schreibt
* man beim Anlegen reflexartig mit, sonst entstünden versehentlich Seiten mit
* zwei Sätzen Inhalt. Eine Kurzmeldung bleibt damit eine reine Feed-Zeile, so
* wie es vor den Detailseiten für alle Meldungen war.
*/
function news_has_article(array $item): bool
{
return !empty($item['slug']) && !empty($item['artikel']);
}
/**
* Alle veröffentlichten Meldungen (data/news.json), neueste zuerst. EINZIGE
* gefilterte Ladestelle — für den Feed, das Archiv und die Sitemap. Die
* Detailseite (app/pages/news-artikel.php) lädt bewusst ungefiltert über
* news_find(): ein Entwurf muss unter seiner direkten URL lesbar bleiben,
* sonst könnte ihn niemand gegenlesen (wie bei veranstaltungen_load()).
*/
function news_load(): array
{
$items = array_values(array_filter(
json_load('news')['news'] ?? [],
'news_is_published'
));
usort($items, static fn (array $a, array $b): int => (string) ($b['date'] ?? '') <=> (string) ($a['date'] ?? ''));
return $items;
}
/**
* Eine Meldung über ihren Slug, UNGEFILTERT (Entwürfe eingeschlossen) — für die
* Detailseite. Ohne Treffer null, der Aufrufer antwortet dann mit 404.
*/
function news_find(string $slug): ?array
{
foreach (json_load('news')['news'] ?? [] as $item) {
if (($item['slug'] ?? '') === $slug) {
return $item;
}
}
return null;
}
/**
* Baut den gemischten News-Feed (redaktionelle Meldungen + Instagram-Posts +
* Ergebnisse) — gemeinsame Grundlage für app/pages/news.php UND die
* Stadionzeitungs-Live-Seite „news" (Phase 3). Rückgabe:
* items normalisierte, absteigend sortierte Form. config('news.max_items')
* kappt NUR Instagram und Ergebnisse (die wachsen automatisch);
* redaktionelle Meldungen stehen immer alle drin
* news veröffentlichte Meldungen aus data/news.json (für news_article_schema())
*
* Die Meldungen kommen über news_load(), also OHNE Entwürfe. Das gilt
* absichtlich auch für den Stadionzeitungs-Snapshot
* (stadionzeitung_build_snapshot()): der friert seinen Stand beim
* Veröffentlichen ein und ist die einzige Stelle, die sich nicht mehr
* zurücknehmen lässt — ein Entwurf darf dort nie landen.
*/
function news_build_feed(): array
{
$news = news_load();
$instagramPosts = json_load('instagram')['posts'] ?? [];
$results = matchcenter_recent_results(json_load('matchcenter'));
$maxItems = (int) config('news.max_items', 20);
$normalizeNews = static function (array $n): array {
// Nur Meldungen mit Artikeltext verlinken (news_has_article()) — eine
// Kurzmeldung bleibt eine nicht verlinkte Zeile, news-feed.php rendert
// sie ohne href als <div>. `link_text` liefert die sichtbare
// Weiterlesen-Auszeichnung; gleiche Prop wie in upcoming_highlights(),
// damit die Komponente die Quellen nicht kennen muss.
$hasArticle = news_has_article($n);
return [
'date' => (string) ($n['date'] ?? ''),
'title' => (string) ($n['title'] ?? ''),
'text' => (string) ($n['text'] ?? ''),
'image' => $n['image'] ?? null,
'href' => $hasArticle ? url('news/' . $n['slug']) : null,
'link_text' => $hasArticle ? 'Artikel lesen' : null,
'source' => 'news',
];
};
$normalizeInstagram = static function (array $post): array {
// Titel = erste Caption-Zeile, Text = Rest ab Zeile zwei. So steht die
// erste Zeile nie doppelt da (Titel + kompletter Caption-Text waren
// vorher beide sichtbar). Einzeilige Captions → Text leer, die
// Anzeige lässt ihn dann weg.
$caption = trim((string) ($post['caption'] ?? ''));
[$firstLine, $rest] = array_pad(explode("\n", $caption, 2), 2, '');
$firstLine = trim($firstLine);
$title = $firstLine !== '' ? mb_substr($firstLine, 0, 120) : 'Instagram-Beitrag';
if ($title !== $firstLine && $firstLine !== '') {
$title .= '…'; // hart gekappt → nicht mitten im Wort enden lassen
}
return [
'date' => !empty($post['taken_at']) ? date('Y-m-d', (int) $post['taken_at']) : '',
'title' => $title,
'text' => trim($rest),
'image' => !empty($post['image'])
? ['src' => $post['image'], 'width' => 640, 'height' => 800, 'alt' => (string) ($post['alt'] ?? '')]
: null,
'href' => (string) ($post['url'] ?? ''),
'source' => 'instagram',
];
};
$normalizeResult = static function (array $r): array {
$isHome = !empty($r['is_home']);
$opponent = $isHome ? ($r['away'] ?? '') : ($r['home'] ?? '');
$opponentCrest = $isHome ? ($r['away_crest'] ?? null) : ($r['home_crest'] ?? null);
$outcomeLabel = match ($r['outcome'] ?? null) {
'win' => 'Sieg gegen',
'loss' => 'Niederlage gegen',
'draw' => 'Unentschieden gegen',
default => 'Spiel gegen',
};
return [
'date' => substr((string) ($r['kickoff'] ?? ''), 0, 10),
'title' => trim("{$outcomeLabel} {$opponent}"),
'text' => trim(($r['team_name'] ?? '') . " · Endstand {$r['goals_home']}:{$r['goals_away']}"),
'image' => null,
'crest' => ['src' => $opponentCrest, 'name' => $opponent],
'href' => url('matchcenter'),
'source' => 'ergebnis',
];
};
// Manche last_results-Einträge (z. B. Testspiele) haben nie ein erfasstes
// Ergebnis — ohne Tore ist es keine News ("Ergebnis" ohne Ergebnis), raus damit.
$resultsWithScore = array_filter(
$results,
static fn (array $r): bool => isset($r['goals_home'], $r['goals_away'])
);
// Gekappt werden NUR die maschinell gefüllten Quellen: Instagram und
// Ergebnisse wachsen von allein weiter (Sync 2×/Tag, jedes Wochenende neue
// Spiele) und dagegen schützt die Grenze. Redaktionelle Meldungen stehen
// immer alle da — sie sind wenige, gewollt und sollen dauerhaft erreichbar
// bleiben. Vorher fiel ein älterer Artikel irgendwann aus der Liste und war
// von der Website aus gar nicht mehr verlinkt.
$automatisch = array_merge(
array_map($normalizeInstagram, $instagramPosts),
array_map($normalizeResult, $resultsWithScore)
);
usort($automatisch, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);
$items = array_merge(
array_map($normalizeNews, $news),
array_slice($automatisch, 0, $maxItems)
);
usort($items, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);
return [
'items' => $items,
'news' => $news,
];
}
/**
* Ist eine Stadionzeitung-Ausgabe veröffentlicht? Ausgaben ohne status-Feld
* (vor Einführung von Entwurf/Veröffentlicht) gelten als veröffentlicht —
* sie waren es faktisch, das Feld kam später dazu.
*/
function stadionzeitung_is_published(array $issue): bool
{
return ($issue['status'] ?? 'veroeffentlicht') !== 'entwurf';
}
/**
* Die neueste VERÖFFENTLICHTE Ausgabe (oder null, wenn es keine gibt) — für die
* Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert
* selbst nach `date` und verlässt sich nicht auf die Reihenfolge in der Datei:
* die stimmt zwar (bin/stadionzeitung-add.php sortiert absteigend), aber ein
* gedruckter QR-Code darf nicht von einer Dateireihenfolge abhängen.
*/
function stadionzeitung_latest(): ?array
{
$issues = array_values(array_filter(
json_load('stadionzeitung')['issues'] ?? [],
'stadionzeitung_is_published'
));
if ($issues === []) {
return null;
}
usort($issues, static fn (array $a, array $b): int => strcmp((string) ($b['date'] ?? ''), (string) ($a['date'] ?? '')));
return $issues[0];
}
/**
* Live-Daten einer Stadionzeitung-Ausgabe frisch berechnen: Tabellen aller
* Mannschaften, letzte Ergebnisse, Vorschau (nächstes Spiel je Team nach dem
* Spieltag) und News-Feed. Einzige Berechnungsstelle — der Viewer (Entwurf,
* live bei jedem Aufruf) und bin/stadionzeitung-publish.php (friert genau
* dieses Ergebnis als `snapshot`-Feld ein) rufen dieselbe Funktion auf,
* damit Vorschau und eingefrorene Ausgabe nie auseinanderlaufen können.
*/
function stadionzeitung_build_snapshot(array $issue): array
{
$date = (string) ($issue['date'] ?? '');
$issueTeam = (string) ($issue['team_key'] ?? '');
$tabellen = [];
$ergebnisse = [];
$vorschau = [];
foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
$key = (string) ($team['key'] ?? '');
if ($key === '') {
continue;
}
$tabellen[$key] = [
'name' => (string) ($team['name'] ?? ''),
'league' => (string) ($team['league'] ?? ''),
'season' => (string) ($team['season'] ?? ''),
'table' => $team['table'] ?? [],
];
// Nur Spiele mit erfasstem Ergebnis — ein Testspiel ohne Tore ist
// kein Rückblick (dieselbe Regel wie im News-Feed).
$withScore = array_values(array_filter(
$team['last_results'] ?? [],
static fn (array $r): bool => isset($r['goals_home'], $r['goals_away'])
));
if ($withScore !== []) {
$ergebnisse[] = [
'team_key' => $key,
'team_name' => (string) ($team['name'] ?? ''),
'league' => (string) ($team['league'] ?? ''),
// EIN letztes Ergebnis pro Team: mit drei Mannschaften plus
// Matchcenter-Ticket passt mehr nicht zuverlässig auf die
// A5-Seite (zwei pro Team liefen über die Blattkante).
'matches' => array_slice($withScore, 0, 1),
];
}
// Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der
// Ausgabe. Das Heft-Spiel selbst fliegt raus (das Cover zeigt es
// schon); ein Spiel einer ANDEREN Mannschaft am selben Tag bleibt drin.
foreach ($team['next_matches'] ?? [] as $match) {
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
// "SPIELFREI" — kein echtes Spiel, im Heft überspringen.
if (($match['home'] ?? '') === 'SPIELFREI' || ($match['away'] ?? '') === 'SPIELFREI') {
continue;
}
$matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
if ($date !== '' && $matchDate < $date) {
continue;
}
if ($matchDate === $date && $key === $issueTeam) {
continue;
}
$vorschau[] = [
'team_key' => $key,
'team_name' => (string) ($team['name'] ?? ''),
'league' => (string) ($team['league'] ?? ''),
'match' => $match,
];
break;
}
}
// Momente-Seite: die vier neuesten Instagram-Fotos (keine Videos — die
// haben zwar ein Vorschaubild, aber ein Standbild ohne Kontext wirkt im
// Heft wie ein kaputtes Foto).
$momente = array_slice(array_values(array_filter(
json_load('instagram')['posts'] ?? [],
static fn (array $post): bool => empty($post['is_video']) && !empty($post['image'])
)), 0, 4);
return [
'tabellen' => $tabellen,
'ergebnisse' => $ergebnisse,
'vorschau' => $vorschau,
// Fünf statt sechs: mit den größeren Überschriften + CTA-Fuß braucht
// die News-Seite den Platz, sonst klebt der CTA an der Blattkante.
'news' => array_slice(news_build_feed()['items'] ?? [], 0, 5),
'momente' => array_map(static fn (array $post): array => [
'image' => (string) $post['image'],
'alt' => (string) ($post['alt'] ?? ''),
], $momente),
];
}
/**
* Daten für die Live-Seiten einer Ausgabe: veröffentlichte Ausgaben rendern
* aus ihrem eingefrorenen `snapshot` (eine gedruckte Publikation ändert sich
* nicht mehr), Entwürfe rechnen bei jedem Aufruf frisch — so sieht Felix beim
* Bauen immer den aktuellen Stand, und das Veröffentlichen friert genau
* diesen Stand ein.
*/
function stadionzeitung_live_data(array $issue): array
{
if (stadionzeitung_is_published($issue) && is_array($issue['snapshot'] ?? null)) {
return $issue['snapshot'];
}
return stadionzeitung_build_snapshot($issue);
}
/**
* Komponente rendern: component('hero', ['title' => …]).
* Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes.
*/
function component(string $name, array $props = []): void
{
extract($props, EXTR_SKIP);
require APP_PATH . '/components/' . $name . '.php';
}
/**
* Page-Datei ausführen: sie setzt $meta und emittiert ihren Body.
* Rückgabe: [$meta, $html].
*/
function render_page(string $file): array
{
$meta = [];
ob_start();
require $file;
return [$meta, (string) ob_get_clean()];
}
/**
* Signierten Zeitstempel für die Formular-Time-Trap erzeugen.
*/
function form_token(): string
{
$ts = (string) time();
return $ts . '.' . hash_hmac('sha256', $ts, (string) config('app_secret'));
}
/**
* Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max.
* Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht
* grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab.
*/
function form_token_valid(string $token, int $min = 3, int $max = 86400): bool
{
$parts = explode('.', $token);
if (count($parts) !== 2) {
return false;
}
[$ts, $sig] = $parts;
if (!hash_equals(hash_hmac('sha256', $ts, (string) config('app_secret')), $sig)) {
return false;
}
$age = time() - (int) $ts;
return $age >= $min && $age <= $max;
}
/**
* Client-IP für Rate-Limiting/Logging. Bewusst nur REMOTE_ADDR — X-Forwarded-For
* ist ohne vertrauenswürdigen Proxy spoofbar und wird daher nicht ausgewertet.
*/
function client_ip(): string
{
return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}
/**
* Dateibasiertes Rate-Limit mit gleitendem Fenster (shared-hosting-sicher, kein
* APCu/Redis nötig). Gibt true zurück und verbucht einen Treffer, solange in den
* letzten $window Sekunden weniger als $max Treffer für $key gezählt wurden; sonst
* false ohne Eintrag. Atomar via flock. Bei Datei-/IO-Fehler wird NICHT geblockt
* (Verfügbarkeit vor Schutz). Verwaiste Zähler werden gelegentlich aufgeräumt.
*/
function rate_limit_ok(string $key, int $max, int $window): bool
{
$dir = STORAGE_PATH . '/ratelimit';
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return true;
}
// Probabilistische GC: Zähler-Dateien, die seit >1 Tag nicht angefasst wurden, löschen.
if (random_int(1, 100) === 1) {
foreach (glob($dir . '/*.json') ?: [] as $stale) {
if ((int) @filemtime($stale) < time() - 86400) {
@unlink($stale);
}
}
}
$file = $dir . '/' . hash('sha256', $key) . '.json';
$fh = @fopen($file, 'c+');
if ($fh === false) {
return true;
}
try {
flock($fh, LOCK_EX);
$raw = (string) stream_get_contents($fh);
$hits = $raw !== '' ? (array) (json_decode($raw, true) ?: []) : [];
$now = time();
$hits = array_values(array_filter($hits, static fn ($t): bool => (int) $t > $now - $window));
if (count($hits) >= $max) {
return false;
}
$hits[] = $now;
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, (string) json_encode($hits));
return true;
} finally {
flock($fh, LOCK_UN);
fclose($fh);
}
}
/**
* EINZIGE Log-Schreibstelle des Projekts — Web wie CLI, alle Kanäle.
*
* $channel Dateiname ohne Endung: mail | spam | instagram | matchcenter
* $level INFO (Normalbetrieb) | WARN (Auffälligkeit, Betrieb läuft weiter) |
* ERROR (Funktion ist ausgefallen)
*
* Format: [2026-07-27T08:11:45+00:00] ERROR Meldung
*
* Ein Ereignis = eine Zeile (Umbrüche werden ersetzt), damit die Logs grep- und
* auswertbar bleiben und Meldungstexte keine Zeilen fälschen können. ERROR läuft
* zusätzlich in eine gemeinsame `error.log` — eine Datei beantwortet die Frage
* „ist gerade etwas kaputt?", die Kanal-Logs behalten den vollen Verlauf.
*
* Datensparsam: hier gehören keine Formularinhalte und keine Klartext-IPs hinein.
* Übersicht über alle Kanäle: php bin/logs.php
*/
function log_write(string $channel, string $level, string $message): void
{
$channel = preg_replace('/[^a-z0-9-]/', '', strtolower($channel)) ?: 'app';
$level = strtoupper($level);
if (!in_array($level, ['INFO', 'WARN', 'ERROR'], true)) {
$level = 'INFO';
}
$message = trim((string) preg_replace('/\s*[\r\n]+\s*/', ' | ', $message));
$stamp = date('c');
$dir = STORAGE_PATH . '/logs';
@file_put_contents($dir . '/' . $channel . '.log', "[{$stamp}] {$level} {$message}\n", FILE_APPEND | LOCK_EX);
if ($level === 'ERROR') {
@file_put_contents($dir . '/error.log', "[{$stamp}] {$channel} {$message}\n", FILE_APPEND | LOCK_EX);
}
}
/**
* Abgewiesenen Formular-Versuch protokollieren (storage/logs/spam.log) — reine
* Beobachtbarkeit zum Tunen der Schwellen. Bewusst INFO: ein geblockter Bot ist
* kein Fehler, sondern der Schutz bei der Arbeit. Datensparsam: nur ein gekürzter,
* gesalzener IP-Hash, keine Klartext-IP/PII. $reason z. B. honeypot|token|ratelimit|links|daily-cap|replay.
*/
function log_spam(string $route, string $reason): void
{
$ipHash = substr(hash_hmac('sha256', client_ip(), (string) config('app_secret')), 0, 12);
log_write('spam', 'INFO', "{$route} {$reason} ip={$ipHash}");
}
/**
* Not-Aus für die Formular-Actions: false, wenn die Konfiguration unvollständig ist
* (dann liefe die Seite auf config.example.php — öffentliches app_secret, kein
* SMTP-Passwort). Protokolliert den Grund einmal pro Versuch.
*/
function config_ready(string $route): bool
{
$problems = config_problems();
if ($problems === []) {
return true;
}
log_write('mail', 'ERROR', "{$route}: Formular abgewiesen, Konfiguration unvollständig — " . implode(' ', $problems));
return false;
}
/**
* Formular-Mail über All-Inkl-SMTP verschicken — EINZIGE Versandstelle der Seite.
* Alle Actions gehen hierdurch, damit Timeout, Verschlüsselung und Fehler-Logging
* an genau einem Ort stehen.
*
* $opts: subject, body (Pflicht) · to, to_name (Default: smtp.to) ·
* reply_to ['email'=>…, 'name'=>…] · context (Präfix im Log).
*
* Gibt true/false zurück und wirft nie — Versandfehler landen in
* storage/logs/mail.log und werden dem Besucher nie im Klartext gezeigt.
* Timeout bewusst kurz (10 s): der Versand hängt im Request-Pfad, ein toter
* SMTP-Server darf den Besucher nicht bis zur max_execution_time blockieren.
*/
function send_mail(array $opts): bool
{
$smtp = (array) config('smtp', []);
$context = (string) ($opts['context'] ?? 'Mailversand');
try {
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = (string) $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = (string) $smtp['username'];
$mail->Password = (string) $smtp['password'];
$mail->CharSet = PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
$mail->Timeout = 10;
$mail->XMailer = ' '; // kein X-Mailer-Header (verrät sonst die PHPMailer-Version)
$mail->setFrom((string) $smtp['from'], (string) ($smtp['from_name'] ?? ''));
$mail->addAddress((string) ($opts['to'] ?? $smtp['to']), (string) ($opts['to_name'] ?? ''));
if (!empty($opts['reply_to']['email'])) {
$mail->addReplyTo((string) $opts['reply_to']['email'], (string) ($opts['reply_to']['name'] ?? ''));
}
$mail->Subject = (string) $opts['subject'];
$mail->Body = (string) $opts['body'];
$mail->send();
// Erfolg als INFO mitschreiben. Vorher loggte nur der Fehlerfall, damit war ein
// funktionierender Versand von einem stummen nicht zu unterscheiden — beim
// Deploy am 31.07.2026 stand im Log gar kein mail-Kanal, was „nichts
// abgeschickt" und „einwandfrei versandt" gleichzeitig bedeuten konnte.
// Nur der Kontext (welches Formular), NIE Empfänger, Absender oder Inhalt:
// das verbietet die Logging-Regel, und für die Frage „läuft der Versand?"
// braucht es davon auch nichts.
log_write('mail', 'INFO', "{$context} versandt.");
return true;
} catch (Throwable $e) {
log_write('mail', 'ERROR', "{$context} fehlgeschlagen: " . $e->getMessage());
return false;
}
}
/**
* Formular-Zustand nach fehlgeschlagener Validierung (Eingaben + Feldfehler), damit
* der 422-Re-Render die Werte zurückschreiben kann. Ohne Argumente = nur lesen.
* $old: Feldname → Wert. $errors: Feld-ID (wie im Markup) → Fehlermeldung.
*/
function form_state(?array $old = null, ?array $errors = null): array
{
static $state = ['old' => [], 'errors' => []];
if ($old !== null) {
$state = ['old' => $old, 'errors' => $errors ?? []];
}
return $state;
}
/**
* Zuvor eingegebener Wert eines Feldes ('' wenn keiner) — für value/selected/checked.
*/
function form_old(string $name): string
{
return (string) (form_state()['old'][$name] ?? '');
}
/**
* Serverseitige Fehlermeldung zu einer Feld-ID ('' wenn das Feld in Ordnung war).
*/
function form_field_error(string $id): string
{
return (string) (form_state()['errors'][$id] ?? '');
}
/**
* True, wenn der aktuelle Request ein Formular mit Feldfehlern rendert.
*/
function form_has_errors(): bool
{
return form_state()['errors'] !== [];
}
/**
* Die Varianten des EINEN Kontaktformulars, adressiert über das Hidden-Feld
* `department`. EINZIGE Quelle für Auswahlliste, Empfänger und Feldregeln —
* `app/components/contact-form.php` und `app/actions/contact-submit.php` lesen
* beide hier, damit die Whitelist nicht an zwei Stellen gepflegt werden muss
* (das war sie vorher, mit einem Kommentar als einziger Absicherung).
*
* Ein unbekannter Wert fällt auf die allgemeine Variante zurück — ein
* manipuliertes Feld kann damit weder einen fremden Empfänger erreichen noch
* die Validierung umgehen.
*
* `to` leer heißt: Standardempfänger aus `config('smtp.to')`. Die
* Turn-Variante geht an `club.email_turnen`, weil die Abteilung ihre Anfragen
* selbst beantwortet (so war es auch auf der alten Seite).
*/
function contact_variant(string $department): array
{
if ($department !== 'turnen') {
return [
'department' => '',
'interests' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Ehrenamtlich mithelfen', 'Sonstiges'],
'interest_required' => false,
'ask_subject' => true,
'message_required' => true,
'to' => '',
'context' => 'Kontaktanfrage',
'subject_prefix' => 'Kontaktanfrage',
];
}
// Die Auswahl ist keine eigene Liste, sondern sind die Disziplinen aus
// data/turnen.json — so heißen die Optionen immer genau wie die Seiten, auf
// die sie sich beziehen, auch wenn eine Disziplin dazukommt oder wegfällt.
$interests = [];
foreach (json_load('turnen')['disciplines'] ?? [] as $discipline) {
if (!empty($discipline['hero']['title'])) {
$interests[] = (string) $discipline['hero']['title'];
}
}
$club = json_load('club');
return [
'department' => 'turnen',
'interests' => $interests,
// Pflicht, anders als in der allgemeinen Variante: die Disziplin ist der
// Kern der Anfrage, und sie ersetzt hier das Betreff-Feld.
'interest_required' => true,
'ask_subject' => false,
// Bewusst optional (wie auf der alten Seite): „Interessiere mich für
// Trampolin, ruft mich an" ist eine vollständige Anfrage. Name, Mail und
// Disziplin stehen ja schon da.
'message_required' => false,
'to' => (string) ($club['email_turnen'] ?? ''),
'context' => 'Anfrage Turnabteilung',
'subject_prefix' => 'Anfrage Turnen',
];
}
/**
* Antwort auf eine fehlgeschlagene Formular-Validierung ohne JS: die absendende
* Seite mit HTTP 422 direkt neu rendern — mit erhaltenen Eingaben und Feldfehlern.
* Bewusst kein PRG-Redirect, weil die Werte sonst verloren gingen (und ein
* Session-Cookie dafür unverhältnismäßig wäre). Die Statuszeile bekommt beim
* Re-Render `autofocus`, damit der Fokus auch ohne JS in der Fehlermeldung landet.
*/
function render_form_invalid(string $slug, array $old, array $errors): never
{
form_state($old, $errors);
$routes = require APP_PATH . '/routes.php';
$current = isset($routes[$slug]) ? $slug : '';
http_response_code(422);
header('Cache-Control: no-store');
[$meta, $content] = render_page(APP_PATH . '/pages/' . $routes[$current]['file']);
require APP_PATH . '/layout.php';
exit;
}
/**
* BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad).
*/
function breadcrumb_schema(array $items): array
{
$list = [];
foreach (array_values($items) as $i => $item) {
$list[] = [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $item['name'],
'item' => abs_url($item['slug']),
];
}
return ['@type' => 'BreadcrumbList', 'itemListElement' => $list];
}
/**
* Redaktioneller Text aus data/*.json darf interne Deeplinks im Format
* [Label](slug) tragen — slug wie in routes.php, optional mit #anker
* ('mitmachen', 'fussball#kontakt') oder nur ein Anker auf derselben Seite
* ('#kontakt'). Kein HTML im JSON: inline_links_html() rendert die Links als <a>
* und escaped alles andere, inline_links_text() liefert den reinen Text (für
* das FAQPage-Schema und jede andere HTML-freie Ausgabe).
*
* Genutzt von den FAQ-Antworten (beide FAQ-Komponenten, faq_schema()) und vom
* Artikelkörper der News-Detailseite. Die Escaping-Garantie ist der Wert dieser
* Funktion: sie ist die einzige Stelle, an der aus JSON HTML entsteht — jede
* Erweiterung (Fettung, Listen, externe URLs) wäre eine neue Angriffsfläche.
*/
function inline_links_html(string $answer): string
{
$parts = preg_split('/\[([^\]]+)\]\(([a-z0-9\/#_-]*)\)/i', $answer, -1, PREG_SPLIT_DELIM_CAPTURE);
$html = '';
foreach ($parts as $i => $part) {
if ($i % 3 === 0) { // Text zwischen den Links
$html .= e($part);
} elseif ($i % 3 === 1) { // Label — der Slug folgt als nächstes Element
$target = $parts[$i + 1];
[$slug, $anchor] = array_pad(explode('#', $target, 2), 2, null);
$href = ($slug === '' ? '' : url($slug)) . ($anchor !== null ? '#' . $anchor : '');
$html .= '<a href="' . e($href) . '">' . e($part) . '</a>';
}
}
return $html;
}
function inline_links_text(string $answer): string
{
return preg_replace('/\[([^\]]+)\]\([a-z0-9\/#_-]*\)/i', '$1', $answer) ?? $answer;
}
/**
* FAQPage-Knoten aus [['q'=>…, 'a'=>…], …]. Leere Paare werden übersprungen;
* ohne Fragen wird ein leerer Array zurückgegeben (Aufrufer filtert das raus).
*/
function faq_schema(array $faq): array
{
$questions = [];
foreach ($faq as $item) {
if (empty($item['q']) || empty($item['a'])) {
continue;
}
$questions[] = [
'@type' => 'Question',
'name' => $item['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => inline_links_text($item['a'])],
];
}
return $questions === [] ? [] : ['@type' => 'FAQPage', 'mainEntity' => $questions];
}
/**
* Seiten-Schema-Knoten zusammenstellen (für $meta['schema']):
* Breadcrumb + optional FAQPage. Generisch für Übersichts-/Jugendseiten.
*/
function page_schema(array $breadcrumb, array $faq = []): array
{
$nodes = [];
if ($breadcrumb !== []) {
$nodes[] = breadcrumb_schema($breadcrumb);
}
if ($faq !== [] && ($faqNode = faq_schema($faq)) !== []) {
$nodes[] = $faqNode;
}
return $nodes;
}
/**
* WebSite-Knoten (für die Startseite): definiert die Site als Entität und
* verweist via publisher auf den Club (#club). Keine SearchAction — es gibt
* keine Site-Suche.
*/
function website_schema(): array
{
$club = json_load('club');
return [
'@type' => 'WebSite',
'@id' => abs_url() . '#website',
'url' => abs_url(),
'name' => $club['name'],
'inLanguage' => 'de-DE',
'publisher' => ['@id' => abs_url() . '#club'],
];
}
/**
* Schema-Knoten für eine Mannschafts-Seite: SportsTeam (verweist auf den Club
* via #club) + Breadcrump (Start → Fußball → Team) + optional FAQPage.
* $team: Eintrag aus data/teams.json; $slug: voller Seiten-Slug.
*/
function team_schema(array $team, string $slug): array
{
$teamNode = [
'@type' => 'SportsTeam',
'name' => $team['name'],
'sport' => 'Fußball',
'url' => abs_url($slug),
'memberOf' => ['@id' => abs_url() . '#club'],
];
if (!empty($team['hero']['text'])) {
$teamNode['description'] = $team['hero']['text'];
}
$nodes = [$teamNode];
$nodes = array_merge($nodes, page_schema(
[
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => $team['name'], 'slug' => $slug],
],
$team['faq'] ?? []
));
return $nodes;
}
/**
* SportsEvent-Knoten für anstehende Spiele (Matchcenter). $upcoming: Einträge aus
* data/matchcenter.json → upcoming[] (home, away, kickoff, …). startDate nur, wenn
* der Anstoß als ISO-Zeit vorliegt. Aufrufer hängt das Ergebnis an $meta['schema'].
*/
function sportsevent_nodes(array $upcoming): array
{
$nodes = [];
foreach ($upcoming as $m) {
if (empty($m['home']) || empty($m['away'])) {
continue;
}
$node = [
'@type' => 'SportsEvent',
'name' => $m['home'] . ' ' . $m['away'],
'sport' => 'Fußball',
'homeTeam' => ['@type' => 'SportsTeam', 'name' => $m['home']],
'awayTeam' => ['@type' => 'SportsTeam', 'name' => $m['away']],
'eventStatus' => 'https://schema.org/EventScheduled',
];
if (!empty($m['kickoff'])) {
$node['startDate'] = $m['kickoff'];
}
$nodes[] = $node;
}
return $nodes;
}
/**
* Event-Knoten für ein Club-Event (data/events.json: title, date_start, date_end?,
* location?, text?). Analog sportsevent_nodes(), nur für die manuell gepflegten
* Vereins-Events statt Spieltermine. Fehlt title/date_start → null (Aufrufer filtert).
*
* $slug: interner Pfad der Detailseite, falls das Event eine hat
* (data/veranstaltungen.json). Dann bekommt der Knoten @id und url — /termine
* und die Detailseite beschreiben damit nachweislich DASSELBE Event und nicht
* zwei zufällig gleich benannte. Events ohne eigene Seite lassen $slug leer.
*/
function event_schema(array $event, ?string $slug = null): ?array
{
if (empty($event['title']) || empty($event['date_start'])) {
return null;
}
$node = [
'@type' => 'Event',
'name' => $event['title'],
'startDate' => $event['date_start'] . (!empty($event['time']) ? 'T' . $event['time'] : ''),
'eventStatus' => 'https://schema.org/EventScheduled',
'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode',
];
if (!empty($event['date_end'])) {
$node['endDate'] = $event['date_end'];
}
if (!empty($event['location'])) {
$node['location'] = ['@type' => 'Place', 'name' => $event['location']];
}
if (!empty($event['text'])) {
$node['description'] = $event['text'];
}
if ($slug !== null && $slug !== '') {
$node['@id'] = abs_url($slug) . '#event';
$node['url'] = abs_url($slug);
}
// Hauptplakat als Event-Bild (nur Veranstaltungen haben eins) — gleiche
// Ableitung wie in news_article_schema().
$plakat = $event['plakate'][0] ?? null;
if (!empty($plakat['base']) && !empty($plakat['widths'])) {
$largest = max($plakat['widths']);
$node['image'] = rtrim((string) config('base_url'), '/') . asset("{$plakat['base']}-{$largest}.jpg");
}
return $node;
}
/**
* Article-Knoten für eine redaktionelle Meldung (data/news.json: title, date,
* text, image?). Fehlt title/date → null (Aufrufer filtert). Verweist wie
* team_schema()/job_posting_schema() via #club auf den Organisation-Knoten
* (Autor und Herausgeber sind hier derselbe Verein, kein einzelner Redakteur).
*
* $pageSlug (z. B. 'news/neue-website-online') nur für Meldungen mit eigener
* Detailseite: dann bekommt der Knoten @id, url und mainEntityOfPage, damit die
* Feed-Seite /news und die Detailseite nachweislich DENSELBEN Article
* beschreiben. Gleiches Muster und dieselbe Begründung wie event_schema().
*/
function news_article_schema(array $item, ?string $pageSlug = null): ?array
{
if (empty($item['title']) || empty($item['date'])) {
return null;
}
$node = [
'@type' => 'Article',
'headline' => $item['title'],
'datePublished' => $item['date'],
'author' => ['@id' => abs_url() . '#club'],
'publisher' => ['@id' => abs_url() . '#club'],
];
if ($pageSlug !== null) {
$node['@id'] = abs_url($pageSlug) . '#article';
$node['url'] = abs_url($pageSlug);
$node['mainEntityOfPage'] = ['@type' => 'WebPage', '@id' => abs_url($pageSlug)];
}
if (!empty($item['text'])) {
$node['description'] = $item['text'];
}
if (!empty($item['image']['base']) && !empty($item['image']['widths'])) {
$largest = max($item['image']['widths']);
$node['image'] = rtrim((string) config('base_url'), '/') . asset("{$item['image']['base']}-{$largest}.jpg");
}
return $node;
}
/**
* JobPosting-Knoten für eine Ehrenamtsstelle (Seite /mitmachen). $job: Eintrag aus
* data/mitmachen.json → positions[]; $pageSlug: Seiten-Slug für die Anker-URL.
* employmentType VOLUNTEER; hiringOrganization verweist via #club auf den Org-Knoten;
* jobLocation = Vereinsadresse aus club.json (Single Source). validThrough bewusst
* optional — ein abgelaufenes Datum entfernt die Anzeige aktiv aus den Ergebnissen,
* deshalb nur bei echt befristeten Stellen setzen. Ohne title → leerer Array (Aufrufer filtert).
*/
function job_posting_schema(array $job, string $pageSlug): array
{
if (empty($job['title'])) {
return [];
}
$club = json_load('club');
$node = [
'@type' => 'JobPosting',
'title' => $job['title'],
'description' => $job['description'] ?? ($job['summary'] ?? $job['title']),
'employmentType' => 'VOLUNTEER',
'hiringOrganization' => ['@id' => abs_url() . '#club'],
'jobLocation' => [
'@type' => 'Place',
'address' => [
'@type' => 'PostalAddress',
'streetAddress' => $club['address']['street'],
'postalCode' => $club['address']['zip'],
'addressLocality' => $club['address']['city'],
'addressCountry' => $club['address']['country'],
],
],
];
if (!empty($job['id'])) {
$node['identifier'] = [
'@type' => 'PropertyValue',
'name' => $club['name'],
'value' => $job['id'],
];
$node['url'] = abs_url($pageSlug) . '#' . $job['id'];
}
if (!empty($job['posted'])) {
$node['datePosted'] = $job['posted'];
}
if (!empty($job['valid_through'])) {
$node['validThrough'] = $job['valid_through'];
}
return $node;
}