Design-Tab (Administration → Einstellungen → Design): - Akzentfarbe für Hell- und Dark-Mode, Markenverlauf und Eckenradius - abgeleitete Töne (Hover, weiche Flächen, Fokusring) werden per color-mix aus der Grundfarbe berechnet – eine Farbe genügt - Live-Vorschau mit Button, Badge, Chip, Logo-Kachel und Link - Ausgabe als schlanker :root-Override über Ui::brandingStyle(), greift auf allen Seiten inklusive Login und Installer Uploads (includes/Upload.php): - Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich jetzt hochladen; das URL-Feld bleibt als Alternative bestehen - Whitelist nach Endung, 3-MB-Grenze, getimagesize-Prüfung für Raster, SVGs werden von Skripten, Event-Handlern und externen Verweisen befreit - Zufällige Dateinamen in uploads/, dort sperrt eine .htaccess die Ausführung von PHP; beim Ersetzen wird die alte Datei gelöscht Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
4.6 KiB
PHP
133 lines
4.6 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'],
|
|
];
|
|
|
|
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(__('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, '<svg') === false) {
|
|
throw new RuntimeException(__('upload_error_type'));
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|