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:
@@ -1,5 +1,5 @@
|
||||
# Dateiablage /dateien (Files Gallery). Die Regeln aus public/.htaccess gelten hier vererbt
|
||||
# weiter — nur diese drei Punkte müssen abweichen.
|
||||
# weiter — nur diese vier Punkte müssen abweichen.
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
# Files Gallery rendert Inline-<script>-Blöcke mit PHP-interpolierten Werten
|
||||
@@ -14,11 +14,35 @@
|
||||
Header always set X-Robots-Tag "noindex, nofollow"
|
||||
</IfModule>
|
||||
|
||||
# Die Konfiguration gibt bei direktem Aufruf nichts aus (sie liefert nur ein Array zurück),
|
||||
# aber ausgeliefert werden muss sie trotzdem nie.
|
||||
<Files "_filesconfig.php">
|
||||
# --- Hochgeladene SVG entschärfen ---
|
||||
# SVG ist der einzige erlaubte Upload-Typ, der Code tragen kann: eine SVG darf <script>
|
||||
# enthalten, und die App liefert Dateien mit "content-disposition: inline" aus
|
||||
# (index.php:781). Ohne das Folgende könnte eine hochgeladene Datei Skript in unserer
|
||||
# eigenen Origin ausführen — und die CSP oben erlaubt für diesen Pfad ja 'unsafe-inline'.
|
||||
#
|
||||
# `sandbox` (ohne allow-scripts, ohne allow-same-origin) macht die Antwort zu einem
|
||||
# Dokument ohne Skriptausführung und ohne Zugriff auf unsere Origin. Für Bilder in <img>
|
||||
# ist das ohne Wirkung — dort wird ohnehin kein Skript ausgeführt —, die Galerie zeigt
|
||||
# Logos also normal an. Absichtlich nur für SVG, damit die PDF-Vorschau des Browsers
|
||||
# (die Skript braucht) weiter funktioniert.
|
||||
#
|
||||
# Greift ab Apache 2.4 (SetEnvIfExpr). Dieselbe Annahme wie beim "Require all denied"
|
||||
# der übrigen Verzeichnisse. Live prüfen mit:
|
||||
# curl -sI "https://www.tsv08kulmbach.de/dateien/?action=file&file=<pfad>.svg" | grep -i security
|
||||
<IfModule mod_setenvif.c>
|
||||
SetEnvIfExpr "%{QUERY_STRING} =~ m#\.[Ss][Vv][Gg]#" fg_svg_response
|
||||
</IfModule>
|
||||
<IfModule mod_headers.c>
|
||||
Header always set Content-Security-Policy "sandbox" env=fg_svg_response
|
||||
</IfModule>
|
||||
|
||||
# Keine Konfigurationsdatei ausliefern. _filesconfig.php gibt bei direktem Aufruf zwar
|
||||
# nichts aus (sie liefert nur ein Array zurück), .user.ini und .htaccess sind aber
|
||||
# lesbarer Text. Ob der Hoster Punkt-Dateien von sich aus sperrt, ist nicht garantiert —
|
||||
# im Test mit Apache 2.4 lieferte er beide mit HTTP 200 aus.
|
||||
<FilesMatch "^(\.|_filesconfig\.php$)">
|
||||
Require all denied
|
||||
</Files>
|
||||
</FilesMatch>
|
||||
|
||||
# Die App setzt ihre Cache-Header selbst (U::header(), index.php:982-1016). Die
|
||||
# ExpiresByType-Regeln aus public/.htaccess würden durch PHP gelieferte Bilder ein Jahr
|
||||
|
||||
23
public/dateien/.user.ini
Normal file
23
public/dateien/.user.ini
Normal file
@@ -0,0 +1,23 @@
|
||||
; PHP-Grenzen für die Dateiablage — nur für dieses Verzeichnis.
|
||||
;
|
||||
; Warum überhaupt: unsere Config erlaubt 100 MB pro Datei (upload_max_filesize in
|
||||
; _filesconfig.php), aber PHP schneidet vorher ab. Auf Shared Hosting steht
|
||||
; upload_max_filesize oft bei 2–8 MB, und ein Handy-Video ist damit chancenlos.
|
||||
; Die Grenze, die zuerst greift, gewinnt — beide müssen also passen.
|
||||
;
|
||||
; .user.ini statt .htaccess, weil All-Inkl PHP als FastCGI/FPM fährt: dort ignoriert
|
||||
; Apache php_value-Zeilen (mit mod_php würden sie sogar einen 500er auslösen, wenn das
|
||||
; Modul fehlt). PHP liest .user.ini pro Verzeichnis ein, gecacht für 5 Minuten
|
||||
; (user_ini.cache_ttl) — Änderungen greifen also mit kurzer Verzögerung.
|
||||
;
|
||||
; Greift NICHT beim lokalen `php -S` (der CLI-Server liest keine .user.ini):
|
||||
; dafür stehen die -d-Schalter im Dev-Kommando in CLAUDE.md.
|
||||
;
|
||||
; Falls der Hoster das hier sperrt: dieselben Werte im KAS unter den PHP-Einstellungen
|
||||
; setzen. `php bin/preflight.php` zeigt an, was tatsächlich gilt.
|
||||
|
||||
upload_max_filesize = 250M
|
||||
; muss über upload_max_filesize liegen (Formular-Overhead kommt obendrauf)
|
||||
post_max_size = 260M
|
||||
; ein großes Video über Mobilfunk darf dauern
|
||||
max_execution_time = 300
|
||||
@@ -18,8 +18,34 @@
|
||||
|
||||
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
|
||||
@@ -31,7 +57,12 @@ if (session_status() === PHP_SESSION_NONE) {
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 28800, // 8 h — ein Upload-Nachmittag ohne erneutes Anmelden
|
||||
'path' => '/dateien/', // Cookie bleibt auf die Ablage beschränkt
|
||||
// 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)
|
||||
@@ -57,6 +88,51 @@ if ($isLoginAttempt && !rate_limit_ok('dateien-login:' . client_ip(), 10, 600))
|
||||
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
|
||||
@@ -81,16 +157,51 @@ return [
|
||||
|
||||
'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.
|
||||
*/
|
||||
'upload_allowed_file_types' => '.jpg, .jpeg, .png, .webp, .gif, .heic, .heif, .mp4, .mov, .pdf',
|
||||
'upload_max_filesize' => 104857600, // Bytes (100 MB) — Handy-Video; PHP-ini bleibt die harte Grenze
|
||||
/*
|
||||
* .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
|
||||
|
||||
/*
|
||||
|
||||
@@ -123,6 +123,9 @@ class Config {
|
||||
// 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'; // optional config file in current dir, useful when overriding shared configs
|
||||
public static $localconfig = []; // config array from localconfigpath
|
||||
|
||||
Reference in New Issue
Block a user