Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Jede Display-Seite bringt jetzt ihr eigenes Erscheinungsbild mit – der Empfang sieht anders aus als der Tagungsraum nebenan: - eigenes Logo (leer = Logo aus den Einstellungen) - formatfüllendes Hintergrundbild mit einstellbarer Abdunklung (0–90 %), damit die Karte auf hellen Fotos lesbar bleibt - eigene Akzentfarbe für den Knopf (leer = Farbe aus dem Design-Tab) - Karte wahlweise hell oder dunkel; auf Fotos wirkt dunkel meist ruhiger Logo und Hintergrund lassen sich hochladen oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden die hochgeladenen Dateien mit. Nebenbei aufgeräumt: das Bildfeld (Vorschau + Upload + URL + Entfernen) lag als Funktion in admin/settings.php und wird jetzt von beiden Seiten genutzt – Ui::imageField() für die Darstellung, Upload::resolveField() für die Auswertung. Sicherheit: die Akzentfarbe landet in einem style-Attribut, deshalb wird sie sowohl beim Speichern als auch beim Ausgeben auf eine echte Hex-Farbe geprüft; ein Test hält das fest. Migration 0006, database.sql nachgezogen, 4 neue Tests (42 gesamt), Screenshots ergänzt, Version 2.8.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
6.4 KiB
PHP
175 lines
6.4 KiB
PHP
<?php
|
||
/**
|
||
* Datei-Uploads fuer Branding-Bilder (Logo, Favicon, Hintergrund).
|
||
*
|
||
* Bewusst eng gefasst: nur Bilder, kleine Groesse, zufaelliger Dateiname,
|
||
* Ablage in uploads/ (dort ist die PHP-Ausfuehrung per .htaccess gesperrt).
|
||
* SVG-Dateien werden vor dem Speichern von aktiven Inhalten befreit.
|
||
*/
|
||
class Upload
|
||
{
|
||
public const MAX_BYTES = 3145728; // 3 MB
|
||
|
||
/** Erlaubte Endungen je Einsatzzweck. */
|
||
private const ALLOWED = [
|
||
'image' => ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'],
|
||
'favicon' => ['ico', 'png', 'svg'],
|
||
];
|
||
|
||
/**
|
||
* Uebersetzte Meldung – faellt auf Deutsch zurueck, wenn die Klasse
|
||
* ausserhalb einer Seite mit geladener I18n verwendet wird.
|
||
*/
|
||
private static function msg(string $key, string $fallback): string
|
||
{
|
||
return function_exists('__') ? __($key) : $fallback;
|
||
}
|
||
|
||
private static function dir(): string
|
||
{
|
||
return dirname(__DIR__) . '/uploads';
|
||
}
|
||
|
||
/** Legt das Upload-Verzeichnis inkl. Schutzdatei an. */
|
||
public static function ensureDir(): bool
|
||
{
|
||
$dir = self::dir();
|
||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||
return false;
|
||
}
|
||
|
||
$htaccess = $dir . '/.htaccess';
|
||
if (!file_exists($htaccess)) {
|
||
@file_put_contents($htaccess, "php_flag engine off\nOptions -ExecCGI\n<FilesMatch \"\\.(php|phtml|phar)$\">\n Require all denied\n</FilesMatch>\n");
|
||
}
|
||
|
||
return is_writable($dir);
|
||
}
|
||
|
||
/** Ist der Pfad eine von uns gespeicherte Datei? */
|
||
public static function isLocal(string $path): bool
|
||
{
|
||
return $path !== '' && strncmp($path, 'uploads/', 8) === 0 && strpos($path, '..') === false;
|
||
}
|
||
|
||
/** Loescht eine zuvor hochgeladene Datei (externe URLs bleiben unberuehrt). */
|
||
public static function delete(string $path): void
|
||
{
|
||
if (!self::isLocal($path)) {
|
||
return;
|
||
}
|
||
$file = dirname(__DIR__) . '/' . $path;
|
||
if (is_file($file)) {
|
||
@unlink($file);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Nimmt einen Upload entgegen und gibt den relativen Pfad zurueck.
|
||
*
|
||
* @param array $file Eintrag aus $_FILES
|
||
* @param string $kind 'image' oder 'favicon'
|
||
* @throws RuntimeException bei ungueltigen Dateien
|
||
*/
|
||
public static function store(array $file, string $kind = 'image'): string
|
||
{
|
||
if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) {
|
||
return '';
|
||
}
|
||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||
throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.'));
|
||
}
|
||
if (!is_uploaded_file($file['tmp_name'])) {
|
||
throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.'));
|
||
}
|
||
if ($file['size'] > self::MAX_BYTES) {
|
||
throw new RuntimeException(self::msg('upload_error_size', 'Die Datei ist zu groß (maximal 3 MB).'));
|
||
}
|
||
|
||
$allowed = self::ALLOWED[$kind] ?? self::ALLOWED['image'];
|
||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||
if ($ext === 'jpeg') {
|
||
$ext = 'jpg';
|
||
}
|
||
if (!in_array($ext, $allowed, true)) {
|
||
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
||
}
|
||
|
||
$data = (string)file_get_contents($file['tmp_name']);
|
||
|
||
if ($ext === 'svg') {
|
||
$data = self::sanitizeSvg($data);
|
||
} elseif ($ext !== 'ico') {
|
||
// Raster: muss als Bild lesbar sein
|
||
if (@getimagesize($file['tmp_name']) === false) {
|
||
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
||
}
|
||
}
|
||
|
||
if (!self::ensureDir()) {
|
||
throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.'));
|
||
}
|
||
|
||
$name = bin2hex(random_bytes(8)) . '.' . $ext;
|
||
$dest = self::dir() . '/' . $name;
|
||
if (file_put_contents($dest, $data) === false) {
|
||
throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.'));
|
||
}
|
||
@chmod($dest, 0644);
|
||
|
||
return 'uploads/' . $name;
|
||
}
|
||
|
||
/**
|
||
* Neuer Wert eines Bildfeldes aus dem Formular.
|
||
*
|
||
* Reihenfolge: hochgeladene Datei schlaegt alles, danach der
|
||
* Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor
|
||
* hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch
|
||
* von der Platte.
|
||
*
|
||
* @param string $name Feldname (erwartet <name>, <name>_file, <name>_remove)
|
||
* @param string $current bisher gespeicherter Wert
|
||
* @param string $kind 'image' oder 'favicon'
|
||
*/
|
||
public static function resolveField(string $name, string $current, string $kind = 'image'): string
|
||
{
|
||
$uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind);
|
||
if ($uploaded !== '') {
|
||
self::delete($current);
|
||
return $uploaded;
|
||
}
|
||
|
||
if (!empty($_POST[$name . '_remove'])) {
|
||
self::delete($current);
|
||
return '';
|
||
}
|
||
|
||
$value = trim((string)($_POST[$name] ?? ''));
|
||
if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) {
|
||
self::delete($current);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
/**
|
||
* Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler,
|
||
* externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke.
|
||
*/
|
||
private static function sanitizeSvg(string $svg): string
|
||
{
|
||
if (stripos($svg, '<svg') === false) {
|
||
throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.'));
|
||
}
|
||
|
||
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
|
||
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*/?>#i', '', $svg);
|
||
$svg = preg_replace('#\son[a-z]+\s*=\s*"[^"]*"#i', '', $svg);
|
||
$svg = preg_replace("#\son[a-z]+\s*=\s*'[^']*'#i", '', $svg);
|
||
$svg = preg_replace('#(href|xlink:href)\s*=\s*([\'"])\s*(javascript|data):[^\'"]*\2#i', '', $svg);
|
||
$svg = preg_replace('#<!ENTITY[^>]*>#i', '', $svg);
|
||
|
||
return (string)$svg;
|
||
}
|
||
}
|