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
This commit is contained in:
2026-07-31 14:55:27 +02:00
parent 3fb480dd78
commit a101b0c3ec
861 changed files with 75244 additions and 487 deletions

View File

@@ -3,14 +3,18 @@
declare(strict_types=1);
/**
* Logrotation für storage/logs/*.log — NUR CLI, per Cron 1×/Woche.
* Logpflege für storage/logs/ — NUR CLI, per Cron 1×/Woche.
*
* Wird eine Datei größer als das Limit, wandert sie nach <name>.log.1 (eine
* vorhandene .1 wird ersetzt) und das aktive Log startet leer. Damit bleiben
* höchstens zwei Generationen pro Log liegen — genug zum Nachsehen, ohne dass
* spam.log oder php-errors.log auf dem Shared Hosting unbegrenzt wachsen.
* Zwei Aufgaben:
* 1. Rotation nach Größe: wird eine Datei größer als das Limit, wandert sie nach
* <name>.log.1 (eine vorhandene .1 wird ersetzt) und das aktive Log startet leer.
* Es bleiben also höchstens zwei Generationen pro Log liegen.
* 2. Aufbewahrungsfrist: Einträge, die älter als --keep-days sind, werden aus den
* aktiven Logs entfernt; rotierte .1-Dateien jenseits der Frist gelöscht. Damit
* gibt es eine echte Löschfrist statt nur einer Größengrenze — auf einer ruhigen
* Vereinsseite läge ein Eintrag sonst jahrelang herum.
*
* Aufruf: php bin/log-rotate.php [--max-mb=2]
* Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v]
*/
if (PHP_SAPI !== 'cli') {
exit(1);
@@ -19,30 +23,108 @@ if (PHP_SAPI !== 'cli') {
require dirname(__DIR__) . '/app/bootstrap.php';
$maxMb = 2.0;
$keepDays = 90;
$verbose = in_array('-v', $argv, true);
foreach ($argv as $arg) {
if (preg_match('/^--max-mb=([0-9.]+)$/', $arg, $m) === 1) {
$maxMb = (float) $m[1];
} elseif (preg_match('/^--keep-days=(\d+)$/', $arg, $m) === 1) {
$keepDays = max(1, (int) $m[1]);
}
}
$limit = (int) ($maxMb * 1024 * 1024);
$cutoff = time() - $keepDays * 86400;
$logDir = STORAGE_PATH . '/logs';
/**
* Zeitstempel am Zeilenanfang: `[ISO]` (log_write), `[27-Jul-2026 08:11:45 UTC]`
* (PHP selbst) oder ein nackter ISO-Stempel. Null = keiner.
*/
$stampOf = static function (string $line): ?int {
if (preg_match('/^\[([^\]]+)\]/', $line, $m) === 1 || preg_match('/^(\d{4}-\d{2}-\d{2}\S*)/', $line, $m) === 1) {
$ts = strtotime($m[1]);
return $ts === false ? null : $ts;
}
return null;
};
/**
* Einträge vor $cutoff entfernen. Zeilen ohne eigenen Zeitstempel (z. B. PHP-Stacktraces)
* gehören zum vorherigen Eintrag und teilen dessen Schicksal. Geschrieben wird über eine
* temporäre Datei + rename; ein Append genau in diesem Moment ginge verloren — bei einem
* wöchentlichen Lauf nachts ein akzeptables Risiko, dafür bleibt die Datei immer konsistent.
*/
$prune = static function (string $file) use ($cutoff, $stampOf): int {
$raw = (string) @file_get_contents($file);
if ($raw === '') {
return 0;
}
$keep = [];
$dropping = false;
$removed = 0;
foreach (explode("\n", $raw) as $line) {
$ts = $stampOf($line);
if ($ts !== null) {
$dropping = $ts < $cutoff;
}
if ($dropping) {
if (trim($line) !== '') {
$removed++;
}
continue;
}
$keep[] = $line;
}
if ($removed === 0) {
return 0;
}
$tmp = $file . '.tmp';
if (@file_put_contents($tmp, implode("\n", $keep)) === false || !@rename($tmp, $file)) {
@unlink($tmp);
return 0;
}
@chmod($file, 0664);
return $removed;
};
$rotated = 0;
foreach (glob(STORAGE_PATH . '/logs/*.log') ?: [] as $log) {
$pruned = 0;
$deleted = 0;
foreach (glob($logDir . '/*.log') ?: [] as $log) {
$name = basename($log);
// 1. Alte Einträge raus (vor der Größenprüfung — spart oft die Rotation).
$removed = $prune($log);
if ($removed > 0) {
$pruned += $removed;
if ($verbose) {
echo "gekürzt: {$name} ({$removed} Einträge älter als {$keepDays} Tage)\n";
}
}
// 2. Rotation nach Größe.
$size = (int) @filesize($log);
if ($size <= $limit) {
continue;
if ($size > $limit) {
if (@rename($log, $log . '.1')) {
@touch($log);
@chmod($log, 0664);
echo "rotiert: {$name} (" . round($size / 1048576, 1) . " MB)\n";
$rotated++;
} else {
fwrite(STDERR, "Rotation fehlgeschlagen: {$name}\n");
}
}
if (!@rename($log, $log . '.1')) {
fwrite(STDERR, 'Rotation fehlgeschlagen: ' . basename($log) . "\n");
continue;
}
// Neue, leere Datei mit denselben Rechten anlegen, damit PHP/Cron weiterschreiben können.
@touch($log);
@chmod($log, 0664);
echo 'rotiert: ' . basename($log) . ' (' . round($size / 1048576, 1) . " MB)\n";
$rotated++;
}
echo $rotated === 0
? "Nichts zu rotieren (alle Logs unter {$maxMb} MB).\n"
: "{$rotated} Log(s) rotiert.\n";
// 3. Rotierte Generationen jenseits der Frist löschen.
foreach (glob($logDir . '/*.log.1') ?: [] as $old) {
if ((int) @filemtime($old) < $cutoff && @unlink($old)) {
echo 'gelöscht: ' . basename($old) . " (älter als {$keepDays} Tage)\n";
$deleted++;
}
}
echo $rotated === 0 && $pruned === 0 && $deleted === 0
? "Nichts zu tun (alle Logs unter {$maxMb} MB, keine Einträge älter als {$keepDays} Tage).\n"
: "Fertig: {$rotated} rotiert, {$pruned} alte Einträge entfernt, {$deleted} Altdatei(en) gelöscht.\n";