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
218 lines
11 KiB
PHP
218 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Konfiguration und Härtung der Dateiablage /dateien (Files Gallery).
|
|
*
|
|
* DIE EINZIGE STELLE, an der die Dateiablage konfiguriert wird. index.php bleibt
|
|
* unangetastet (bis auf den TSV-PATCH in Zeile ~122), und
|
|
* storage/dateien/system/config/config.php wird von der App selbst generiert und bei
|
|
* jedem Update überschrieben (index.php:248) — dort niemals etwas eintragen.
|
|
* Diese Datei lädt index.php als erstes (index.php:142) und sie gewinnt gegen beide
|
|
* anderen Ebenen (index.php:239), überlebt also jedes Update.
|
|
*
|
|
* Sie läuft VOR session_start() (index.php:295) und VOR dem Login (index.php:159) —
|
|
* deshalb dürfen Session-Härtung und Login-Bremse hier stehen; später wäre es zu spät.
|
|
*
|
|
* Secrets (Lizenzschlüssel, Passwort-Hashes) stehen in config/config.php, nicht hier.
|
|
*/
|
|
|
|
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
|
|
|
|
/*
|
|
* Die App ist gegen PHP 7 geschrieben und wirft unter 8.5 Deprecation-Notices
|
|
* (z. B. imagedestroy()). Mit display_errors — also lokal bei env: development — landen
|
|
* die MITTEN im Bild-Stream und machen jedes Thumbnail kaputt. Deshalb hier immer aus:
|
|
* Ausgaben dieser App sind zum großen Teil Binärdaten, für die es keinen sinnvollen Ort
|
|
* für Fehlertexte gibt. Echte Fehler stehen weiter in storage/logs/php-errors.log.
|
|
*/
|
|
ini_set('display_errors', '0');
|
|
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
|
|
|
$base = ROOT_PATH . '/storage/dateien';
|
|
|
|
/*
|
|
* Das Vereins-Konto MUSS das globale username/password der App sein, nicht bloß ein
|
|
* Konto unter system/users/. Sonst gilt index.php:328-337: ohne globales Passwort sind
|
|
* die Konten aus dem users-Verzeichnis nur ZUSÄTZLICHE Logins, und der Grundzustand
|
|
* bleibt eine offen lesbare Ablage. Genau das ist beim Bauen einmal passiert.
|
|
* Der Admin liegt daneben in system/users/admin/config.php.
|
|
*
|
|
* Fail closed: fehlt der Hash in config/config.php, tritt ein Zufallswert an seine
|
|
* Stelle. Dann verlangt die App weiter ein Passwort, das niemand kennt — statt die
|
|
* Ablage aufzumachen, weil das Passwort leer ist.
|
|
*/
|
|
$vereinPassword = (string) config('dateien.users.verein.password_hash', '');
|
|
if ($vereinPassword === '') {
|
|
$vereinPassword = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
/*
|
|
* Session-Cookie härten. Files Gallery lässt die PHP-Defaults stehen (index.php:291
|
|
* und 298 sind auskommentiert): Logout beim Schließen des Browsers und kein
|
|
* SameSite/Secure-Flag. Für einen Upload vom Handy ist das zu kurz gedacht.
|
|
*/
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
$https = ($_SERVER['HTTPS'] ?? '') === 'on'
|
|
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
|
|
|
session_set_cookie_params([
|
|
'lifetime' => 28800, // 8 h — ein Upload-Nachmittag ohne erneutes Anmelden
|
|
// Ohne Slash am Ende — Absicht: ein Cookie mit `path=/dateien/` wird bei einem
|
|
// Aufruf von `/dateien` (ohne Slash) NICHT mitgesendet (RFC 6265 §5.1.4). Dann
|
|
// fehlt das CSRF-Token in der Session und jeder Login scheitert als „ungültig".
|
|
// Apache leitet `/dateien` zwar auf `/dateien/` um, der Dev-Server aber nicht.
|
|
// `/dateien` deckt beide Formen ab und greift nicht auf `/datenschutz` über.
|
|
'path' => '/dateien',
|
|
'secure' => $https, // lokal (http) sonst kein Cookie und damit kein Login
|
|
'httponly' => true,
|
|
'samesite' => 'Lax', // trägt zusätzlich das fehlende CSRF-Handling der App (index.php:301)
|
|
]);
|
|
ini_set('session.gc_maxlifetime', '28800');
|
|
}
|
|
|
|
/*
|
|
* Brute-Force-Bremse. Files Gallery bringt keine mit — im gesamten index.php gibt es
|
|
* kein Rate-Limit, Anmeldeversuche sind unbegrenzt. Zähler und Log sind dieselben wie
|
|
* bei den Formularen (storage/ratelimit/, storage/logs/spam.log).
|
|
* Der Login-POST kommt aus der App per fetch() als JSON-Body (index.php:384), aus dem
|
|
* Login-Formular als normales POST — daher beide Wege prüfen.
|
|
*/
|
|
$isLoginAttempt = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'
|
|
&& (($_GET['action'] ?? '') === 'login' || isset($_POST['fusername']));
|
|
|
|
if ($isLoginAttempt && !rate_limit_ok('dateien-login:' . client_ip(), 10, 600)) {
|
|
log_spam('dateien', 'login-ratelimit');
|
|
http_response_code(429);
|
|
header('Retry-After: 600');
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
exit("Zu viele Anmeldeversuche. Bitte in 10 Minuten noch einmal probieren.\n");
|
|
}
|
|
|
|
/*
|
|
* SVG beim Upload auf ausführbare Bestandteile prüfen — die einzige erlaubte Endung, die
|
|
* Code tragen kann. Die CSP `sandbox` in public/dateien/.htaccess entschärft das im
|
|
* Browser, ist aber die zweite Reihe: hier landet die Datei gar nicht erst in der Ablage.
|
|
* Ein Vereins- oder Sponsorenlogo braucht kein <script>, kein onload und keine externen
|
|
* Verweise; Vektorgrafik bleibt Vektorgrafik.
|
|
*
|
|
* Muss hier stehen, weil diese Datei als erstes geladen wird: $_FILES ist von PHP schon
|
|
* gefüllt, die App hat die Datei aber noch nicht verschoben (index.php:4116).
|
|
*/
|
|
$upload = $_FILES['file'] ?? null;
|
|
if (($_GET['action'] ?? '') === 'upload'
|
|
&& is_array($upload)
|
|
&& is_string($upload['name'] ?? null)
|
|
&& is_string($upload['tmp_name'] ?? null)
|
|
&& preg_match('/\.svgz?$/i', $upload['name']) === 1
|
|
&& is_uploaded_file($upload['tmp_name'])
|
|
) {
|
|
$reject = static function (string $message): never {
|
|
log_spam('dateien', 'svg-abgewiesen');
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
};
|
|
|
|
// Eine Vektorgrafik dieser Größe ist keine: nicht einlesen, sondern abweisen
|
|
// (sonst müsste der Scan Hunderte MB in den Speicher holen).
|
|
if ((int) ($upload['size'] ?? 0) > 4194304) {
|
|
$reject('Diese SVG ist größer als 4 MB und wurde abgewiesen. Bitte als PNG oder JPG hochladen.');
|
|
}
|
|
|
|
$svg = (string) file_get_contents($upload['tmp_name']);
|
|
|
|
// Skript und Nachladen von außen. Der href/src-Teil trifft auch <image> und <use> und
|
|
// hält damit Regel 1 ein: eine SVG darf keinen fremden Host kontaktieren. Interne
|
|
// Verweise (href="#gradient") und eingebettete Daten (href="data:…") bleiben erlaubt,
|
|
// ebenso die xmlns-Deklarationen, die naturgemäß http-URLs enthalten.
|
|
$gefaehrlich = '/<script|javascript:|\son[a-z]+\s*=|<foreignObject|<!ENTITY'
|
|
. '|(?:xlink:)?(?:href|src)\s*=\s*["\']?\s*(?:https?:|\/\/)/i';
|
|
|
|
if (preg_match($gefaehrlich, $svg) === 1) {
|
|
$reject('Diese SVG enthält Skript oder verweist auf fremde Server und wurde abgewiesen. Bitte ohne Interaktivität und ohne verlinkte Bilder exportieren — oder als PNG hochladen.');
|
|
}
|
|
}
|
|
|
|
return [
|
|
/*
|
|
* Die Dateien der Nutzer liegen AUSSERHALB des Docroots. Damit sind sie per URL nicht
|
|
* erreichbar, und die App liefert sie selbst durch PHP aus (index.php:1607) — eine
|
|
* hochgeladene .php kann so prinzipiell nicht ausgeführt werden. Das ist die wichtigste
|
|
* Schutzschicht, weil die App per Default JEDE Endung zum Upload zulässt.
|
|
*/
|
|
'root' => $base . '/uploads',
|
|
'storage_path' => $base . '/system', // Cache, Nutzer, generierte Config — ebenfalls außerhalb
|
|
'root_lock' => $base, // kein Per-User-root kann hier ausbrechen (index.php:1146)
|
|
|
|
/*
|
|
* Auslieferung immer durch PHP erzwingen. Nötig für den Fall, dass der Hoster das
|
|
* Repo-Root statt public/ serviert: dann läge storage/ innerhalb des DOCUMENT_ROOT,
|
|
* die App würde direkte URLs bauen — und die Root-.htaccess blockt /storage/ mit 403.
|
|
*/
|
|
'load_files_proxy_php' => true,
|
|
|
|
// Assets self-hosted (harte Regel 1: keine CDNs). Bestand prüfen/erneuern:
|
|
// php bin/filesgallery-update.php --check
|
|
'assets' => '/assets/filesgallery',
|
|
|
|
'license_key' => (string) config('dateien.license_key', ''),
|
|
|
|
/*
|
|
* Zugang „verein" — das gemeinsame Konto zum Hochladen (Passwort im Verein
|
|
* weitergeben). Weil es das globale Konto ist, sind die folgenden Rechte gleichzeitig
|
|
* die Grundrechte der Ablage; der Admin hebt sie in system/users/admin/config.php an.
|
|
*
|
|
* Bewusst KEIN Löschen, Umbenennen oder Verschieben: an einem gemeinsamen Konto darf
|
|
* ein Fehlklick nicht die Fotos anderer treffen. Aufräumen macht der Admin.
|
|
*/
|
|
'username' => 'verein',
|
|
'password' => $vereinPassword,
|
|
'allow_upload' => true,
|
|
'allow_new_folder' => true,
|
|
'allow_download' => true,
|
|
|
|
// Deutsche Oberfläche, Browsersprache darf weiterhin gewinnen.
|
|
'lang_default' => 'de',
|
|
|
|
// Punkt-Dateien ausblenden (Regex, nicht fnmatch): .gitkeep hält das Upload-Verzeichnis
|
|
// im Repo, hat in der Ablage aber nichts zu suchen — ebenso .DS_Store vom Mac.
|
|
'files_exclude' => '/^\./',
|
|
|
|
/*
|
|
* Upload-Filter. Default ist ein LEERER String und bedeutet "alles erlaubt" — es gibt
|
|
* keine Blacklist (index.php:4078-4092). Die Liste ist also nicht Kosmetik, sondern
|
|
* die serverseitige Grenze. .heic/.heif, weil iPhones so fotografieren.
|
|
*/
|
|
/*
|
|
* .svg ist bewusst dabei (Sponsoren- und Vereinslogos kommen als Vektor), aber es
|
|
* ist der einzige Eintrag in dieser Liste, der ausführbaren Code enthalten kann:
|
|
* eine SVG darf <script> tragen, und die App liefert Dateien inline aus
|
|
* (index.php:781). Dagegen setzt public/dateien/.htaccess für SVG-Antworten
|
|
* `Content-Security-Policy: sandbox` — die Datei wird dann als Dokument ohne
|
|
* Skripte und ohne Origin-Zugehörigkeit geladen. Wer die Zeile hier ändert, muss
|
|
* die dort mitziehen.
|
|
*/
|
|
'upload_allowed_file_types' => '.jpg, .jpeg, .png, .webp, .gif, .heic, .heif, .svg, .mp4, .mov, .m4v, .webm, .pdf',
|
|
|
|
/*
|
|
* 250 MB pro Datei. Das ist auf Videoclips gerechnet: eine Minute Full-HD vom Handy
|
|
* sind rund 130 MB, 4K etwa das Dreifache — mit 100 MB wäre schon ein Clip von
|
|
* 45 Sekunden abgewiesen worden. Die PHP-Grenzen müssen mitziehen, sonst schneidet
|
|
* PHP vorher ab: public/dateien/.user.ini (Server) bzw. die -d-Schalter im
|
|
* Dev-Kommando. Es gilt immer die kleinere der beiden Grenzen.
|
|
*/
|
|
'upload_max_filesize' => 262144000, // Bytes = 250 MB
|
|
'upload_exists' => 'increment', // zwei "IMG_1234.jpg" überschreiben sich nie
|
|
|
|
/*
|
|
* Alles abschalten, was der Verein nicht braucht und was die Angriffsfläche vergrößert.
|
|
* Rechte pro Konto stehen in storage/dateien/system/users/<name>/config.php.
|
|
*/
|
|
'allow_settings' => false, // das Settings-UI schreibt und includet geliefertes PHP (index.php:1019-1062)
|
|
'allow_check_updates' => false, // würde index.php ungeprüft von jsDelivr überschreiben (index.php:3956)
|
|
'allow_tests' => false, // Default ist TRUE und leakt Server-Infos + Config (nur admin darf es)
|
|
'allow_tasks' => false,
|
|
'allow_symlinks' => false, // kein Ausbruch aus dem Upload-Verzeichnis über Symlinks
|
|
'index_cache' => false, // würde eine statische index.html in public/dateien/ ablegen
|
|
];
|