Unifi-Voucher-Tool/includes/Upload.php
Friederich Loheide 0716311ff6 Tests für Upload und Ui, strengere CI-Prüfungen
- 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>
2026-09-23 06:52:22 +00:00

142 lines
5.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}