From 51656d65781f4c91c9ceb848585a8316b49c819d Mon Sep 17 00:00:00 2001 From: M_Viper Date: Mon, 19 Oct 2026 18:06:00 +0000 Subject: [PATCH] Upload via GUI (267 Dateien) --- DeskHub-Webseite/staff/custom_fields.php | 38 ++- DeskHub-Webseite/staff/department_form.php | 289 +++++++++++++++++++++ DeskHub-Webseite/staff/departments.php | 2 + DeskHub-Webseite/staff/forgot_password.php | 5 +- DeskHub-Webseite/staff/help_topics.php | 2 +- DeskHub-Webseite/staff/import_osticket.php | 64 ++++- DeskHub-Webseite/staff/login.php | 16 +- DeskHub-Webseite/staff/plugin.php | 3 +- DeskHub-Webseite/staff/profile.php | 30 +-- DeskHub-Webseite/staff/reset_password.php | 3 +- DeskHub-Webseite/staff/settings.php | 61 ++++- DeskHub-Webseite/staff/ticket_create.php | 9 +- DeskHub-Webseite/staff/tickets.php | 48 +++- 13 files changed, 516 insertions(+), 54 deletions(-) create mode 100644 DeskHub-Webseite/staff/department_form.php diff --git a/DeskHub-Webseite/staff/custom_fields.php b/DeskHub-Webseite/staff/custom_fields.php index fe3d449d..de9bac20 100644 --- a/DeskHub-Webseite/staff/custom_fields.php +++ b/DeskHub-Webseite/staff/custom_fields.php @@ -8,6 +8,7 @@ $fieldTypes = [ 'text' => 'Einzeiliger Text', 'textarea' => 'Mehrzeiliger Text', 'select' => 'Dropdown-Auswahl', + 'radio' => 'Radio-Auswahl (Punkte)', 'checkbox' => 'Checkbox (Ja/Nein)', 'number' => 'Zahl', 'date' => 'Datum', @@ -25,20 +26,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $isRequired = !empty($_POST['is_required']) ? 1 : 0; $isActive = !empty($_POST['is_active']) ? 1 : 0; $sort = (int) ($_POST['sort_order'] ?? 0); + $radioInline = !empty($_POST['radio_inline']) ? 1 : 0; + $helpText = form_help_compose((string) ($_POST['help_text'] ?? ''), (string) ($_POST['link_url'] ?? ''), (string) ($_POST['link_word'] ?? ''), $label); if ($label === '') $errors[] = 'Bitte eine Bezeichnung angeben.'; - if ($type === 'select' && $optionsRaw === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).'; + if (in_array($type, ['select', 'radio'], true) && $optionsRaw === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).'; if (!$errors) { if ($action === 'create') { - $pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, is_required, is_active, sort_order, created_at) VALUES (?,?,?,?,?,?,?,?)') - ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, db_now()]); + $pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, is_required, is_active, sort_order, radio_inline, help_text, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)') + ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $radioInline, $helpText ?: null, db_now()]); audit_log($admin['id'], "Ticket-Feld erstellt: {$label}"); flash('success', 'Feld erstellt.'); } else { $id = (int) $_POST['id']; - $pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, help_topic_id=?, is_required=?, is_active=?, sort_order=? WHERE id=?') - ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $id]); + $pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, help_topic_id=?, is_required=?, is_active=?, sort_order=?, radio_inline=?, help_text=? WHERE id=?') + ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $radioInline, $helpText ?: null, $id]); audit_log($admin['id'], "Ticket-Feld aktualisiert: {$label}"); flash('success', 'Feld aktualisiert.'); } @@ -61,7 +64,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $fields = $pdo->query( 'SELECT cf.*, ht.name AS help_topic_name FROM custom_fields cf LEFT JOIN help_topics ht ON ht.id = cf.help_topic_id - ORDER BY cf.sort_order, cf.label' + WHERE cf.department_id IS NULL ORDER BY cf.sort_order, cf.label' )->fetchAll(); $helpTopics = $pdo->query('SELECT * FROM help_topics ORDER BY name')->fetchAll(); @@ -128,17 +131,36 @@ require __DIR__ . '/_header.php'; - $lbl): ?> -
+
+ +
+ + +
+ + + + + +
+
+
+
+

Bei einer Checkbox steht der Text neben dem Kästchen, bei anderen Feldern unter dem Feld. Das Wort aus „Verlinkter Teil“ wird im Text zum Link auf die Adresse. Eine Checkbox als Pflichtfeld muss angehakt werden.

+ + + + +

+ Ist das aktiv, erscheinen für diese Abteilung nur die unten festgelegten Felder. Die allgemeinen Ticket-Felder und die Felder der Hilfe-Themen werden dann nicht verwendet. + Name und E-Mail-Adresse werden immer abgefragt, damit wir antworten können. +

+ +
+
+
+
+
+
+
+
+ + + + + +

Standardfelder

+ +

Ohne Betrefffeld wird der Betreff automatisch „Anfrage “.

+
+
+
+
+ 'Ganze Zeile', 'half' => 'Halbe Zeile', 'third' => 'Ein Drittel', 'twothirds' => 'Zwei Drittel']; ?> +
+
+
+
+
+
+

Mit „Halbe Zeile“ steht der Betreff neben einem benachbarten eigenen Feld (zum Beispiel einem Zahlenfeld direkt vor oder nach dem Betreff).

+ +

Ohne Beschreibung wird der Text des Tickets aus deinen eigenen Feldern zusammengesetzt („Bezeichnung: Eingabe“).

+ + + + +
+ + +
+
+
+

Felder dieser Abteilung

+ + + + + + + + + + + + + + + + +
ReihenfolgeAbschnittBezeichnungTypPflichtAktiv
+ Bearbeiten · +
+ + +
+
Noch keine eigenen Felder. Rechts kannst du das erste anlegen.
+
+
+
+
+

+
+ + + + + + + + +
+ + +
+ + + +
+ + + trim((string) ($x['form_section'] ?? '')), $fields), fn($v) => $v !== '' && $v[0] !== '@')) as $sn): ?> +

Felder mit gleichem Namen landen im selben Abschnitt.

+
+ +
+ + +
+ + + + +
+
+
+
+

Bei einer Checkbox steht der Text neben dem Kästchen, bei anderen Feldern unter dem Feld. Das Wort aus „Verlinkter Teil“ wird im Text zum Link auf die Adresse. Eine Checkbox als Pflichtfeld muss angehakt werden.

+ + + +

Aufeinanderfolgende Felder im selben Abschnitt, die zusammen in eine Zeile passen, stehen nebeneinander.

+ + + + +
+ + Abbrechen +
+
+
+
+
+ + diff --git a/DeskHub-Webseite/staff/departments.php b/DeskHub-Webseite/staff/departments.php index 822168d3..d802b0f1 100644 --- a/DeskHub-Webseite/staff/departments.php +++ b/DeskHub-Webseite/staff/departments.php @@ -106,6 +106,7 @@ require __DIR__ . '/_header.php';
+ Ticket-Formular
@@ -224,6 +225,7 @@ require __DIR__ . '/_header.php'; Bearbeiten · + Formular ·
diff --git a/DeskHub-Webseite/staff/forgot_password.php b/DeskHub-Webseite/staff/forgot_password.php index 892f88c0..243d930d 100644 --- a/DeskHub-Webseite/staff/forgot_password.php +++ b/DeskHub-Webseite/staff/forgot_password.php @@ -20,7 +20,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Gleiche Fehlermeldung – verrät nicht ob die E-Mail bekannt ist $errors[] = 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten erneut.'; } else { - $stmt = get_db()->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1'); + $stmt = get_db()->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1'); $stmt->execute([$email]); $staff = $stmt->fetch(); @@ -42,6 +42,7 @@ $pageTitle = 'Passwort vergessen'; Passwort vergessen – Mitarbeiterbereich "> +
@@ -59,7 +60,7 @@ $pageTitle = 'Passwort vergessen'; - +
diff --git a/DeskHub-Webseite/staff/help_topics.php b/DeskHub-Webseite/staff/help_topics.php index dbc511d4..f1b59baf 100644 --- a/DeskHub-Webseite/staff/help_topics.php +++ b/DeskHub-Webseite/staff/help_topics.php @@ -53,7 +53,7 @@ if ($editTopic) { $cfStmt = $pdo->prepare('SELECT * FROM custom_fields WHERE help_topic_id = ? ORDER BY sort_order, label'); $cfStmt->execute([$editTopic['id']]); $topicFields = $cfStmt->fetchAll(); - $globalFields = $pdo->query('SELECT * FROM custom_fields WHERE help_topic_id IS NULL AND is_active = 1 ORDER BY sort_order, label')->fetchAll(); + $globalFields = $pdo->query('SELECT * FROM custom_fields WHERE help_topic_id IS NULL AND department_id IS NULL AND is_active = 1 ORDER BY sort_order, label')->fetchAll(); } $sortMode = ($_GET['sort'] ?? 'alpha') === 'manual' ? 'manual' : 'alpha'; $topics = $pdo->query( diff --git a/DeskHub-Webseite/staff/import_osticket.php b/DeskHub-Webseite/staff/import_osticket.php index 9540cdbd..8de1e7c6 100644 --- a/DeskHub-Webseite/staff/import_osticket.php +++ b/DeskHub-Webseite/staff/import_osticket.php @@ -31,11 +31,47 @@ function import_json(array $data): never function import_ajax_guard(): void { header('Content-Type: application/json'); - if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) import_json(['ok' => false, 'error' => 'CSRF']); + if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) { + // Ist eine Anfrage größer als post_max_size, verwirft PHP alle Formulardaten: dann fehlt auch das Token + import_json(['ok' => false, 'error' => 'Die Sitzung ist abgelaufen oder der Upload-Teil war größer als der Server erlaubt (post_max_size ' . ini_get('post_max_size') . '). Bitte die Seite neu laden und erneut versuchen.']); + } } $stateDir = osticket_state_dir(); +/** PHP-Größenangabe ("2M", "128K", "1G") in Bytes. */ +function import_ini_bytes(string $v): int +{ + $v = trim($v); + if ($v === '' || $v === '-1') return PHP_INT_MAX; + $n = (float) $v; + return (int) match (strtolower(substr($v, -1))) { 'g' => $n * 1073741824, 'm' => $n * 1048576, 'k' => $n * 1024, default => $n }; +} + +/** Größe eines Upload-Teils: so groß wie der Server erlaubt (höchstens 5 MB, mit Reserve), damit der Upload auch bei kleinen Limits (z. B. 2 MB) klappt. */ +function import_chunk_size(): int +{ + $limit = min(import_ini_bytes((string) ini_get('upload_max_filesize')), import_ini_bytes((string) ini_get('post_max_size'))); + $size = (int) ($limit * 0.8); + $size = intdiv($size, 65536) * 65536; + return max(131072, min(5 * 1048576, $size)); +} + +/** Verständliche Meldung zu einem PHP-Upload-Fehlercode. */ +function import_upload_error(?array $file): string +{ + $code = $file['error'] ?? UPLOAD_ERR_NO_FILE; + $limit = ini_get('upload_max_filesize') . ' / post_max_size ' . ini_get('post_max_size'); + return match ($code) { + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Der Server lehnt den Upload-Teil ab, er ist größer als erlaubt (upload_max_filesize ' . $limit . '). Laden Sie die Seite neu und versuchen Sie es erneut, oder legen Sie die Datei per FTP in den DeskHub-Ordner (Option 1).', + UPLOAD_ERR_PARTIAL => 'Der Upload-Teil kam nur unvollständig an (Verbindung unterbrochen). Bitte erneut versuchen.', + UPLOAD_ERR_NO_FILE => 'Es kam kein Upload-Teil an.', + UPLOAD_ERR_NO_TMP_DIR => 'Auf dem Server fehlt der temporäre Ordner für Uploads (upload_tmp_dir). Bitte den Hoster fragen oder die Datei per FTP hochladen (Option 1).', + UPLOAD_ERR_CANT_WRITE => 'Der Server konnte den Upload-Teil nicht speichern (Speicherplatz oder Schreibrechte). Bitte die Datei per FTP hochladen (Option 1).', + default => 'Upload fehlgeschlagen (PHP-Fehlercode ' . $code . ').', + }; +} + // ── Chunked Upload ───────────────────────────────────────────────────────── if (($_POST['action'] ?? '') === 'upload_chunk') { import_ajax_guard(); @@ -43,10 +79,12 @@ if (($_POST['action'] ?? '') === 'upload_chunk') { $idx = (int) ($_POST['chunk_index'] ?? 0); $total = (int) ($_POST['total_chunks'] ?? 1); $tmpFile = $stateDir . '/upload.sql'; - if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => 'Chunk-Fehler']); - $fh = fopen($tmpFile, $idx === 0 ? 'wb' : 'ab'); - fwrite($fh, file_get_contents($chunk['tmp_name'])); + if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => import_upload_error($chunk)]); + $fh = @fopen($tmpFile, $idx === 0 ? 'wb' : 'ab'); + if (!$fh) import_json(['ok' => false, 'error' => 'Die Datei konnte nicht gespeichert werden: Der Import-Ordner ist nicht beschreibbar (' . $stateDir . ').']); + $written = fwrite($fh, (string) file_get_contents($chunk['tmp_name'])); fclose($fh); + if ($written === false || $written < (int) filesize($chunk['tmp_name'])) import_json(['ok' => false, 'error' => 'Die Datei konnte nicht vollständig gespeichert werden (Speicherplatz voll?).']); $done = ($idx + 1) >= $total; if ($done) { $_SESSION['import_sql_path'] = $tmpFile; @@ -64,8 +102,11 @@ if (($_POST['action'] ?? '') === 'use_path') { $filename = basename(trim($_POST['sql_filename'] ?? '')); $fullPath = realpath(__DIR__ . '/../' . $filename); $rootDir = realpath(__DIR__ . '/..'); - if (!$filename || !$fullPath || strpos($fullPath, $rootDir) !== 0 || !file_exists($fullPath)) { - flash('error', 'Datei nicht gefunden: ' . $filename); redirect('/staff/import_osticket.php'); + if ($filename === '') { + flash('error', 'Bitte den Dateinamen der SQL-Datei eintragen (das graue Beispiel im Feld ist nur ein Platzhalter).'); redirect('/staff/import_osticket.php'); + } + if (!$fullPath || strpos($fullPath, $rootDir) !== 0 || !is_file($fullPath)) { + flash('error', 'Datei nicht gefunden: ' . $filename . '. Sie muss im DeskHub-Hauptordner auf dem Server liegen (dort, wo auch index.php liegt).'); redirect('/staff/import_osticket.php'); } $_SESSION['import_sql_path'] = $fullPath; $_SESSION['import_filename'] = $filename; @@ -170,7 +211,7 @@ require __DIR__ . '/_header.php';
-

Option 2: Direkt hochladen (in 5-MB-Teilen)

+

Option 2: Direkt hochladen (in Teilen von MB, passend zum Limit dieses Servers)

diff --git a/DeskHub-Webseite/staff/reset_password.php b/DeskHub-Webseite/staff/reset_password.php index 82533e41..bf6a5b73 100644 --- a/DeskHub-Webseite/staff/reset_password.php +++ b/DeskHub-Webseite/staff/reset_password.php @@ -23,7 +23,7 @@ if (!$token || !$email) { $pdo->prepare('UPDATE staff SET password_hash = ? WHERE email = ?') ->execute([password_hash($pass1, PASSWORD_DEFAULT), $email]); consume_password_reset('staff', $token); - $staffRow = $pdo->prepare('SELECT id FROM staff WHERE email = ?'); + $staffRow = $pdo->prepare('SELECT id FROM staff WHERE LOWER(email) = LOWER(?)'); $staffRow->execute([$email]); audit_log((int) $staffRow->fetchColumn(), 'Passwort per Reset-Link zurückgesetzt'); $done = true; @@ -39,6 +39,7 @@ $pageTitle = 'Passwort zurücksetzen'; Passwort zurücksetzen – Mitarbeiterbereich "> +
diff --git a/DeskHub-Webseite/staff/settings.php b/DeskHub-Webseite/staff/settings.php index ea89348d..af2e185e 100644 --- a/DeskHub-Webseite/staff/settings.php +++ b/DeskHub-Webseite/staff/settings.php @@ -174,6 +174,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { else { set_setting('logo_path', '/assets/branding/logo.'.$ext.'?v='.time()); audit_log($staff['id'],'Logo aktualisiert'); flash('success','Logo aktualisiert.'); redirect('/staff/settings.php?tab=design'); } } } + if ($action === 'upload_favicon') { + $file = $_FILES['favicon'] ?? []; + $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); + $err = null; + if (empty($file['name'])) $err = 'Bitte eine Datei auswählen.'; + elseif (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) $err = 'Upload fehlgeschlagen' . (($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE ? ' (die Datei ist größer als das Upload-Limit des Servers).' : '.'); + elseif ($file['size'] > 1024 * 1024) $err = 'Das Favicon darf höchstens 1 MB groß sein.'; + elseif (!isset(favicon_types()[$ext])) $err = 'Erlaubt: ICO, PNG, JPG, GIF, WebP, SVG.'; + else $err = favicon_validate_file($file['tmp_name'], $ext); + if ($err !== null) { $errors[] = $err; } + else { + foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2); + if (!move_uploaded_file($file['tmp_name'], $branding . '/favicon.' . $ext)) { $errors[] = 'Speichern fehlgeschlagen (Ordner assets/branding/ nicht beschreibbar?).'; } + else { set_setting('favicon_path', '/assets/branding/favicon.' . $ext . '?v=' . time()); audit_log($staff['id'], 'Favicon aktualisiert'); flash('success', 'Favicon aktualisiert. Browser zeigen Änderungen oft erst nach Neuladen (Strg+F5) oder in einem neuen Tab.'); redirect('/staff/settings.php?tab=design'); } + } + } + if ($action === 'remove_favicon') { + foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2); + set_setting('favicon_path', null); audit_log($staff['id'], 'Favicon entfernt'); flash('success', 'Favicon entfernt.'); redirect('/staff/settings.php?tab=design'); + } + if ($action === 'save_logo_options') { + $hide = !empty($_POST['logo_hide_name']) ? '1' : '0'; + set_setting('logo_hide_name', $hide); + audit_log($staff['id'], $hide === '1' ? 'Name neben dem Logo ausgeblendet' : 'Name neben dem Logo wird angezeigt'); + flash('success', $hide === '1' ? 'Der Name wird neben dem Logo nicht mehr angezeigt.' : 'Der Name wird wieder neben dem Logo angezeigt.'); + redirect('/staff/settings.php?tab=design'); + } if ($action === 'remove_logo') { foreach (['png','jpg','jpeg','gif','svg'] as $e2) @unlink($branding.'/logo.'.$e2); set_setting('logo_path', null); audit_log($staff['id'],'Logo entfernt'); flash('success','Logo entfernt.'); redirect('/staff/settings.php?tab=design'); @@ -556,7 +583,7 @@ require __DIR__ . '/_header.php';

Firmenlogo

-

PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt.

+

PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt. Der Name der Installation steht standardmäßig daneben und lässt sich unten ausblenden.

Logo @@ -565,6 +592,14 @@ require __DIR__ . '/_header.php'; +
+ + +
+

Kein Logo hinterlegt.

@@ -575,6 +610,30 @@ require __DIR__ . '/_header.php';
+
+

Favicon (Symbol im Browser-Tab)

+

ICO, PNG, JPG, GIF, WebP oder SVG, höchstens 1 MB, am besten quadratisch (z. B. 256×256). Ohne Favicon verwendet DeskHub das Logo.

+ + +
+ + Favicon + +
+ + +
+
+ +

Kein eigenes Favicon hinterlegt.

+ +
+ + +
+
+
+

Titelbild (Hero-Banner)

Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB.

diff --git a/DeskHub-Webseite/staff/ticket_create.php b/DeskHub-Webseite/staff/ticket_create.php index a3901732..2c1530ff 100644 --- a/DeskHub-Webseite/staff/ticket_create.php +++ b/DeskHub-Webseite/staff/ticket_create.php @@ -20,7 +20,7 @@ $departments = $pdo->query('SELECT * FROM departments WHERE is_active = 1 ORDER $priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll(); $helpTopics = help_topic_options($pdo); $allAgents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); -$customFields = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 ORDER BY sort_order, label')->fetchAll(); +$customFields = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll(); // Abteilungen auf zugängliche einschränken if ($myDeptIds !== null) { @@ -285,6 +285,13 @@ require __DIR__ . '/_header.php'; + + + +
+
+ + +
+