Upload via GUI (396 Dateien)
This commit is contained in:
@@ -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"';
|
||||
|
||||
Reference in New Issue
Block a user