- tests/UploadTest.php prüft den SVG-Filter (Skripte, Event-Handler, javascript:-Verweise) und Upload::isLocal gegen Pfad-Tricks - tests/UiTest.php prüft Versionsstempel, Media-Pfade und die Branding-Overrides inklusive Abweisung ungültiger Farbwerte - PHPStan analysiert jetzt auch includes/Ui.php und includes/Upload.php - CI vergleicht die Sprachdateien (gleiche Schlüsselmenge) und prüft, dass jeder im Code verwendete Schlüssel existiert Dabei aufgefallen und behoben: Upload.php rief __() direkt auf und wäre außerhalb einer Seite mit geladener I18n mit einem Fatal Error abgebrochen; jetzt gibt es einen Fallback auf die deutsche Meldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.3 KiB
PHP
142 lines
5.3 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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|