['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'], 'favicon' => ['ico', 'png', 'svg'], ]; 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\n Require all denied\n\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(__('upload_error_generic')); } if (!is_uploaded_file($file['tmp_name'])) { throw new RuntimeException(__('upload_error_generic')); } if ($file['size'] > self::MAX_BYTES) { throw new RuntimeException(__('upload_error_size')); } $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(__('upload_error_type')); } $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(__('upload_error_type')); } } if (!self::ensureDir()) { throw new RuntimeException(__('upload_error_dir')); } $name = bin2hex(random_bytes(8)) . '.' . $ext; $dest = self::dir() . '/' . $name; if (file_put_contents($dest, $data) === false) { throw new RuntimeException(__('upload_error_dir')); } @chmod($dest, 0644); return 'uploads/' . $name; } /** * 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, ']*>.*?<\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('#]*>#i', '', $svg); return (string)$svg; } }