\'.+\',/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."; } // --- Datenstand des Cron-Skripts (fehlt nur beim Erstlauf) --- // Nur noch matchcenter: data/instagram.json ist seit dem 17.09.2026 chat-gepflegt // (bin/instagram-add.php) und nicht „alt“, nur weil eine Woche nichts passiert ist. foreach (['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}?"; } } // --- Instagram: Einträge und Bilder müssen zusammenpassen --- // Genau daran ist es am 17.09.2026 auseinandergelaufen: die Bilder waren versioniert, // data/instagram.json nicht. Ein Eintrag ohne Bild ist auf der Startseite eine kaputte // Kachel, ein Bild ohne Eintrag eine Leiche, die niemand mehr findet. $igPosts = json_load('instagram')['posts'] ?? []; $igFehlt = []; foreach ($igPosts as $igPost) { if (!empty($igPost['image']) && !is_file(PUBLIC_PATH . '/assets/' . $igPost['image'])) { $igFehlt[] = $igPost['image']; } } if ($igFehlt !== []) { $errors[] = 'Instagram-Beiträge ohne Bild: ' . implode(', ', $igFehlt) . '. Bild nachholen mit: php bin/instagram-add.php https://www.instagram.com/p//'; } $igCodes = array_column($igPosts, 'shortcode'); foreach (glob(PUBLIC_PATH . '/assets/img/instagram/*.jpg') ?: [] as $igDatei) { if (!in_array(basename($igDatei, '.jpg'), $igCodes, true)) { $warnings[] = 'Instagram-Bild ohne Eintrag im Feed: ' . basename($igDatei) . ' — entweder aufnehmen oder löschen.'; } } // --- 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/. 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ö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']; } } } // --- Stadionzeitung ↔ Veranstaltung (Sportfest-Ausgabe) --- // Eine Ausgabe mit `veranstaltung`-Feld zieht Programm, Turnierpläne und die // Gegnerseiten je Heimspiel aus data/veranstaltungen.json. Ein Tippfehler im Slug // oder im Turnier-Key rendert dort eine LEERE A5-Seite — ohne Fehlermeldung, weil // die Komponenten bewusst still zurückkehren. Deshalb hier laut. if (is_file($szFile)) { $szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []); $vBySlug = array_column($veranstaltungRows, null, 'slug'); foreach ($szData['issues'] ?? [] as $ausgabe) { $vSlug = (string) ($ausgabe['veranstaltung'] ?? ''); if ($vSlug === '') { continue; } if (!isset($vBySlug[$vSlug])) { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': veranstaltung '{$vSlug}' fehlt in data/veranstaltungen.json."; continue; } $turniere = $vBySlug[$vSlug]['turniere'] ?? []; foreach ($turniere as $i => $turnier) { foreach (['key', 'title', 'date', 'teams', 'spiele'] as $feld) { if (empty($turnier[$feld])) { $warnings[] = "data/veranstaltungen.json, '{$vSlug}' → turniere[{$i}]: Feld '{$feld}' fehlt oder ist leer."; } } } $keys = array_column($turniere, 'key'); foreach ($ausgabe['images'] ?? [] as $seite) { if (($seite['type'] ?? '') === 'live' && ($seite['page'] ?? '') === 'turnier' && !in_array((string) ($seite['turnier'] ?? ''), $keys, true)) { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Turnierseite '" . ($seite['turnier'] ?? '') . "' hat keinen Eintrag in turniere[] von '{$vSlug}'."; } } } } // --- Stadionzeitung: Galerie-Fotos vorhanden? --- // Die Galerie-Bilder liegen im Ausgaben-Ordner, den bin/stadionzeitung-add.php bei // jedem Lauf leert und neu füllt. Fehlt eine Datei (Lauf ohne galerie/ im Ordner, // Bild nicht mitcommittet), zeigt die Seite ein kaputtes Bild. if (is_file($szFile)) { $szData = $szData ?? (json_decode((string) file_get_contents($szFile), true) ?: []); foreach ($szData['issues'] ?? [] as $ausgabe) { if (is_array($ausgabe['beitrag'] ?? null)) { $beitrag = $ausgabe['beitrag']; foreach (['heading', 'source', 'source_url', 'image_credit'] as $feld) { if (trim((string) ($beitrag[$feld] ?? '')) === '') { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrag ohne {$feld}."; } } $quelle = (string) ($beitrag['source_url'] ?? ''); if (!filter_var($quelle, FILTER_VALIDATE_URL) || parse_url($quelle, PHP_URL_SCHEME) !== 'https') { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrag mit ungültiger Quellen-URL."; } $foto = $beitrag['image'] ?? []; if (empty($foto['base']) || empty($foto['widths']) || empty($foto['alt'])) { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitragsfoto oder Alt-Text fehlt."; } foreach ($foto['widths'] ?? [] as $w) { if (!is_file(PUBLIC_PATH . '/assets/' . ($foto['base'] ?? '') . "-{$w}.jpg")) { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitragsfoto fehlt ({$w}px)."; } } if (!is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/' . $ausgabe['slug'] . '/qr-beitrag.png')) { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Beitrags-QR fehlt."; } } if (is_array($ausgabe['rueckblick'] ?? null)) { $rueckblick = $ausgabe['rueckblick']; $foto = $rueckblick['image'] ?? []; if (empty($foto['base']) || empty($foto['widths'])) { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Foto zum Spielrückblick fehlt."; } foreach ($foto['widths'] ?? [] as $w) { if (!is_file(PUBLIC_PATH . '/assets/' . ($foto['base'] ?? '') . "-{$w}.jpg")) { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Rückblick-Foto fehlt ({$w}px)."; } } if (trim((string) ($rueckblick['credit'] ?? '')) === '') { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Bildnachweis zum Spielrückblick fehlt."; } } foreach ($ausgabe['galerie']['bilder'] ?? [] as $bild) { foreach ($bild['widths'] ?? [] as $w) { $datei = PUBLIC_PATH . '/assets/' . ($bild['base'] ?? '') . "-{$w}.jpg"; if (!is_file($datei)) { $errors[] = "Stadionzeitung '{$ausgabe['slug']}': Galerie-Foto fehlt (" . ($bild['base'] ?? '?') . "-{$w}.jpg)."; } } } } } // --- 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