Files
tsv08kulmbach-website/bin/preflight.php
fs 75fce4f680 Logo-Downloadseite /logo mit Erzeuger-Skript
Damit die Abteilungen das Wappen selbst holen können, statt jedes Mal zu fragen:
drei Varianten (farbig, einfarbig schwarz, einfarbig weiß) in SVG, PDF, PNG und JPG,
dazu ein ZIP über alles und die Vereinsfarben. Verlinkt nur in der Fußzeile.

Bisher lag das Logo genau einmal im Projekt, als eine einzige SVG. Es gab kein PNG,
kein JPG, kein PDF und keine einfarbige Fassung.

bin/logo-build.php erzeugt alle Dateien aus einer Master-SVG unter storage/logo-quellen/.
Gerastert wird mit headless Chrome, weil GD kein SVG lesen kann und weder Inkscape noch
rsvg-convert noch ImageMagick verfügbar sind; das PDF ist dabei echter Vektor. Das Skript
läuft einmalig lokal, nichts davon im Request-Pfad. bin/preflight.php meldet als Fehler,
wenn eine Download-Datei fehlt: die Komponente ließe den Knopf sonst still weg.

Die beiden einfarbigen Fassungen sind exakte Negative voneinander, nur Linien, alle
Flächen frei. EPS gibt es bewusst nicht, dafür fehlt jedes Werkzeug; SVG und PDF decken
die Druckereien ab. Frühere Vereinslogos sind vorbereitet, aber im Projekt liegt bisher
kein einziges historisches Wappen vor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
2026-09-17 10:10:08 +02:00

465 lines
24 KiB
PHP

<?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.';
}
// 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.";
}
}
// --- 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.";
}
}
// --- 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.";
}
// --- 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}?";
}
}
// --- 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ö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}'.";
}
}
}
}
// --- 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.';
}
// --- Logo-Downloads (/logo) ---
// Ein fehlender Download sieht man der Seite nicht an: die Komponente lässt den Knopf
// still weg, die Seite bleibt heil und niemand merkt, dass eine Variante fehlt. Deshalb
// ein Fehler und keine Warnung. Erzeugt werden die Dateien mit bin/logo-build.php.
$logo = json_load('logo');
$logoDir = PUBLIC_PATH . '/assets/downloads/logo';
$logoFehlt = [];
foreach (array_merge($logo['varianten'] ?? [], $logo['retro'] ?? []) as $variante) {
if (empty($variante['key'])) {
continue;
}
$basis = 'tsv08-kulmbach-logo-' . $variante['key'];
// Mindestens diese drei muss es geben; die übrigen Breiten hängen an der Quelle
// (ein gescanntes Altlogo liefert weder SVG noch PDF, siehe bin/logo-build.php).
foreach ([$basis . '-1000.png', $basis . '-1000.jpg'] as $datei) {
if (!is_file($logoDir . '/' . $datei)) {
$logoFehlt[] = $datei;
}
}
}
if ($logo !== [] && !is_file($logoDir . '/tsv08-kulmbach-logo-paket.zip')) {
$logoFehlt[] = 'tsv08-kulmbach-logo-paket.zip';
}
if ($logoFehlt !== []) {
$errors[] = 'Logo-Downloads fehlen (/logo zeigt den Knopf dann einfach nicht an): '
. implode(', ', $logoFehlt) . '. Erzeugen mit: php bin/logo-build.php';
}
// Master neuer als das Erzeugte? Dann steht auf der Seite ein altes Logo.
$logoMaster = glob(STORAGE_PATH . '/logo-quellen/*.svg') ?: [];
$logoNeuestesZiel = 0;
foreach (glob($logoDir . '/*') ?: [] as $datei) {
$logoNeuestesZiel = max($logoNeuestesZiel, (int) filemtime($datei));
}
foreach ($logoMaster as $datei) {
if ((int) filemtime($datei) > $logoNeuestesZiel) {
$warnings[] = 'Logo-Master ist neuer als die Download-Dateien ('
. basename($datei) . ') — php bin/logo-build.php neu laufen lassen.';
break;
}
}
// --- 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);