Files
tsv08kulmbach-website/bin/preflight.php

388 lines
20 KiB
PHP
Raw Normal View History

Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen. h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der --fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar. Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts. Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten- Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()- Rampen für Karten-Überschriften (bento__heading, bento__stat-value, group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich. Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und 11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser, folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest. Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em. Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von tokens.css. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
<?php
declare(strict_types=1);
/**
* Deploy-Selbsttest NUR CLI. Prüft alles, was nach dem Hochladen stimmen muss,
* bevor die Seite Besucher sieht: Konfiguration, Secrets, Schreibrechte,
* Composer-Abhängigkeiten, PHP-Version, Datenstände.
*
* Prüft NICHT den SMTP-Login (Netzwerkzugriff) dafür: php bin/smtp-test.php
*
* Aufruf: php bin/preflight.php (Exit 0 = startklar, 1 = Fehler offen)
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$errors = [];
$warnings = [];
// --- Konfiguration & Secrets (dieselbe Prüfung, die auch die Formular-Actions macht) ---
foreach (config_problems() as $problem) {
$errors[] = $problem;
}
if ((string) config('env') !== 'production') {
$errors[] = "env steht auf '" . (string) config('env') . "' — in Produktion 'production' (sonst zeigt die Seite PHP-Fehler).";
}
$baseUrl = (string) config('base_url');
if (!str_starts_with($baseUrl, 'https://')) {
$errors[] = "base_url ist nicht https ({$baseUrl}) — Canonical, OG und Sitemap wären falsch.";
}
if (str_ends_with($baseUrl, '/')) {
$warnings[] = 'base_url endet auf "/" — abs_url() erzeugt dann doppelte Slashes.';
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// base_url MUSS der Host sein, den public/.htaccess erzwingt. Läuft es
// auseinander, zeigen Canonical, OG und Sitemap auf Adressen, die der Server
// sofort per 301 weiterleitet — und gedruckte QR-Codes bekommen einen
// unnötigen Umweg eingebacken. Genau dieser Fehler stand schon einmal drin
// (base_url ohne www, .htaccess mit).
$htaccess = PUBLIC_PATH . '/.htaccess';
if (is_file($htaccess)
&& preg_match('~RewriteRule \^ https://([a-z0-9.-]+)%\{REQUEST_URI\}~i', (string) file_get_contents($htaccess), $forced) === 1) {
$forcedHost = strtolower($forced[1]);
$configHost = strtolower((string) parse_url($baseUrl, PHP_URL_HOST));
if ($configHost !== '' && $configHost !== $forcedHost && !str_contains($configHost, 'localhost')) {
$errors[] = "base_url nutzt '{$configHost}', public/.htaccess leitet aber alles auf '{$forcedHost}' um — Canonical, OG und Sitemap zeigten auf weitergeleitete Adressen.";
}
}
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen. h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der --fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar. Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts. Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten- Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()- Rampen für Karten-Überschriften (bento__heading, bento__stat-value, group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich. Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und 11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser, folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest. Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em. Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von tokens.css. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
// --- Abhängigkeiten ---
if (!is_file(ROOT_PATH . '/vendor/autoload.php')) {
$errors[] = 'vendor/ fehlt — auf dem Server "composer install --no-dev --optimize-autoloader" laufen lassen.';
} elseif (!class_exists(PHPMailer\PHPMailer\PHPMailer::class)) {
$errors[] = 'PHPMailer ist nicht ladbar — vendor/ unvollständig?';
}
if (version_compare(PHP_VERSION, '8.1', '<')) {
$errors[] = 'PHP ' . PHP_VERSION . ' ist zu alt — mindestens 8.1 (der Code nutzt never-Return-Types und enums-Syntax).';
}
// --- Schreibrechte (Logs, Rate-Limit-Zähler, Sync-Caches) ---
foreach (['logs', 'ratelimit', 'cache'] as $dir) {
$path = STORAGE_PATH . '/' . $dir;
if (!is_dir($path)) {
$errors[] = "storage/{$dir}/ fehlt.";
} elseif (!is_writable($path)) {
$errors[] = "storage/{$dir}/ ist nicht beschreibbar (Rate-Limiting und Logging fielen sonst still aus).";
}
}
foreach (['img/instagram', 'img/crests'] as $dir) {
$path = PUBLIC_PATH . '/assets/' . $dir;
if (!is_dir($path) || !is_writable($path)) {
$warnings[] = "public/assets/{$dir}/ fehlt oder ist nicht beschreibbar — der Sync kann dort keine Bilder ablegen.";
}
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// --- Dateiablage /dateien (Files Gallery) ---
// Die App muss in ihr Upload- und Storage-Verzeichnis schreiben können, sonst bricht
// schon der erste Aufruf ab (sie legt Cache-Verzeichnisse selbst an).
foreach (['uploads', 'system'] as $dir) {
$path = STORAGE_PATH . '/dateien/' . $dir;
if (!is_dir($path)) {
$errors[] = "storage/dateien/{$dir}/ fehlt — anlegen und beschreibbar machen.";
} elseif (!is_writable($path)) {
$errors[] = "storage/dateien/{$dir}/ ist nicht beschreibbar — Uploads und Thumbnails scheitern.";
}
}
// Ohne echten Hash ist der Zugang gesperrt (fail closed) — das soll auffallen, bevor
// jemand vor dem Login-Formular steht und sich wundert.
if (config('dateien') === null) {
$errors[] = "Der Block 'dateien' fehlt in config/config.php — die Dateiablage hätte keine Zugänge (siehe config.example.php).";
} else {
foreach (['verein', 'admin'] as $user) {
$hash = (string) config("dateien.users.{$user}.password_hash", '');
if ($hash === '' || $hash === 'CHANGE_ME') {
$errors[] = "dateien.users.{$user}.password_hash fehlt — dieser Zugang ist gesperrt.";
} elseif (password_get_info($hash)['algoName'] === 'unknown') {
$warnings[] = "dateien.users.{$user}.password_hash sieht wie Klartext aus — besser hashen: php -r \"echo password_hash('…', PASSWORD_DEFAULT);\"";
}
}
if (in_array((string) config('dateien.license_key', ''), ['', 'CHANGE_ME'], true)) {
$warnings[] = 'dateien.license_key fehlt — die Dateiablage läuft, zeigt aber den Hinweis der unlizenzierten Version.';
}
}
// Der Patch und die self-hosted Assets entscheiden darüber, ob die App überhaupt
// mit Login startet und ob sie ohne CDN auskommt. Details: bin/filesgallery-update.php
$galleryApp = PUBLIC_PATH . '/dateien/index.php';
if (!is_file($galleryApp)) {
$errors[] = 'public/dateien/index.php fehlt — die Dateiablage ist nicht ausgeliefert.';
} else {
$source = (string) file_get_contents($galleryApp);
if (!str_contains($source, "\$localconfigpath = __DIR__ . '/_filesconfig.php'")) {
$errors[] = 'public/dateien/index.php ist ungepatcht — die App würde OHNE Login starten. php bin/filesgallery-update.php';
}
// Wer Einstellungen direkt in die vendorte Datei schreibt, verliert sie beim nächsten
// Update — und Secrets hätten dort ohnehin nichts zu suchen (Regel 5). Die App warnt
// selbst davor (index.php:33), also fangen wir es hier ab.
if (preg_match('/^\s*\'(license_key|username|password)\' => \'.+\',/m', $source, $edited) === 1) {
$errors[] = "public/dateien/index.php wurde direkt editiert ('{$edited[1]}') — Wert nach config/config.php (dateien.*) verschieben und die Zeile leeren, sonst ist er nach dem nächsten Update weg.";
}
$version = preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : '?';
if (!is_file(PUBLIC_PATH . "/assets/filesgallery/files.photo.gallery@{$version}/css/files.css")) {
$errors[] = "Assets der Dateiablage fehlen (Version {$version}) — sonst lädt sie vom CDN und die CSP blockt alles: php bin/filesgallery-update.php --assets";
}
}
// Es gilt immer die kleinere von zwei Grenzen: unsere aus _filesconfig.php und die von
// PHP. Steht PHP niedriger, wird jedes größere Video abgewiesen, ohne dass man es
// unserer Konfiguration ansehen könnte — deshalb hier vergleichen, nicht nur ausgeben.
$phpUpload = ini_parse_quantity((string) ini_get('upload_max_filesize'));
$phpPost = ini_parse_quantity((string) ini_get('post_max_size'));
// Bewusst gelesen und nicht ausgeführt: _filesconfig.php setzt display_errors und
// Session-Parameter — Nebenwirkungen, die in diesem Skript nichts zu suchen haben.
$ourMax = 0;
$galleryConfigFile = PUBLIC_PATH . '/dateien/_filesconfig.php';
if (is_file($galleryConfigFile)
&& preg_match("/'upload_max_filesize'\s*=>\s*(\d+)/", (string) file_get_contents($galleryConfigFile), $um) === 1) {
$ourMax = (int) $um[1];
}
$mb = static fn (int|float $bytes): string => round($bytes / 1048576) . ' MB';
echo 'INFO Uploadgrenze: ' . ($ourMax > 0 ? $mb($ourMax) : 'unbekannt')
. " laut _filesconfig.php, diese PHP-CLI erlaubt {$mb($phpUpload)} (post_max_size {$mb($phpPost)})\n";
if ($ourMax > 0 && $phpUpload < $ourMax) {
// Achtung: das hier ist die CLI-Konfiguration. Für Uploads zählt die des Webservers
// (FPM + .user.ini), die abweichen darf — deshalb Warnung und nicht Fehler.
$warnings[] = "Diese PHP-CLI deckelt Uploads auf {$mb($phpUpload)}, unsere Config erlaubt {$mb($ourMax)}. Für Uploads zählt aber die Webserver-Konfiguration: public/dateien/.user.ini setzt {$mb($ourMax)}. Verbindlich prüfen mit einem echten großen Upload über /dateien (oder phpinfo() im Browser).";
}
if ($phpPost <= $phpUpload) {
$warnings[] = "post_max_size ({$mb($phpPost)}) ist nicht größer als upload_max_filesize ({$mb($phpUpload)}) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung.";
}
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen. h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der --fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar. Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts. Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten- Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()- Rampen für Karten-Überschriften (bento__heading, bento__stat-value, group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich. Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und 11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser, folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest. Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em. Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von tokens.css. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
// --- Datenstände der Cron-Skripte (fehlen nur beim Erstlauf) ---
foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchcenter-sync.php'] as $name => $script) {
$file = DATA_PATH . '/' . $name . '.json';
if (!is_file($file)) {
$warnings[] = "data/{$name}.json fehlt — einmal \"php {$script}\" von Hand anstoßen.";
} elseif (filemtime($file) < time() - 3 * 86400) {
$warnings[] = "data/{$name}.json ist älter als 3 Tage — läuft der Cron für {$script}?";
}
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News, Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover, Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das Skript selbst, statt sie von Hand zu prüfen. Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie doppelt gepflegt wird. News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head. Behobene Fehler: - Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen (Ergebnis-Rückblick, Vorschau, Cover). - --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links unter dem Logo. - base_url in der Produktions-Config ohne www, während .htaccess auf www umleitet; preflight prüft das jetzt. - .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen. Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php (Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit Warnung, wenn base_url nicht auf die echte Domain zeigt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// --- Termin-Quellen: events.json vs. veranstaltungen.json ---
// Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne
// eigene Seite in events.json, mit eigener Seite in veranstaltungen.json.
// Steht es in beiden, taucht es doppelt in der Termine-Liste auf und /termine
// baut zwei Event-Knoten für dasselbe Fest — beides fällt im Alltag kaum auf,
// deshalb prüft es die Maschine.
$eventSlugs = array_filter(array_column(json_load('events')['events'] ?? [], 'slug'));
$veranstaltungRows = json_load('veranstaltungen')['veranstaltungen'] ?? [];
$veranstaltungSlugs = array_column($veranstaltungRows, 'slug');
$doppelt = array_intersect($eventSlugs, $veranstaltungSlugs);
if ($doppelt !== []) {
$errors[] = 'Diese Slugs stehen in data/events.json UND data/veranstaltungen.json: '
. implode(', ', $doppelt) . ' — der Termin erscheint doppelt. Eintrag in events.json löschen.';
}
if (count($veranstaltungSlugs) !== count(array_unique($veranstaltungSlugs))) {
$errors[] = 'data/veranstaltungen.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.';
}
foreach ($veranstaltungRows as $i => $row) {
if (empty($row['slug']) || empty($row['title']) || empty($row['date_start'])) {
$errors[] = "data/veranstaltungen.json, Eintrag #{$i}: slug, title und date_start sind Pflicht.";
}
}
// --- Formular-Empfänger auf eigener Domain ---
// app/pages/datenschutz.php sagt zu, dass wir "keinen zusätzlichen
// E-Mail-Dienstleister" einsetzen. Das stimmt nur, solange jeder Empfänger ein
// Postfach auf unserer Domain bei All-Inkl ist. Zeigt club.json auf einen
// fremden Anbieter (die Turnabteilung lief früher auf Gmail), muss die
// Datenschutzerklärung mit — und das fällt von allein nie auf.
$clubMails = array_filter(
json_load('club'),
static fn ($v, string $k): bool => str_starts_with($k, 'email') && is_string($v) && $v !== '',
ARRAY_FILTER_USE_BOTH
);
foreach ($clubMails as $key => $address) {
if (!str_ends_with(strtolower((string) $address), '@tsv08kulmbach.de')) {
$warnings[] = "club.json → {$key} ({$address}) liegt nicht auf tsv08kulmbach.de."
. ' Formular-Mails an diese Adresse verlassen die All-Inkl-Auftragsverarbeitung —'
. ' dann muss app/pages/datenschutz.php den Anbieter nennen.';
}
}
// --- News (data/news.json) ---
// Meldungen mit `artikel` haben eine eigene Seite /news/<slug>. Die Fehler, die
// dabei still ins Nichts laufen, prüft die Maschine: ein Artikel ohne Slug ist
// nicht erreichbar, ein doppelter Slug verdeckt den zweiten Artikel, ein
// unbekannter Blocktyp verschluckt seinen Inhalt beim Rendern.
$newsRows = json_load('news')['news'] ?? [];
$newsSlugs = array_values(array_filter(array_column($newsRows, 'slug')));
$newsBlockTypen = ['text', 'zwischentitel', 'bild', 'zitat'];
if (count($newsSlugs) !== count(array_unique($newsSlugs))) {
$errors[] = 'data/news.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.';
}
// Kollision mit einem exakten Routen-Slug: der Prefix-Match für 'news/' läuft in
// public/index.php VOR dem routes.php-Lookup. Ein künftiger Eintrag wie
// 'news/archiv' wäre also von der Detailseite verdeckt, und die Ursache dafür
// sieht man an der weißen Seite nicht.
$routeSlugs = array_keys(require APP_PATH . '/routes.php');
$verdeckt = array_intersect(array_map(static fn (string $s): string => 'news/' . $s, $newsSlugs), $routeSlugs);
if ($verdeckt !== []) {
$errors[] = 'Diese News-Slugs verdecken einen Routen-Eintrag: ' . implode(', ', $verdeckt)
. ' — der Prefix-Match greift vor routes.php. Slug der Meldung ändern.';
}
$gleichnamig = array_intersect($newsSlugs, $veranstaltungSlugs);
if ($gleichnamig !== []) {
$warnings[] = 'Diese Slugs stehen in data/news.json UND data/veranstaltungen.json: '
. implode(', ', $gleichnamig) . ' — die URLs kollidieren nicht (verschiedene Präfixe), aber es sind'
. ' wahrscheinlich zwei Seiten für dieselbe Sache.';
}
foreach ($newsRows as $i => $row) {
if (empty($row['title']) || empty($row['date'])) {
$errors[] = "data/news.json, Eintrag #{$i}: title und date sind Pflicht.";
}
if (!empty($row['date']) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $row['date'])) {
// Der Feed sortiert Datumsangaben als Strings und german_date() fällt
// still auf den Rohwert zurück — ein abweichendes Format sortiert also
// falsch, ohne dass etwas kaputt aussieht.
$errors[] = "data/news.json, Eintrag #{$i}: date muss YYYY-MM-DD sein.";
}
if (empty($row['artikel'])) {
continue; // Kurzmeldung ohne Detailseite — ein Slug ist hier erlaubt, aber nicht nötig.
}
if (empty($row['slug'])) {
$errors[] = "data/news.json, Eintrag #{$i}: mit artikel[] braucht die Meldung einen slug, sonst ist die Detailseite nicht erreichbar.";
} elseif (!preg_match('/^[a-z0-9-]+$/', (string) $row['slug'])) {
$errors[] = "data/news.json, Eintrag #{$i}: slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.";
}
foreach ($row['artikel'] as $b => $block) {
$typ = (string) ($block['typ'] ?? '');
if (!in_array($typ, $newsBlockTypen, true)) {
$errors[] = "data/news.json, Eintrag #{$i}, Block #{$b}: unbekannter typ '{$typ}' — erlaubt sind "
. implode(', ', $newsBlockTypen) . '. Der Block würde beim Rendern verschluckt.';
continue;
}
if ($typ === 'bild' && empty($block['alt'])) {
$errors[] = "data/news.json, Eintrag #{$i}, Block #{$b}: Bild ohne alt-Text.";
}
}
}
// --- Cron-Endpoint (public/cron.php) ---
// Nur relevant, wenn der Tarif keine Cronjobs hat und der Takt von außen kommt.
// Ohne brauchbaren Schlüssel ist der Endpoint zu (403) und nichts synct — das fällt
// sonst wochenlang nicht auf, weil die Seite mit dem alten Cache normal weiterläuft.
$cronKey = (string) config('cron.key', '');
$cronInterval = (int) config('cron.min_interval', 240);
if ($cronKey === '' || $cronKey === 'CHANGE_ME') {
$warnings[] = 'cron.key ist nicht gesetzt — public/cron.php weist jeden Aufruf ab. Nur in Ordnung, wenn der Takt über echte Shell-Cronjobs läuft.';
} else {
if (strlen($cronKey) < 24) {
$errors[] = 'cron.key ist kürzer als 24 Zeichen — public/cron.php verweigert den Dienst. Erzeugen: php -r \'echo bin2hex(random_bytes(24)), "\n";\'';
}
if ($cronKey === (string) config('app_secret')) {
$errors[] = 'cron.key ist identisch mit app_secret — der Cron-Schlüssel liegt bei einem externen Dienst und darf nicht die Formular-Token aufschließen.';
}
if ($cronInterval >= 1800) {
$warnings[] = "cron.min_interval ist {$cronInterval} s — bei halbstündlichem Takt am Spieltag würde jeder zweite Aufruf verworfen.";
}
}
Veröffentlichen friert auch die Bilder ein Der Snapshot fror die Daten ein, verwies aber weiter auf Ordner, die die Sync-Skripte verwalten: matchcenter-sync räumt Wappen weg, die der BFV nicht mehr führt (eine Saison), instagram-sync ältere Posts (Wochen). Am 31.07.2026 nachgewiesen — ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe wäre also mit der Zeit von selbst zerfallen, und "gedruckt = fixiert" war damit nur die halbe Wahrheit. bin/stadionzeitung-publish.php kopiert jetzt alles, worauf der Snapshot zeigt (img/crests, img/instagram, img/news), als fix-* in den Ausgaben-Ordner und schreibt die Pfade um. Varianten-Sets {base, widths} werden über alle Breiten kopiert und nur umgeschrieben, wenn alle da sind — ein halbes srcset wäre schlimmer als der alte Pfad; genau daran fiel img/news/neue-website beim ersten Versuch auf. --entwurf löscht die fix-*-Dateien wieder, sie gehören zum Snapshot. Flache Dateinamen statt Unterordner, weil die Aufräum-Logik in stadionzeitung-add.php nur Dateien entfernt und ein Unterordner als Waise bliebe. bin/preflight.php warnt, wenn eine veröffentlichte Ausgabe noch auf die flüchtigen Ordner zeigt — sonst verfiele sie still. Geprüft am Hutschdorf-Heft: 39 Dateien eingefroren, Snapshot verweist nur noch auf den Ausgaben-Ordner, 88 Bilder in Viewer und Druckfassung erreichbar, keins fehlt, preflight-Warnung verschwindet. Der Testlauf wurde danach zurückgenommen: der lokale Matchcenter-Stand ist einen Tag alt, eingefroren wird auf dem Server nach dem letzten Sync vor dem Druck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:47:44 +02:00
// --- Veröffentlichte Stadionzeitungen: Bilder wirklich eingefroren? ---
// Der Snapshot friert die Daten ein. Zeigt er noch auf img/crests oder img/instagram,
// hängt die Ausgabe weiter am Sync: der räumt Wappen weg, die der BFV nicht mehr führt
// (eine Saison), und Instagram-Bilder älterer Posts (Wochen). Am 31.07.2026 löschte ein
// Lauf 18 Wappen und 3 Bilder. Danach zeigte eine gedruckte Ausgabe Lücken.
// bin/stadionzeitung-publish.php kopiert sie deshalb in den Ausgaben-Ordner.
// Reparatur einer Alt-Ausgabe: `--entwurf` und neu veröffentlichen.
$szFile = DATA_PATH . '/stadionzeitung.json';
if (is_file($szFile)) {
$szData = json_decode((string) file_get_contents($szFile), true) ?: [];
foreach ($szData['issues'] ?? [] as $ausgabe) {
if (($ausgabe['status'] ?? 'veroeffentlicht') !== 'veroeffentlicht' || !isset($ausgabe['snapshot'])) {
continue;
}
$rohSnap = (string) json_encode($ausgabe['snapshot']);
$fluechtig = [];
foreach (['img/crests/', 'img/instagram/'] as $ordner) {
// JSON escapt Schrägstriche, deshalb beide Schreibweisen prüfen.
if (str_contains($rohSnap, $ordner) || str_contains($rohSnap, str_replace('/', '\\/', $ordner))) {
$fluechtig[] = rtrim($ordner, '/');
}
}
if ($fluechtig !== []) {
$warnings[] = "Stadionzeitung '{$ausgabe['slug']}' ist veröffentlicht, ihr Snapshot zeigt aber noch auf "
. implode(' und ', $fluechtig) . ' — diese Ordner verwalten die Sync-Skripte, die Bilder verschwinden dort mit der Zeit.'
. ' Einfrieren: php bin/stadionzeitung-publish.php ' . $ausgabe['slug'] . ' --entwurf && php bin/stadionzeitung-publish.php ' . $ausgabe['slug'];
}
}
}
Banner-Hintergründe CSP-fest: <img> statt Inline-Variable Beim ersten Deploy blieben die Slides des Banner-Sliders schwarz. Ursache war kein fehlendes Bild, sondern die CSP: public/.htaccess setzt default-src 'self' ohne style-src, der Browser verwirft damit jedes style-Attribut — und die Komponente lieferte Bild und Akzentfarbe genau so (style="--banner-bg: url(…); --banner-accent: #6cbbbc"). Lokal war das unsichtbar, weil php -S keine .htaccess liest. Der Hintergrund ist jetzt ein <img class="ad-banner__bg"> über die bestehende img-Komponente (CSP erlaubt img-src) und bringt srcset mit: das Handy lädt die 640er statt der 1600er, die erste Slide lädt eager mit fetchpriority. Der dunkle Leseverlauf liegt auf ::before statt im background-image. Die Akzentfarbe kann als beliebiger Hex-Wert nicht ohne Inline-Style in die Seite. Sie steht deshalb als Token in tokens.css (wo Farben laut CLAUDE.md hingehören), components.css bildet den Namen darauf ab, und banners.json nennt nur noch "accent": "breadcrumb". bin/preflight.php bricht ab, wenn unter app/ wieder ein style-Attribut auftaucht — der Fehler wäre sonst erneut erst in Produktion sichtbar. Beim Bauen dieser Wache zweimal selbst hineingetappt: ein Muster mit [^>]* bricht an jedem schließenden PHP-Tag ab, und die Zeichenfolge dieses Tags beendet den PHP-Modus auch in einem //-Kommentar (preflight gab ab dort seinen eigenen Quelltext aus, php -l meldete nichts). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:50:43 +02:00
// --- Inline-Styles (die CSP verwirft sie stillschweigend) ---
// Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der
// Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf
// echtem Apache greift die CSP aus public/.htaccess (default-src 'self', KEIN
// style-src), der Browser verwarf das Attribut, die Slides blieben schwarz. Lokal
// war das unsichtbar, weil `php -S` keine .htaccess liest — der Fehler konnte also
// erst in Produktion auffallen. Genau davor schützt diese Wache.
// Hinweis: per JavaScript gesetzte Styles (element.style.…) sind NICHT betroffen,
// die CSP prüft nur vom HTML-Parser gelesene style-Attribute und <style>-Blöcke.
/* Zeilenweise und ohne Tag-Kontext geprüft: ein Muster, das beim öffnenden Tag
ansetzt und mit [^>]* weitersucht, bricht sofort ab PHP-Templates enthalten
über das schließende PHP-Tag ständig eine spitze Klammer. Ein style-Attribut in
einer PHP-Datei ist praktisch immer echtes Markup, diese Prüfung genügt also.
Dieser Kommentar ist bewusst ein Blockkommentar: die Zeichenfolge des schließenden
PHP-Tags beendet den PHP-Modus AUCH in einem //-Kommentar. Genau daran ist diese
Prüfung beim Bauen einmal gescheitert (preflight gab ab dort seinen eigenen
Quelltext aus, und php -l meldete nichts, weil das gültiges PHP mit HTML ist). */
$inlineStyles = [];
$phpDateien = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(ROOT_PATH . '/app'));
foreach ($phpDateien as $datei) {
if (!$datei->isFile() || $datei->getExtension() !== 'php') {
continue;
}
foreach (file($datei->getPathname()) ?: [] as $nr => $zeile) {
$roh = ltrim($zeile);
// Kommentarzeilen überspringen — dieser Abschnitt selbst nennt style=" ja auch.
if ($roh === '' || str_starts_with($roh, '*') || str_starts_with($roh, '//') || str_starts_with($roh, '/*')) {
continue;
}
if (preg_match('/\sstyle\s*=\s*"/i', $zeile) === 1) {
$kurz = str_replace(ROOT_PATH . '/', '', $datei->getPathname());
$inlineStyles[] = $kurz . ':' . ($nr + 1);
break;
}
}
}
if ($inlineStyles !== []) {
$errors[] = 'Inline-Style im Markup — die CSP (kein style-src) verwirft ihn, die Regel greift NUR in Produktion: '
. implode(', ', $inlineStyles)
. '. Bilder als <img> ausgeben, Farben als Token in tokens.css plus Klasse in components.css.';
}
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen. h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der --fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar. Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts. Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten- Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()- Rampen für Karten-Überschriften (bento__heading, bento__stat-value, group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich. Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und 11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser, folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest. Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em. Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von tokens.css. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
// --- Ausgabe ---
foreach ($warnings as $w) {
echo "WARNUNG {$w}\n";
}
foreach ($errors as $e) {
echo "FEHLER {$e}\n";
}
if ($errors === []) {
echo $warnings === [] ? "Alles startklar.\n" : "Keine Fehler — nur die Warnungen oben prüfen.\n";
echo "Nächster Schritt: php bin/smtp-test.php (Login testen) und je eine Testmail über die drei Formulare.\n";
exit(0);
}
echo "\n" . count($errors) . " Fehler offen — noch nicht live gehen.\n";
exit(1);