Unifi-Voucher-Tool/includes/Upload.php
Friederich Loheide 4159b92268
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Display-Seiten individuell gestalten
Jede Display-Seite bringt jetzt ihr eigenes Erscheinungsbild mit – der
Empfang sieht anders aus als der Tagungsraum nebenan:

- eigenes Logo (leer = Logo aus den Einstellungen)
- formatfüllendes Hintergrundbild mit einstellbarer Abdunklung (0–90 %),
  damit die Karte auf hellen Fotos lesbar bleibt
- eigene Akzentfarbe für den Knopf (leer = Farbe aus dem Design-Tab)
- Karte wahlweise hell oder dunkel; auf Fotos wirkt dunkel meist ruhiger

Logo und Hintergrund lassen sich hochladen oder als URL hinterlegen; beim
Löschen einer Display-Seite verschwinden die hochgeladenen Dateien mit.

Nebenbei aufgeräumt: das Bildfeld (Vorschau + Upload + URL + Entfernen)
lag als Funktion in admin/settings.php und wird jetzt von beiden Seiten
genutzt – Ui::imageField() für die Darstellung, Upload::resolveField()
für die Auswertung.

Sicherheit: die Akzentfarbe landet in einem style-Attribut, deshalb wird
sie sowohl beim Speichern als auch beim Ausgeben auf eine echte Hex-Farbe
geprüft; ein Test hält das fest.

Migration 0006, database.sql nachgezogen, 4 neue Tests (42 gesamt),
Screenshots ergänzt, Version 2.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:58:54 +00:00

175 lines
6.4 KiB
PHP
Raw Permalink 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;
}
/**
* Neuer Wert eines Bildfeldes aus dem Formular.
*
* Reihenfolge: hochgeladene Datei schlaegt alles, danach der
* Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor
* hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch
* von der Platte.
*
* @param string $name Feldname (erwartet <name>, <name>_file, <name>_remove)
* @param string $current bisher gespeicherter Wert
* @param string $kind 'image' oder 'favicon'
*/
public static function resolveField(string $name, string $current, string $kind = 'image'): string
{
$uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind);
if ($uploaded !== '') {
self::delete($current);
return $uploaded;
}
if (!empty($_POST[$name . '_remove'])) {
self::delete($current);
return '';
}
$value = trim((string)($_POST[$name] ?? ''));
if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) {
self::delete($current);
}
return $value;
}
/**
* 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;
}
}