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

273
bin/filesgallery-update.php Normal file
View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/**
* Files Gallery (Dateiablage /dateien) aktualisieren — App-Datei und self-hosted Assets.
*
* Warum ein Skript: die App lädt ihre Assets sonst vom jsDelivr-CDN (index.php:965), was
* gegen Regel 1 verstößt. Self-Hosting ist offiziell vorgesehen, muss aber bei JEDEM
* Versionswechsel wiederholt werden, weil die Asset-Pfade die Version enthalten
* (files.photo.gallery@<version>/...). Dazu kommt der TSV-PATCH in index.php, den ein
* Update sonst wegwirft. Beides von Hand ist genau der Schritt, der einmal vergessen wird.
*
* NUR CLI/Build — nie im Request-Pfad. Die einzige ausgehende Verbindung ist jsDelivr,
* und die passiert hier, nicht im Browser des Besuchers.
*
* Aufruf:
* php bin/filesgallery-update.php --check nur prüfen (Patch da? Assets zur Version da?)
* php bin/filesgallery-update.php --assets Assets zur vendorten Version neu holen
* php bin/filesgallery-update.php neue index.php holen, patchen, Assets holen
*
* Nach einem echten Versionssprung: Login, Upload und Vorschau einmal durchklicken und
* die alten Asset-Verzeichnisse löschen (das Skript sagt, welche das sind).
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
const FG_APP = PUBLIC_PATH . '/dateien/index.php';
const FG_ASSETS_DIR = PUBLIC_PATH . '/assets/filesgallery';
const FG_APP_URL = 'https://cdn.jsdelivr.net/npm/files.photo.gallery/index.php';
const FG_LOG = STORAGE_PATH . '/logs/filesgallery.log';
// Der Patch, ohne den die App bei abweichendem Arbeitsverzeichnis ohne Login startet.
const FG_PATCH_NEEDLE = "public static \$localconfigpath = '_filesconfig.php';";
const FG_PATCH_MARKER = "public static \$localconfigpath = __DIR__ . '/_filesconfig.php';";
const FG_PATCH_REPLACEMENT = <<<'PHP'
// TSV-PATCH (einzige Änderung an dieser Datei): absolut statt relativ.
// Der Original-Pfad wird gegen das aktuelle Arbeitsverzeichnis aufgelöst. Weicht das ab
// (z. B. unter `php -S` mit Router-Script), findet die App _filesconfig.php nicht und
// startet mit ihrer Default-Config — also OHNE Login und mit falschen Pfaden.
// Nebeneffekt: der Selbst-Ausschluss in Path::is_exclude() vergleicht diesen Wert mit
// einem Basename und greift dadurch nicht mehr. Belanglos, weil unser `root`
// (storage/dateien/uploads) außerhalb dieses Verzeichnisses liegt.
// Nach jedem Update neu setzen: `php bin/filesgallery-update.php` erledigt und prüft das.
public static $localconfigpath = __DIR__ . '/_filesconfig.php';
PHP;
$mode = $argv[1] ?? '';
if (!in_array($mode, ['', '--check', '--assets'], true)) {
fwrite(STDERR, "Unbekannte Option: {$mode}\nAufruf: php bin/filesgallery-update.php [--check|--assets]\n");
exit(1);
}
$errors = 0;
function fg_log(string $line): void
{
@file_put_contents(FG_LOG, date('c') . ' ' . $line . "\n", FILE_APPEND);
}
function fg_fail(string $message): void
{
global $errors;
$errors++;
fwrite(STDERR, "{$message}\n");
fg_log('FEHLER ' . $message);
}
/** Datei von jsDelivr holen; gibt den Rohinhalt zurück oder null. */
function fg_fetch(string $url): ?string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 120, // die Assets sind ein paar MB
CURLOPT_USERAGENT => 'tsv08kulmbach-filesgallery-update/1.0',
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
if (!is_string($body) || $body === '' || $status !== 200) {
fg_fail("Download fehlgeschlagen ({$status}" . ($error !== '' ? ", {$error}" : '') . "): {$url}");
return null;
}
return $body;
}
/** Version aus dem Quelltext der App lesen. */
function fg_version(string $source): ?string
{
return preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : null;
}
/**
* Den TSV-PATCH anwenden. Idempotent: ist er schon drin, bleibt die Datei unverändert.
* Fehlt beides (Patch und Original-Zeile), bricht das Skript ab — dann hat die App ihre
* Struktur geändert und der Patch muss von Hand nachgezogen werden.
*/
function fg_patch(string $source): ?string
{
if (str_contains($source, FG_PATCH_MARKER)) {
return $source;
}
if (!str_contains($source, FG_PATCH_NEEDLE)) {
fg_fail('Patch-Stelle nicht gefunden ($localconfigpath) — index.php von Hand prüfen, NICHT ungepatcht ausliefern.');
return null;
}
// Die Original-Zeile trägt einen Kommentar hinter dem Semikolon; den behalten wir.
return (string) preg_replace(
'/^\h*' . preg_quote(FG_PATCH_NEEDLE, '/') . '/m',
FG_PATCH_REPLACEMENT,
$source,
1
);
}
/** assets.zip zur Version holen und nach public/assets/filesgallery/ entpacken. */
function fg_assets(string $version): void
{
$url = "https://cdn.jsdelivr.net/npm/files.photo.gallery@{$version}/_files/assets/assets.zip";
$zipData = fg_fetch($url);
if ($zipData === null) {
return;
}
$tmp = tempnam(sys_get_temp_dir(), 'fg-assets-');
if ($tmp === false || file_put_contents($tmp, $zipData) === false) {
fg_fail('Konnte assets.zip nicht zwischenspeichern.');
return;
}
$zip = new ZipArchive();
if ($zip->open($tmp) !== true) {
fg_fail('assets.zip ist kein lesbares ZIP-Archiv.');
@unlink($tmp);
return;
}
if (!is_dir(FG_ASSETS_DIR) && !mkdir(FG_ASSETS_DIR, 0775, true) && !is_dir(FG_ASSETS_DIR)) {
fg_fail('Kann ' . FG_ASSETS_DIR . ' nicht anlegen.');
$zip->close();
@unlink($tmp);
return;
}
$written = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
// Manche Archive tragen den _files/assets-Pfad noch mit sich; wir wollen den Inhalt flach.
$rel = (string) preg_replace('#^(\./)?(_files/)?assets/#', '', $name);
$rel = ltrim(str_replace('\\', '/', $rel), '/');
// Zip-Slip: alles mit Parent-Traversal oder absolutem Pfad wird verworfen.
if ($rel === '' || str_contains($rel, '../')) {
continue;
}
$target = FG_ASSETS_DIR . '/' . $rel;
if (str_ends_with($name, '/')) {
if (!is_dir($target)) {
@mkdir($target, 0775, true);
}
continue;
}
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
fg_fail("Kann Verzeichnis nicht anlegen: {$dir}");
continue;
}
$stream = $zip->getStream($name);
if ($stream === false) {
fg_fail("Konnte Eintrag nicht lesen: {$name}");
continue;
}
if (@file_put_contents($target, $stream) === false) {
fg_fail("Konnte Datei nicht schreiben: {$target}");
} else {
$written++;
}
fclose($stream);
}
$zip->close();
@unlink($tmp);
echo "✓ Assets entpackt ({$written} Dateien) → " . str_replace(ROOT_PATH . '/', '', FG_ASSETS_DIR) . "\n";
fg_log("Assets {$version} entpackt ({$written} Dateien)");
}
/** Prüfen, ob Patch und Assets zur vendorten Version passen. */
function fg_check(string $version, string $source): void
{
if (str_contains($source, FG_PATCH_MARKER)) {
echo "✓ TSV-PATCH in public/dateien/index.php vorhanden\n";
} else {
fg_fail('TSV-PATCH fehlt in public/dateien/index.php — die App würde ohne Login starten.');
}
$css = FG_ASSETS_DIR . "/files.photo.gallery@{$version}/css/files.css";
if (is_file($css)) {
echo "✓ Assets für Version {$version} vorhanden\n";
} else {
fg_fail("Assets für Version {$version} fehlen (erwartet: " . str_replace(ROOT_PATH . '/', '', $css) . ") — php bin/filesgallery-update.php --assets");
}
// Verirrte Assets anderer Versionen melden, damit sie nicht mitgeschleppt werden.
foreach (glob(FG_ASSETS_DIR . '/files.photo.gallery@*') ?: [] as $dir) {
if (basename($dir) !== "files.photo.gallery@{$version}") {
echo "· Alte Assets, können weg: " . str_replace(ROOT_PATH . '/', '', $dir) . "\n";
}
}
}
// --- Ablauf ---------------------------------------------------------------------------
if (!is_file(FG_APP)) {
fwrite(STDERR, '✗ ' . FG_APP . " fehlt.\n");
exit(1);
}
$source = (string) file_get_contents(FG_APP);
if ($mode === '' ) {
// Vollupdate: neue App-Datei holen, patchen, dann Assets zur neuen Version.
$fresh = fg_fetch(FG_APP_URL);
if ($fresh !== null) {
$newVersion = fg_version($fresh);
if ($newVersion === null) {
fg_fail('Version im Download nicht gefunden — Datei nicht übernommen.');
} else {
$patched = fg_patch($fresh);
if ($patched !== null) {
$old = fg_version($source) ?? '?';
if ($patched === $source) {
echo "· Version {$newVersion} ist schon aktuell und gepatcht\n";
} elseif (file_put_contents(FG_APP, $patched) === false) {
fg_fail('Konnte ' . FG_APP . ' nicht schreiben.');
} else {
echo "✓ index.php aktualisiert: {$old}{$newVersion} (gepatcht)\n";
fg_log("index.php {$old}{$newVersion}");
$source = $patched;
}
}
}
}
}
$version = fg_version($source);
if ($version === null) {
fwrite(STDERR, "✗ Version in public/dateien/index.php nicht lesbar.\n");
exit(1);
}
if ($mode !== '--check') {
fg_assets($version);
}
fg_check($version, $source);
if ($errors > 0) {
fwrite(STDERR, "\n{$errors} Problem(e) — nicht ausliefern, bis sie behoben sind.\n");
exit(1);
}
echo "\nFiles Gallery {$version}: alles an seinem Platz.\n";
exit(0);