Branding: Farben systemweit einstellbar + Bild-Upload statt nur URLs
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>
This commit is contained in:
parent
6da46f040a
commit
30d0ce3a23
13 changed files with 437 additions and 32 deletions
|
|
@ -116,6 +116,24 @@ class Ui
|
|||
. '</style>';
|
||||
}
|
||||
|
||||
/**
|
||||
* URL eines Bildes aus den Einstellungen.
|
||||
* Hochgeladene Dateien liegen relativ zur Projektwurzel (uploads/…),
|
||||
* externe Adressen bleiben unveraendert.
|
||||
*/
|
||||
public static function mediaUrl(string $value, string $base = ''): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('#^(https?:)?//#i', $value) || strncmp($value, 'data:', 5) === 0 || $value[0] === '/') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $base . $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompletter Standard-Kopf: Favicon, Schrift, Icons, Design-System,
|
||||
* Theme-Bootstrap und Branding.
|
||||
|
|
@ -124,7 +142,7 @@ class Ui
|
|||
{
|
||||
$out = [];
|
||||
|
||||
$favicon = $db ? trim((string)$db->getSetting('favicon_url', '')) : '';
|
||||
$favicon = $db ? self::mediaUrl((string)$db->getSetting('favicon_url', ''), $base) : '';
|
||||
if ($favicon !== '') {
|
||||
$out[] = '<link rel="icon" href="' . htmlspecialchars($favicon) . '">';
|
||||
}
|
||||
|
|
|
|||
133
includes/Upload.php
Normal file
133
includes/Upload.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue