Upload via GUI (396 Dateien)

This commit is contained in:
2026-11-04 07:55:19 +00:00
parent c55cc54ba2
commit 27b3e4dbaf
19 changed files with 1559 additions and 9 deletions
+92 -5
View File
@@ -784,6 +784,7 @@ function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void
$deletedByStaffId,
db_now(),
]);
if (function_exists('ticket_links_purge')) ticket_links_purge($pdo, (int) $ticket['id']);
$pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]);
}
@@ -1332,6 +1333,16 @@ function get_allowed_extensions(): array
return config()['uploads']['allowed_extensions'];
}
/** Zusätzliche Dateiendungen, die nur für Anhänge eingehender E-Mails erlaubt sind (Einstellungen → Uploads); Standard: gängige Büro- und Bildformate. */
const MAIL_ATTACHMENT_EXTRA_DEFAULT = 'doc, xls, ppt, pptx, odt, ods, odp, csv, rtf, msg, heic, webp';
/** @return string[] */
function mail_attachment_extra_extensions(): array
{
$list = array_filter(array_map('trim', explode(',', strtolower(get_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT)))));
return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $list)));
}
/** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */
function get_max_upload_size(): int
{
@@ -1342,10 +1353,16 @@ function get_max_upload_size(): int
return config()['uploads']['max_size_bytes'];
}
function handle_ticket_upload(array $file): array
/**
* @param bool $dryRun nur prüfen (Name, Endung, Größe, Inhalt), nichts speichern
* @param array $extraAllowed zusätzlich erlaubte Endungen (nur für Dateien, die DeskHub selbst aus einer Mail übernimmt: Bilder, .eml)
* @param bool $httpUpload true bei einem Formular-Upload (move_uploaded_file); false für eine Datei, die DeskHub selbst angelegt hat
* (zum Beispiel ein Anhang aus einer abgerufenen E-Mail). Die Prüfungen sind in beiden Fällen dieselben.
*/
function handle_ticket_upload(array $file, bool $httpUpload = true, array $extraAllowed = [], bool $dryRun = false): array
{
$cfg = config()['uploads'];
$allowed = get_allowed_extensions();
$allowed = array_values(array_unique(array_merge(get_allowed_extensions(), $extraAllowed)));
$maxSize = get_max_upload_size();
if ($file['error'] !== UPLOAD_ERR_OK) {
@@ -1392,6 +1409,7 @@ function handle_ticket_upload(array $file): array
return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.'];
}
}
if ($dryRun) return ['original_name' => $file['name'], 'stored_name' => '', 'filesize' => $file['size']];
// Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz
$storedName = bin2hex(random_bytes(16)) . '.' . $ext;
$uploadsDir = realpath(rtrim($cfg['path'], '/'));
@@ -1399,12 +1417,55 @@ function handle_ticket_upload(array $file): array
if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) {
return ['error' => 'Ungültiger Upload-Pfad.'];
}
if (!move_uploaded_file($file['tmp_name'], $dest)) {
$moved = $httpUpload ? move_uploaded_file($file['tmp_name'], $dest) : @copy($file['tmp_name'], $dest);
if (!$moved) {
return ['error' => 'Die Datei konnte nicht gespeichert werden.'];
}
return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']];
}
/**
* Speichert einen Anhang aus einer E-Mail (Rohdaten) als Anhang einer Nachricht. Es gelten dieselben Regeln wie beim Upload
* (erlaubte Endungen, Größe, gesperrte Typen).
*
* @return array{ok:bool,error?:string,name:string}
*/
function store_ticket_attachment_data(PDO $pdo, int $threadId, string $name, string $data, array $opts = []): array
{
$name = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', '', basename(str_replace(chr(92), '/', $name))));
if ($name === '') $name = 'Anhang';
$name = mb_substr($name, 0, 255);
if ($data === '') return ['ok' => false, 'error' => 'Der Anhang ist leer.', 'name' => $name];
$tmp = tempnam(sys_get_temp_dir(), 'dhmail');
if ($tmp === false || file_put_contents($tmp, $data) === false) return ['ok' => false, 'error' => 'Der Anhang konnte nicht zwischengespeichert werden.', 'name' => $name];
$res = handle_ticket_upload(['name' => $name, 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, (array) ($opts['extra_allowed'] ?? []));
@unlink($tmp);
if (isset($res['error'])) return ['ok' => false, 'error' => (string) $res['error'], 'name' => $name];
if (!empty($opts['content_id']) && ticket_mail_html_ready($pdo)) {
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at, content_id, is_inline) VALUES (?,?,?,?,?,?,?)')
->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now(), mb_substr((string) $opts['content_id'], 0, 255), empty($opts['inline']) ? 0 : 1]);
} else {
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')
->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now()]);
}
return ['ok' => true, 'name' => $name];
}
/** Gibt es die Spalten für die HTML-Fassung von Mails (Datenbank-Update php_0070 eingespielt)? Bis dahin verhält sich alles wie bisher. */
function ticket_mail_html_ready(PDO $pdo): bool
{
static $cache = [];
$key = spl_object_id($pdo);
if (isset($cache[$key])) return $cache[$key];
try {
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(ticket_threads)' : 'SHOW COLUMNS FROM ticket_threads')->fetchAll();
foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'message_html') return $cache[$key] = true;
} catch (Throwable $e) {
// Tabelle fehlt: nichts zu tun
}
return $cache[$key] = false;
}
/** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input).
* Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */
function handle_ticket_uploads_multi(array $filesInput): array
@@ -1518,8 +1579,26 @@ function build_report_table(PDO $pdo, string $dimColumn, string $from, string $t
* der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities),
* weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert.
*/
function sanitize_staff_html(string $html): string
/**
* Ob eine Nachricht HTML enthält (echte HTML-Tags wie <p>, <div>, <br>, <b> …) und deshalb als HTML dargestellt werden muss.
* Eine Adresse in spitzen Klammern ("Olaf <olaf@gmx.de>") ist kein HTML: Wird sie dafür gehalten, verschwinden die Zeilenumbrüche
* des Textes (HTML kennt keine).
*/
function message_is_html(string $text): bool
{
return (bool) preg_match('/<\/?(?:a|abbr|b|big|blockquote|body|br|caption|center|code|col|colgroup|div|em|figcaption|figure|font|h[1-6]|head|hr|html|i|img|li|meta|ol|p|pre|s|small|span|strike|strong|style|sub|sup|table|tbody|td|tfoot|th|thead|title|tr|u|ul|o:p)(?=[\s\/>])[^>]*>/i', $text);
}
/**
* @param array{remote_images?:string,cid_map?:array<string,string>} $opts
* remote_images: 'placeholder' = Bilder von fremden Adressen (https) werden nicht geladen, sondern durch einen Platzhalter ersetzt
* (Datenschutz: ein Bild von außen verrät dem Absender, wann und von wo die Mail geöffnet wurde).
* cid_map: Content-ID (klein, ohne <>) => Adresse des Bildes, das aus der Mail übernommen wurde (cid:-Bilder im Text)
*/
function sanitize_staff_html(string $html, array $opts = []): string
{
$placeholderRemote = ($opts['remote_images'] ?? '') === 'placeholder';
$cidMap = (array) ($opts['cid_map'] ?? []);
if (trim($html) === '') return '';
// Gefährliche Elemente komplett inkl. Inhalt entfernen
@@ -1603,7 +1682,15 @@ function sanitize_staff_html(string $html): string
$src = $attr($raw, 'src');
if ($src !== null) {
// Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen
if (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);|cid:)#i', $src)) {
$blank = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=';
if (preg_match('#^cid:#i', $src)) {
$cid = strtolower(trim(substr($src, 4), " \t\r\n<>"));
if (isset($cidMap[$cid])) $out .= ' src="' . $e((string) $cidMap[$cid]) . '"';
elseif ($placeholderRemote) $out .= ' src="' . $blank . '" title="Bild aus der E-Mail nicht verfügbar"';
else $out .= ' src="' . $e($src) . '"';
} elseif ($placeholderRemote && preg_match('#^https://#i', $src)) {
$out .= ' src="' . $blank . '" title="Externes Bild aus Datenschutzgründen nicht geladen" data-blocked-remote="1"';
} elseif (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);)#i', $src)) {
$out .= ' src="' . $e($src) . '"';
} else {
$out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"';