From 5a602de4845eb595a973ac8c883a05534d3354f2 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 16 Oct 2026 19:12:24 +0000 Subject: [PATCH] Upload via GUI (258 Dateien) --- .../staff/_dashboard_recent_partial.php | 44 + DeskHub-Webseite/staff/_footer.php | 5 + DeskHub-Webseite/staff/_header.php | 410 +++++ .../staff/_ticket_lookup_ajax.php | 10 + .../staff/_tickets_list_partial.php | 90 + DeskHub-Webseite/staff/_ui.php | 52 + DeskHub-Webseite/staff/tasks.php | 202 ++ DeskHub-Webseite/staff/teams.php | 180 ++ DeskHub-Webseite/staff/ticket_create.php | 341 ++++ .../staff/ticket_merge_search.php | 72 + DeskHub-Webseite/staff/ticket_poll_staff.php | 25 + DeskHub-Webseite/staff/ticket_presence.php | 44 + DeskHub-Webseite/staff/ticket_print.php | 170 ++ DeskHub-Webseite/staff/ticket_rules.php | 426 +++++ DeskHub-Webseite/staff/ticket_split.php | 151 ++ DeskHub-Webseite/staff/ticket_view.php | 1625 +++++++++++++++++ DeskHub-Webseite/staff/tickets.php | 634 +++++++ DeskHub-Webseite/staff/time_entries.php | 120 ++ DeskHub-Webseite/staff/update.php | 420 +++++ DeskHub-Webseite/staff/update_download.php | 24 + .../staff/upload_signature_image.php | 56 + DeskHub-Webseite/staff/webhooks.php | 160 ++ DeskHub-Webseite/sw-staff.js | 40 + DeskHub-Webseite/sw.js | 42 + .../tests/admin_models_integration.php | 205 +++ DeskHub-Webseite/tests/admin_shared_logic.php | 144 ++ DeskHub-Webseite/tests/app_downloads.php | 56 + DeskHub-Webseite/tests/backup_restore.php | 169 ++ DeskHub-Webseite/tests/catalog_preview.php | 176 ++ DeskHub-Webseite/tests/custom_themes.php | 129 ++ .../tests/database_compatibility.php | 112 ++ .../tests/fixtures/no-catalog.php | 3 + DeskHub-Webseite/tests/fixtures/paths.php | 42 + DeskHub-Webseite/tests/integrations_api.php | 152 ++ .../tests/mobile_api_integration.php | 231 +++ DeskHub-Webseite/tests/osticket_import.php | 221 +++ .../tests/plugin_tagesbericht.php | 101 + DeskHub-Webseite/tests/plugin_website.php | 246 +++ .../tests/plugin_website_docker.php | 117 ++ DeskHub-Webseite/tests/plugins.php | 373 ++++ DeskHub-Webseite/tests/run_all.php | 53 + DeskHub-Webseite/tests/sanitize_html.php | 109 ++ .../tests/schema_foreign_keys.php | 125 ++ DeskHub-Webseite/tests/security_check.php | 84 + DeskHub-Webseite/tests/security_helpers.php | 75 + DeskHub-Webseite/tests/theme_catalog.php | 106 ++ .../tests/ticket_access_integration.php | 61 + DeskHub-Webseite/tests/update_security.php | 105 ++ .../tests/web_smoke_integration.php | 353 ++++ DeskHub-Webseite/ticket_lookup.php | 57 + 50 files changed, 8948 insertions(+) create mode 100644 DeskHub-Webseite/staff/_dashboard_recent_partial.php create mode 100644 DeskHub-Webseite/staff/_footer.php create mode 100644 DeskHub-Webseite/staff/_header.php create mode 100644 DeskHub-Webseite/staff/_ticket_lookup_ajax.php create mode 100644 DeskHub-Webseite/staff/_tickets_list_partial.php create mode 100644 DeskHub-Webseite/staff/_ui.php create mode 100644 DeskHub-Webseite/staff/tasks.php create mode 100644 DeskHub-Webseite/staff/teams.php create mode 100644 DeskHub-Webseite/staff/ticket_create.php create mode 100644 DeskHub-Webseite/staff/ticket_merge_search.php create mode 100644 DeskHub-Webseite/staff/ticket_poll_staff.php create mode 100644 DeskHub-Webseite/staff/ticket_presence.php create mode 100644 DeskHub-Webseite/staff/ticket_print.php create mode 100644 DeskHub-Webseite/staff/ticket_rules.php create mode 100644 DeskHub-Webseite/staff/ticket_split.php create mode 100644 DeskHub-Webseite/staff/ticket_view.php create mode 100644 DeskHub-Webseite/staff/tickets.php create mode 100644 DeskHub-Webseite/staff/time_entries.php create mode 100644 DeskHub-Webseite/staff/update.php create mode 100644 DeskHub-Webseite/staff/update_download.php create mode 100644 DeskHub-Webseite/staff/upload_signature_image.php create mode 100644 DeskHub-Webseite/staff/webhooks.php create mode 100644 DeskHub-Webseite/sw-staff.js create mode 100644 DeskHub-Webseite/sw.js create mode 100644 DeskHub-Webseite/tests/admin_models_integration.php create mode 100644 DeskHub-Webseite/tests/admin_shared_logic.php create mode 100644 DeskHub-Webseite/tests/app_downloads.php create mode 100644 DeskHub-Webseite/tests/backup_restore.php create mode 100644 DeskHub-Webseite/tests/catalog_preview.php create mode 100644 DeskHub-Webseite/tests/custom_themes.php create mode 100644 DeskHub-Webseite/tests/database_compatibility.php create mode 100644 DeskHub-Webseite/tests/fixtures/no-catalog.php create mode 100644 DeskHub-Webseite/tests/fixtures/paths.php create mode 100644 DeskHub-Webseite/tests/integrations_api.php create mode 100644 DeskHub-Webseite/tests/mobile_api_integration.php create mode 100644 DeskHub-Webseite/tests/osticket_import.php create mode 100644 DeskHub-Webseite/tests/plugin_tagesbericht.php create mode 100644 DeskHub-Webseite/tests/plugin_website.php create mode 100644 DeskHub-Webseite/tests/plugin_website_docker.php create mode 100644 DeskHub-Webseite/tests/plugins.php create mode 100644 DeskHub-Webseite/tests/run_all.php create mode 100644 DeskHub-Webseite/tests/sanitize_html.php create mode 100644 DeskHub-Webseite/tests/schema_foreign_keys.php create mode 100644 DeskHub-Webseite/tests/security_check.php create mode 100644 DeskHub-Webseite/tests/security_helpers.php create mode 100644 DeskHub-Webseite/tests/theme_catalog.php create mode 100644 DeskHub-Webseite/tests/ticket_access_integration.php create mode 100644 DeskHub-Webseite/tests/update_security.php create mode 100644 DeskHub-Webseite/tests/web_smoke_integration.php create mode 100644 DeskHub-Webseite/ticket_lookup.php diff --git a/DeskHub-Webseite/staff/_dashboard_recent_partial.php b/DeskHub-Webseite/staff/_dashboard_recent_partial.php new file mode 100644 index 00000000..c09ab6db --- /dev/null +++ b/DeskHub-Webseite/staff/_dashboard_recent_partial.php @@ -0,0 +1,44 @@ + weiter unten in + * dashboard.php) – ohne das wäre z.B. ein Ticket, das per App geschlossen wurde, hier erst + * nach einem manuellen Reload verschwunden (siehe Nutzerrückmeldung: App zeigte + * "Geschlossen", das Dashboard hier noch "Offen", bis neu geladen wurde). + * Erwartet die von dashboard.php bereits berechnete Variable $recent. + */ +?> +
+

Priorisiert (mir zugewiesen oder unbearbeitet)

+
+ +
Keine offenen Tickets – gute Arbeit!
+ + + + + + + + + + + + + + + +
TicketBetreffAbteilungPrioritätStatusFällig
# +
+ + · +
+ +
+
diff --git a/DeskHub-Webseite/staff/_footer.php b/DeskHub-Webseite/staff/_footer.php new file mode 100644 index 00000000..9d019e33 --- /dev/null +++ b/DeskHub-Webseite/staff/_footer.php @@ -0,0 +1,5 @@ + + + + + diff --git a/DeskHub-Webseite/staff/_header.php b/DeskHub-Webseite/staff/_header.php new file mode 100644 index 00000000..d276974f --- /dev/null +++ b/DeskHub-Webseite/staff/_header.php @@ -0,0 +1,410 @@ + + + + + + +<?= e($pageTitle ?? 'DeskHub – Mitarbeiterbereich') ?> + + + + + +"> + + + + + + + + + + + + + +
+ + + + + + + +
+ +
+ + +
+ +
+ + + + + +
+ Datenbank veraltet: ausstehende Änderung(en) – der Code ist neuer als die Datenbank. + Jetzt aktualisieren +
+ + +
+ Plugin-Updates verfügbar: $u['name'] . ' ' . $u['available'], $__upd))) ?> + Zu den Plugins +
+ + + +
+ 🔓 Als Admin können Sie die Sperre von „“ auf Ticket # sofort aufheben, statt auf den automatischen Timeout zu warten. +
+ + + + +
+
+ + +
+ ⚠️ SQLite-Modus aktiv: Nicht für den Produktivbetrieb geeignet. + Bitte in config/config.php auf driver = 'mysql' umstellen und MySQL-Zugangsdaten eintragen. +
+ + +
+ 🔑 Kein App-Key gesetzt: Passwörter werden nur schwach verschlüsselt. + Bitte in config/config.php den Wert app_key setzen. + Generieren: php -r "echo bin2hex(random_bytes(32));" +
+ diff --git a/DeskHub-Webseite/staff/_ticket_lookup_ajax.php b/DeskHub-Webseite/staff/_ticket_lookup_ajax.php new file mode 100644 index 00000000..0a331e77 --- /dev/null +++ b/DeskHub-Webseite/staff/_ticket_lookup_ajax.php @@ -0,0 +1,10 @@ +prepare('SELECT id, subject, department_id FROM tickets WHERE ticket_number = ?'); +$stmt->execute([$n]); +$t = $stmt->fetch(); +if ($t && !staff_can_access_department($staff, (int) $t['department_id'])) $t = false; // Betreff fremder Abteilungen nicht preisgeben +echo json_encode($t ? ['id' => $t['id'], 'subject' => $t['subject']] : ['id' => null]); diff --git a/DeskHub-Webseite/staff/_tickets_list_partial.php b/DeskHub-Webseite/staff/_tickets_list_partial.php new file mode 100644 index 00000000..f6b2b5d0 --- /dev/null +++ b/DeskHub-Webseite/staff/_tickets_list_partial.php @@ -0,0 +1,90 @@ + weiter unten in tickets.php) – damit die Tabelle alle paar + * Sekunden ohne Seiten-Reload aktuell bleibt, auch während ein Mitarbeiter + * gerade auf der Liste ist. + * Erwartet dieselben Variablen, die tickets.php vor dem Einbinden bereits berechnet + * hat ($tickets, $ticketTags, $totalPages, $page, $totalCount, $departments, $search, + * $filterDept/$filterStatus/... sowie $_GET für die Pagination-Links). + */ +?> +
+
+ + + +
+ +
+ Treffer + für „" + · Abteilung: + · Filter zurücksetzen +
+ + +
Keine Tickets gefunden.
+ + + + + + + + + + + + + + + + + + + + +
TicketBetreffAbteilungPrioritätStatusZugewiesenFällig
#
+ + · + +
+ + + +
+ +
–' ?>
+ +
+
+ + 1): ?> +
+ '','partial'=>''])) . '&page='; + $start = max(1, $page-2); + $end = min($totalPages, $page+2); + ?> + 1): ?> + ← Zurück + + + + + + Weiter → + + Tickets gesamt +
+ +
diff --git a/DeskHub-Webseite/staff/_ui.php b/DeskHub-Webseite/staff/_ui.php new file mode 100644 index 00000000..15aef59b --- /dev/null +++ b/DeskHub-Webseite/staff/_ui.php @@ -0,0 +1,52 @@ + + + + 'Beschriftung', …] */ +function ui_tab_bar(array $tabs): void +{ + echo '
'; + foreach ($tabs as $key => $label) { + echo ''; + } + echo '
'; +} + +/** Status-Auswahl (Aktiv/Deaktiviert) als Radio-Buttons; Feldname "status" mit den Werten active|inactive. */ +function ui_status_radios(bool $active, string $inactiveLabel = 'Deaktiviert'): void +{ + echo '
'; + echo ''; + echo ''; + echo '
'; +} + +/** Datum für Listenspalten ("–" bei leer). */ +function ui_date(?string $dt): string +{ + return $dt ? date('d.m.Y H:i', strtotime($dt)) : '–'; +} diff --git a/DeskHub-Webseite/staff/tasks.php b/DeskHub-Webseite/staff/tasks.php new file mode 100644 index 00000000..504120f0 --- /dev/null +++ b/DeskHub-Webseite/staff/tasks.php @@ -0,0 +1,202 @@ +query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + + if ($action === 'create') { + $title = trim($_POST['title'] ?? ''); + $desc = trim($_POST['description'] ?? ''); + $assignTo = ($_POST['assigned_to'] ?? '') !== '' ? (int)$_POST['assigned_to'] : null; + $dueAt = trim($_POST['due_at'] ?? '') ?: null; + $ticketId = ($_POST['ticket_id'] ?? '') !== '' ? (int)$_POST['ticket_id'] : null; + + if ($title === '') $errors[] = 'Bitte einen Titel angeben.'; + if (!$errors) { + $pdo->prepare('INSERT INTO tasks (ticket_id,title,description,assigned_to,due_at,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)') + ->execute([$ticketId, $title, $desc ?: null, $assignTo, $dueAt, $staff['id'], db_now(), db_now()]); + flash('success', 'Aufgabe erstellt.'); + if ($ticketId) { + $tn = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id=?'); $tn->execute([$ticketId]); + redirect('/staff/ticket_view.php?n=' . urlencode($tn->fetchColumn())); + } + redirect('/staff/tasks.php'); + } + } + + if ($action === 'update_status') { + $id = (int) $_POST['id']; + $status = in_array($_POST['status']??'', ['open','in_progress','done']) ? $_POST['status'] : 'open'; + $pdo->prepare('UPDATE tasks SET status=?, updated_at=? WHERE id=?')->execute([$status, db_now(), $id]); + // Kein open redirect: Referer nur wenn er auf /staff/ zeigt + $ref = $_SERVER['HTTP_REFERER'] ?? ''; + $safePath = (parse_url($ref, PHP_URL_HOST) === ($_SERVER['HTTP_HOST'] ?? '') && strpos(parse_url($ref, PHP_URL_PATH) ?? '', '/staff/') === 0) + ? $ref : '/staff/tasks.php'; + redirect($safePath); + } + + if ($action === 'delete') { + $pdo->prepare('DELETE FROM tasks WHERE id=?')->execute([(int)$_POST['id']]); + flash('success', 'Aufgabe gelöscht.'); + redirect('/staff/tasks.php'); + } +} + +$filter = $_GET['filter'] ?? 'my'; // my, all, open, done +$where = []; +$params = []; + +if ($filter === 'my') { + $where[] = 'ta.assigned_to = ?'; $params[] = $staff['id']; +} elseif ($filter === 'open') { + $where[] = "ta.status != 'done'"; +} elseif ($filter === 'done') { + $where[] = "ta.status = 'done'"; +} + +$sql = 'SELECT ta.*, s.name AS assigned_name, s2.name AS creator_name, + t.ticket_number, t.subject AS ticket_subject + FROM tasks ta + LEFT JOIN staff s ON s.id = ta.assigned_to + LEFT JOIN staff s2 ON s2.id = ta.created_by + LEFT JOIN tickets t ON t.id = ta.ticket_id' + . ($where ? ' WHERE ' . implode(' AND ', $where) : '') + . ' ORDER BY ta.due_at IS NULL, ta.due_at ASC, ta.created_at DESC LIMIT 200'; +$stmt = $pdo->prepare($sql); +$stmt->execute($params); +$tasks = $stmt->fetchAll(); + +$statusLabels = ['open'=>'Offen','in_progress'=>'In Arbeit','done'=>'Erledigt']; +$statusColors = ['open'=>'#2563eb','in_progress'=>'#d97706','done'=>'#16a34a']; + +$pageTitle = 'Aufgaben'; $activePage = 'tasks'; +require __DIR__ . '/_header.php'; +?> +
+

Aufgaben

+ +
+ +
+ + + + + +
+ 'Meine','all'=>'Alle','open'=>'Offen','done'=>'Erledigt'] as $f=>$lbl): ?> + + + + +
+ +
+ + + + + + + + + + + + + + + +
AufgabeVerknüpftes TicketZugewiesenFälligStatus
+ +
+
+ + #
+ + – +
+ + +
+ + + + +
+
+
+ + + + +
+
Keine Aufgaben gefunden.
+
+ + + diff --git a/DeskHub-Webseite/staff/teams.php b/DeskHub-Webseite/staff/teams.php new file mode 100644 index 00000000..c9a518f0 --- /dev/null +++ b/DeskHub-Webseite/staff/teams.php @@ -0,0 +1,180 @@ +query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + + if ($action === 'create' || $action === 'update') { + $id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0; + try { + team_save($pdo, $id, [ + 'name' => $_POST['name'] ?? '', 'lead_id' => $_POST['lead_id'] ?? null, 'notes' => $_POST['notes'] ?? '', + 'is_active' => ($_POST['status'] ?? 'active') === 'active', 'no_alerts' => !empty($_POST['no_alerts']), + 'members' => $_POST['members'] ?? [], + ]); + audit_log($staff['id'], ($action === 'create' ? 'Team erstellt: ' : 'Team aktualisiert: ') . trim($_POST['name'] ?? '')); + flash('success', $action === 'create' ? 'Team erstellt.' : 'Änderungen gespeichert.'); + redirect('/staff/teams.php'); + } catch (AdminModelException $e) { + $errors[] = $e->getMessage(); + } + } + + if ($action === 'delete') { + $deleted = team_delete($pdo, (int) $_POST['id']); + audit_log($staff['id'], "Team gelöscht: {$deleted}"); + flash('success', 'Team gelöscht.'); + redirect('/staff/teams.php'); + } +} + +$isNew = isset($_GET['new']); +$editId = (int) ($_GET['edit'] ?? 0); +$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true)); + +$editTeam = null; $editMembers = []; +if ($editId) { + $es = $pdo->prepare('SELECT * FROM teams WHERE id = ?'); $es->execute([$editId]); + $editTeam = $es->fetch() ?: null; + $em = $pdo->prepare('SELECT staff_id FROM team_members WHERE team_id = ?'); $em->execute([$editId]); + $editMembers = array_map('intval', $em->fetchAll(PDO::FETCH_COLUMN)); +} +if ($_POST && $errors) $editMembers = array_map('intval', (array) ($_POST['members'] ?? [])); + +$teams = $pdo->query( + 'SELECT t.*, s.name AS lead_name, + (SELECT COUNT(*) FROM team_members tm WHERE tm.team_id = t.id) AS member_count + FROM teams t LEFT JOIN staff s ON s.id = t.lead_id ORDER BY t.name' +)->fetchAll(); +$fmt = fn(?string $dt) => $dt ? date('d.m.Y H:i', strtotime($dt)) : '–'; + +$pageTitle = 'Teams'; $activePage = 'teams'; +require __DIR__ . '/_header.php'; +?> + +
+ + + $_POST[$k] ?? $default; + $v = fn(string $k, string $col, $default = '') => $f($k, $editTeam[$col] ?? $default); + $curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editTeam['is_active'] ?? 1) ? 'active' : 'inactive'); + $curLead = (string) $v('lead_id', 'lead_id'); + $noAlerts = $_POST ? !empty($_POST['no_alerts']) : !empty($editTeam['no_alerts']); +?> +

+ + — +

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

Teaminformationen

+ + + +
+ + +
+ + + +

Administratornotizen – Interne Notizen, sichtbar für alle Administratoren

+ +
+ + + +
+ + Abbrechen +
+
+ + + + + +
+

Teams

+ Neues Team hinzufügen +
+

Abteilungsübergreifende Mitarbeiter-Gruppen. Tickets können einem Team statt einem einzelnen Mitarbeiter zugewiesen werden; die Mitglieder werden dabei benachrichtigt.

+ +
+ + + + + + + + + + + + + + + + +
TeamnameStatusMitgliederTeamleiterErstelltZuletzt aktualisiert
+ Bearbeiten · +
+ + + + +
+
Noch keine Teams angelegt.
+
+ + + diff --git a/DeskHub-Webseite/staff/ticket_create.php b/DeskHub-Webseite/staff/ticket_create.php new file mode 100644 index 00000000..a3901732 --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_create.php @@ -0,0 +1,341 @@ +query('SELECT * FROM departments WHERE is_active = 1 ORDER BY name')->fetchAll(); +$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(); + +// Abteilungen auf zugängliche einschränken +if ($myDeptIds !== null) { + $departments = array_filter($departments, fn($d) => in_array((int) $d['id'], $myDeptIds, true)); +} + +$errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + + $requesterName = trim($_POST['requester_name'] ?? ''); + $requesterEmail = trim($_POST['requester_email'] ?? ''); + $subject = trim($_POST['subject'] ?? ''); + $message = sanitize_staff_html(trim($_POST['message'] ?? '')); + $internalNote = sanitize_staff_html(trim($_POST['internal_note'] ?? '')); + $source = in_array($_POST['source'] ?? '', ['phone','staff','web']) ? $_POST['source'] : 'phone'; + $deptId = (int) ($_POST['department_id'] ?? ($departments[0]['id'] ?? 0)); + $prioId = (int) ($_POST['priority_id'] ?? ($priorities[0]['id'] ?? 0)); + $helpTopicId = ($_POST['help_topic_id'] ?? '') !== '' ? (int) $_POST['help_topic_id'] : null; + $assignedId = ($_POST['assigned_staff_id'] ?? '') !== '' ? (int) $_POST['assigned_staff_id'] : null; + + if ($requesterName === '') $errors[] = 'Bitte Name des Anfragers angeben.'; + if ($requesterEmail !== '' && !filter_var($requesterEmail, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Bitte eine gültige E-Mail-Adresse angeben (oder das Feld leer lassen).'; + } + if ($subject === '') $errors[] = 'Bitte einen Betreff angeben.'; + if (trim(strip_tags($message)) === '') $errors[] = 'Bitte eine Beschreibung eingeben.'; + + if (!$errors) { + // Sperrliste prüfen (nur wenn E-Mail vorhanden) + if ($requesterEmail !== '' && is_banned($requesterEmail)) { + $errors[] = 'Diese E-Mail-Adresse steht auf der Sperrliste.'; + } + } + + if (!$errors) { + // Ticket-Regeln anwenden + $ruleResult = evaluate_ticket_rules($pdo, [ + 'subject' => $subject, + 'message' => strip_tags($message), + 'requester_email' => $requesterEmail, + 'requester_name' => $requesterName, + 'source' => $source, + ]); + + if ($ruleResult['set_department'] !== null) $deptId = $ruleResult['set_department']; + if ($ruleResult['set_priority'] !== null) $prioId = $ruleResult['set_priority']; + if ($ruleResult['set_help_topic'] !== null) $helpTopicId = $ruleResult['set_help_topic']; + if ($ruleResult['assign_staff'] !== null && $assignedId === null) $assignedId = $ruleResult['assign_staff']; + + $prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); + $prioStmt->execute([$prioId]); + $priority = $prioStmt->fetch(); + $sla = calc_sla_dates($priority, null, (int) $deptId, $helpTopicId ? (int) $helpTopicId : null); + $now = db_now(); + $ticketNumber = generate_ticket_number($pdo); + + // Bekannten registrierten Kunden nachschlagen + $userId = null; + if ($requesterEmail !== '') { + $userStmt = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)'); + $userStmt->execute([$requesterEmail]); + $userId = $userStmt->fetchColumn() ?: null; + } + + $pdo->prepare( + 'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, + help_topic_id, priority_id, status, source, assigned_staff_id, + first_response_due_at, resolution_due_at, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + )->execute([ + $ticketNumber, $subject, $userId, $requesterName, $requesterEmail, $deptId, + $helpTopicId, $prioId, 'open', $source, $assignedId, + $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now, + ]); + $ticketId = (int) $pdo->lastInsertId(); + + // Erste Nachricht eintragen (öffentlich, vom Mitarbeiter im Namen des Kunden) + $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)' + )->execute([$ticketId, 'staff', $staff['id'], $message, $now]); + + // Optionale interne Notiz + if (trim(strip_tags($internalNote)) !== '') { + $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,1,?)' + )->execute([$ticketId, 'staff', $staff['id'], $internalNote, $now]); + } + + // Individuelle Felder + if ($customFields) { + $cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)'); + foreach ($customFields as $f) { + $fn = 'custom_' . $f['id']; + $val = $f['field_type'] === 'checkbox' ? (!empty($_POST[$fn]) ? '1' : '0') : trim($_POST[$fn] ?? ''); + if ($val !== '' && $val !== '0') $cfStmt->execute([$ticketId, $f['id'], $val]); + } + } + + log_activity($pdo, $ticketId, $staff['id'], "Ticket durch Mitarbeiter erstellt (Quelle: {$source})"); + webhook_dispatch($pdo, 'ticket.created', $ticketId); + + foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName); + if ($ruleResult['matched_rules']) { + log_activity($pdo, $ticketId, $staff['id'], 'Ticket-Regel: ' . implode(', ', $ruleResult['matched_rules'])); + } + + // Eingangsbestätigung an Kunden (nur wenn E-Mail vorhanden und nicht interne Quelle) + if ($requesterEmail !== '' && !empty($_POST['notify_customer'])) { + $deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?'); + $deptStmt->execute([$deptId]); + $dept = $deptStmt->fetch(); + notify_customer_ticket_created([ + 'ticket_number' => $ticketNumber, + 'subject' => $subject, + 'requester_name' => $requesterName, + 'requester_email' => $requesterEmail, + 'department_id' => $deptId, + 'department_name' => $dept['name'] ?? null, + ]); + } + + flash('success', "Ticket #{$ticketNumber} erfolgreich erstellt."); + redirect('/staff/ticket_view.php?n=' . urlencode($ticketNumber)); + } +} + +$pageTitle = 'Ticket erstellen'; +$activePage = 'tickets'; +require __DIR__ . '/_header.php'; +?> + +
+ +
+

Neues Ticket erstellen

+ ← Zur Ticket-Liste +
+

Ticket im Namen eines Kunden anlegen – z.B. bei Telefonanrufen oder Vor-Ort-Anfragen.

+ + +
+ + +
+ + +
+

Anfrage-Quelle

+
+ '📞 Telefon', 'staff' => '🖥 Vor Ort / Intern', 'web' => '🌐 Web (im Namen)'] as $val => $lbl): ?> + + +
+
+ +
+

Kundendaten

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

Ticket-Details

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

Individuelle Felder

+ + + + + + + + + + > + + +
+ + +
+

Interne Notiz (optional, nur für Mitarbeiter sichtbar)

+
+
+ + + + +
+
+
+ +
+ +
+ + Abbrechen +
+
+ +
+ + + + diff --git a/DeskHub-Webseite/staff/ticket_merge_search.php b/DeskHub-Webseite/staff/ticket_merge_search.php new file mode 100644 index 00000000..5c1618cc --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_merge_search.php @@ -0,0 +1,72 @@ +prepare($sql); +$stmt->execute($params); +$results = $stmt->fetchAll(PDO::FETCH_ASSOC); + +// Nur nötige Felder zurückgeben +$out = array_map(fn($t) => [ + 'ticket_number' => $t['ticket_number'], + 'subject' => $t['subject'], + 'requester_name' => $t['requester_name'], + 'requester_email' => $t['requester_email'], + 'status' => $t['status'], + 'priority_name' => $t['priority_name'], + 'department_name' => $t['department_name'], + 'staff_name' => $t['staff_name'] ?? '–', + 'created_at' => $t['created_at'], +], $results); + +echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG); diff --git a/DeskHub-Webseite/staff/ticket_poll_staff.php b/DeskHub-Webseite/staff/ticket_poll_staff.php new file mode 100644 index 00000000..ca82213a --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_poll_staff.php @@ -0,0 +1,25 @@ +prepare('SELECT id, department_id FROM tickets WHERE ticket_number = ?'); +$stmt->execute([$number]); +$ticket = $stmt->fetch(); + +if (!$ticket || !staff_can_access_department($staff, (int) $ticket['department_id'])) { + echo json_encode(['new_count' => $known]); + exit; +} + +// Mitarbeiter sehen auch interne Notizen +$stmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = ?'); +$stmt->execute([$ticket['id']]); +$count = (int) $stmt->fetchColumn(); + +echo json_encode(['new_count' => $count]); diff --git a/DeskHub-Webseite/staff/ticket_presence.php b/DeskHub-Webseite/staff/ticket_presence.php new file mode 100644 index 00000000..5fb8403e --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_presence.php @@ -0,0 +1,44 @@ +prepare("DELETE FROM ticket_presence WHERE ticket_id=? AND staff_id=?") + ->execute([$ticketId, $staff['id']]); + echo json_encode(['ok' => true]); + exit; +} + +if ($action === 'ping') { + // Eigene Präsenz aktualisieren + $presenceSql = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' + ? 'INSERT INTO ticket_presence (ticket_id, staff_id, last_seen_at) VALUES (?,?,?) + ON CONFLICT(ticket_id, staff_id) DO UPDATE SET last_seen_at=excluded.last_seen_at' + : 'INSERT INTO ticket_presence (ticket_id, staff_id, last_seen_at) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE last_seen_at=VALUES(last_seen_at)'; + $pdo->prepare($presenceSql)->execute([$ticketId, $staff['id'], db_now()]); + + // Alte Einträge (> 30 Sek.) löschen + $pdo->prepare('DELETE FROM ticket_presence WHERE last_seen_at < ?') + ->execute([date('Y-m-d H:i:s', time() - 30)]); +} + +// Alle aktiven Mitarbeiter auf diesem Ticket zurückgeben +$activeSince = date('Y-m-d H:i:s', time() - 30); +$stmt = $pdo->prepare( + "SELECT s.id, s.name, tp.last_seen_at + FROM ticket_presence tp + JOIN staff s ON s.id = tp.staff_id + WHERE tp.ticket_id = ? AND tp.staff_id != ? + AND tp.last_seen_at >= ? + ORDER BY s.name" +); +$stmt->execute([$ticketId, $staff['id'], $activeSince]); +echo json_encode($stmt->fetchAll()); diff --git a/DeskHub-Webseite/staff/ticket_print.php b/DeskHub-Webseite/staff/ticket_print.php new file mode 100644 index 00000000..98e35b7f --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_print.php @@ -0,0 +1,170 @@ +prepare( + 'SELECT t.*, p.name AS priority_name, p.color AS priority_color, + d.name AS department_name, s.name AS staff_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id + WHERE t.ticket_number = ?' +); +$stmt->execute([$number]); +$ticket = $stmt->fetch(); + +if (!$ticket) { + http_response_code(404); + die('Ticket nicht gefunden.'); +} +if (!staff_can_access_department($staff, (int) $ticket['department_id'])) { + http_response_code(403); + die('Sie haben keinen Zugriff auf Tickets dieser Abteilung.'); +} + +// Thread laden +$thread = $pdo->prepare( + 'SELECT tt.*, s.name AS staff_name, u.name AS user_name + FROM ticket_threads tt + LEFT JOIN staff s ON s.id = tt.staff_id + LEFT JOIN users u ON u.id = tt.user_id + WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 + ORDER BY tt.created_at ASC' +); +$thread->execute([$ticket['id']]); +$messages = $thread->fetchAll(); + +// Anhänge +$attStmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE thread_id = ?'); + +$siteName = get_setting('display_name', config()['site']['name'] ?? 'DeskHub'); +[$statusLabel] = status_label($ticket['status']); +?> + + + + +Ticket #<?= e($ticket['ticket_number']) ?> – <?= e($ticket['subject']) ?> + + + + + +
+ + ← Zurück +
+ + + + + +
+ + +
+
Ticket-Nr.:#
+
Status:
+
Absender: <>
+
Priorität:
+
Abteilung:
+
Zugewiesen:
+
Erstellt:
+ +
Fällig bis:
+ +
+ + +
Verlauf ( Nachricht)
+ +]*>/i', $body); + + $attStmt->execute([$msg['id']]); + $atts = $attStmt->fetchAll(); +?> +
+
+ + +
+
+ + + + '.nl2br(e($p)).'

'; + } + ?> + +
+ +
+ 📎 Anhänge: e($a['original_name']), $atts)) ?> +
+ +
+ + + + + + + diff --git a/DeskHub-Webseite/staff/ticket_rules.php b/DeskHub-Webseite/staff/ticket_rules.php new file mode 100644 index 00000000..857f3460 --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_rules.php @@ -0,0 +1,426 @@ +query("SELECT action_value FROM ticket_rule_actions WHERE action_type = 'set_department'")->fetchAll(PDO::FETCH_COLUMN))); +$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll(); +$helpTopics = $pdo->query('SELECT * FROM help_topics ORDER BY name')->fetchAll(); +$staffList = $pdo->query("SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name")->fetchAll(); +$cannedResponses = $pdo->query('SELECT id, title FROM canned_responses ORDER BY title')->fetchAll(); + +$fieldLabels = [ + 'subject' => 'Betreff', + 'message' => 'Nachricht', + 'requester_email' => 'Absender-E-Mail', + 'requester_name' => 'Absender-Name', + 'source' => 'Kanal (web/email)', +]; +$operatorLabels = [ + 'contains' => 'enthält', + 'not_contains' => 'enthält nicht', + 'equals' => 'ist genau', + 'starts_with' => 'beginnt mit', + 'ends_with' => 'endet mit', +]; +$actionLabels = [ + 'set_department' => 'Abteilung setzen', + 'set_priority' => 'Priorität setzen', + 'set_help_topic' => 'Hilfe-Thema setzen', + 'assign_staff' => 'Mitarbeiter zuweisen', + 'add_tag' => 'Tag hinzufügen', + 'auto_reply' => 'Auto-Antwort senden (Textbaustein)', + 'reject' => 'Ticket verwerfen (z.B. Spam)', +]; + +$errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + + if ($action === 'create' || $action === 'update') { + $name = trim($_POST['name'] ?? ''); + $matchType = ($_POST['match_type'] ?? 'all') === 'any' ? 'any' : 'all'; + $isActive = ($_POST['status'] ?? 'active') === 'active' ? 1 : 0; + $notes = trim($_POST['notes'] ?? ''); + $sortOrder = (int) ($_POST['sort_order'] ?? 0); + + $condFields = $_POST['cond_field'] ?? []; + $condOperators = $_POST['cond_operator'] ?? []; + $condValues = $_POST['cond_value'] ?? []; + + $actTypes = $_POST['action_type'] ?? []; + $actDept = $_POST['action_value_dept'] ?? []; + $actPrio = $_POST['action_value_prio'] ?? []; + $actTopic = $_POST['action_value_topic'] ?? []; + $actStaff = $_POST['action_value_staff'] ?? []; + $actTag = $_POST['action_value_tag'] ?? []; + $actCanned = $_POST['action_value_canned'] ?? []; + + if ($name === '') $errors[] = 'Bitte einen Namen für die Regel angeben.'; + + $conditions = []; + foreach ($condFields as $i => $f) { + $val = trim($condValues[$i] ?? ''); + if ($val === '') continue; + if (!isset($fieldLabels[$f]) || !isset($operatorLabels[$condOperators[$i] ?? ''])) continue; + $conditions[] = ['field' => $f, 'operator' => $condOperators[$i], 'value' => $val]; + } + if (!$conditions) $errors[] = 'Bitte mindestens eine Bedingung mit einem Wert angeben.'; + + $actionsToSave = []; + foreach ($actTypes as $i => $t) { + if (!isset($actionLabels[$t])) continue; + $value = match ($t) { + 'set_department' => $actDept[$i] ?? '', + 'set_priority' => $actPrio[$i] ?? '', + 'set_help_topic' => $actTopic[$i] ?? '', + 'assign_staff' => $actStaff[$i] ?? '', + 'add_tag' => trim($actTag[$i] ?? ''), + 'auto_reply' => $actCanned[$i] ?? '', + 'reject' => '1', + default => '', + }; + if ($t !== 'reject' && $value === '') continue; + $actionsToSave[] = ['action_type' => $t, 'action_value' => $value]; + } + if (!$actionsToSave) $errors[] = 'Bitte mindestens eine Aktion festlegen.'; + + if (!$errors) { + if ($action === 'create') { + $pdo->prepare('INSERT INTO ticket_rules (name, match_type, is_active, sort_order, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?)') + ->execute([$name, $matchType, $isActive, $sortOrder, $notes ?: null, db_now(), db_now()]); + $ruleId = (int) $pdo->lastInsertId(); + audit_log($staff['id'], "Ticket-Regel erstellt: {$name}"); + flash('success', 'Ticket-Regel erstellt.'); + } else { + $ruleId = (int) $_POST['id']; + $pdo->prepare('UPDATE ticket_rules SET name=?, match_type=?, is_active=?, sort_order=?, notes=?, updated_at=? WHERE id=?') + ->execute([$name, $matchType, $isActive, $sortOrder, $notes ?: null, db_now(), $ruleId]); + $pdo->prepare('DELETE FROM ticket_rule_conditions WHERE rule_id = ?')->execute([$ruleId]); + $pdo->prepare('DELETE FROM ticket_rule_actions WHERE rule_id = ?')->execute([$ruleId]); + audit_log($staff['id'], "Ticket-Regel aktualisiert: {$name}"); + flash('success', 'Ticket-Regel aktualisiert.'); + } + $condStmt = $pdo->prepare('INSERT INTO ticket_rule_conditions (rule_id, field, operator, value) VALUES (?,?,?,?)'); + foreach ($conditions as $c) { + $condStmt->execute([$ruleId, $c['field'], $c['operator'], $c['value']]); + } + $actStmt = $pdo->prepare('INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES (?,?,?)'); + foreach ($actionsToSave as $a) { + $actStmt->execute([$ruleId, $a['action_type'], $a['action_value']]); + } + redirect('/staff/ticket_rules.php'); + } + } + + if ($action === 'toggle_active') { + $id = (int) $_POST['id']; + $pdo->prepare('UPDATE ticket_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]); + redirect('/staff/ticket_rules.php'); + } + + if ($action === 'delete') { + $id = (int) $_POST['id']; + $nameStmt = $pdo->prepare('SELECT name FROM ticket_rules WHERE id = ?'); + $nameStmt->execute([$id]); + $ruleName = $nameStmt->fetchColumn(); + $pdo->prepare('DELETE FROM ticket_rules WHERE id = ?')->execute([$id]); + audit_log($staff['id'], "Ticket-Regel gelöscht: {$ruleName}"); + flash('success', 'Ticket-Regel gelöscht.'); + redirect('/staff/ticket_rules.php'); + } + + if ($action === 'move' && in_array($_POST['dir'] ?? '', ['up', 'down'], true)) { + $id = (int) $_POST['id']; + $dir = $_POST['dir']; + $rules = $pdo->query('SELECT id, sort_order FROM ticket_rules ORDER BY sort_order, id')->fetchAll(); + $idx = null; + foreach ($rules as $i => $r) { if ((int) $r['id'] === $id) { $idx = $i; break; } } + $swapIdx = $dir === 'up' ? $idx - 1 : $idx + 1; + if ($idx !== null && isset($rules[$swapIdx])) { + $a = $rules[$idx]; $b = $rules[$swapIdx]; + $pdo->prepare('UPDATE ticket_rules SET sort_order = ? WHERE id = ?')->execute([$b['sort_order'], $a['id']]); + $pdo->prepare('UPDATE ticket_rules SET sort_order = ? WHERE id = ?')->execute([$a['sort_order'], $b['id']]); + } + redirect('/staff/ticket_rules.php'); + } +} + +$rules = $pdo->query('SELECT * FROM ticket_rules ORDER BY sort_order, id')->fetchAll(); +foreach ($rules as &$r) { + $cs = $pdo->prepare('SELECT * FROM ticket_rule_conditions WHERE rule_id = ?'); + $cs->execute([$r['id']]); + $r['conditions'] = $cs->fetchAll(); + $as = $pdo->prepare('SELECT * FROM ticket_rule_actions WHERE rule_id = ?'); + $as->execute([$r['id']]); + $r['actions'] = $as->fetchAll(); +} +unset($r); + +$editId = $_GET['edit'] ?? null; +$showForm = isset($_GET['new']) || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true)); +$editRule = null; +if ($editId) { + foreach ($rules as $r) { if ((int) $r['id'] === (int) $editId) { $editRule = $r; break; } } +} + +$pageTitle = 'Ticket-Regeln'; +$activePage = 'ticket_rules'; +require __DIR__ . '/_header.php'; + +/** Rendert die Werte-Auswahl einer Aktionszeile für eine bereits gespeicherte Aktion (Bearbeiten-Modus) */ +function render_action_value_selects(array $departments, array $priorities, array $helpTopics, array $staffList, array $cannedResponses, ?array $existing = null): void +{ + $effectiveType = $existing['action_type'] ?? 'set_department'; // Default-Auswahl des Typ-Selects + $sel = fn($type) => ($effectiveType === $type) ? '' : 'style="display:none"'; + $val = fn($type) => ($existing && $existing['action_type'] === $type) ? $existing['action_value'] : ''; + ?> + + + + + > + + + + Noch keine Textbausteine angelegt – jetzt erstellen. + + Keine weitere Einstellung nötig. + + + +
+

Ticket-Regeln (Filter)

+ Neue Regel hinzufügen +
+

Automatische Aktionen für neue Tickets (Web-Formular und E-Mail-Eingang), ausgewertet in der Reihenfolge unten. Trifft eine Regel mit Aktion „Ticket verwerfen“ zu, werden keine weiteren Regeln mehr geprüft.

+ + + +
+ + + +
+ + + + $r): ?> + + + + + + + + + + + + + + +
ReihenfolgeNameBedingungenAktionenStatusZuletzt aktualisiert
+
+ + + +
+
+ + + +
+
+ + +
„“
+ +
+ +
+ +
+
+ + + + +
+
+ Bearbeiten · +
+ + + + +
+
Noch keine Ticket-Regeln angelegt.
+
+ + $_POST[$k] ?? $default; + $curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editRule['is_active'] ?? 1); +?> +

+ + — +

+ +
+
+ + + + + 'Regel', 'conds' => 'Bedingungen', 'acts' => 'Aktionen']); ?> + +
+

Regelinformationen

+ + + + + +

Administratornotizen – Interne Notizen, sichtbar für alle Administratoren

+ +
+ + + + + +
+ + Abbrechen +
+
+
+ + + + + + + + + + diff --git a/DeskHub-Webseite/staff/ticket_split.php b/DeskHub-Webseite/staff/ticket_split.php new file mode 100644 index 00000000..87a7a071 --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_split.php @@ -0,0 +1,151 @@ +prepare( + 'SELECT t.*, d.name AS department_name, p.name AS priority_name + FROM tickets t + JOIN departments d ON d.id = t.department_id + JOIN priorities p ON p.id = t.priority_id + WHERE t.id = ?' +); +$stmt->execute([$ticketId]); +$ticket = $stmt->fetch(); +if (!$ticket) { http_response_code(404); die('Ticket nicht gefunden.'); } +if (!staff_can_access_department($staff, (int) $ticket['department_id'])) { http_response_code(403); die('Sie haben keinen Zugriff auf Tickets dieser Abteilung.'); } + +// Nachrichten laden +$threads = $pdo->prepare( + 'SELECT tt.*, s.name AS staff_name + FROM ticket_threads tt + LEFT JOIN staff s ON s.id = tt.staff_id + WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 + ORDER BY tt.created_at ASC' +); +$threads->execute([$ticketId]); +$messages = $threads->fetchAll(); + +$errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + + $selectedMsgIds = array_map('intval', $_POST['message_ids'] ?? []); + $newSubject = trim($_POST['new_subject'] ?? ''); + + if (empty($selectedMsgIds)) { + $errors[] = 'Bitte mindestens eine Nachricht auswählen.'; + } elseif (!$newSubject) { + $errors[] = 'Bitte einen Betreff für das neue Ticket angeben.'; + } else { + // Neues Ticket erstellen + $newNumber = generate_ticket_number(); + $pdo->prepare( + 'INSERT INTO tickets (ticket_number, subject, status, priority_id, department_id, + user_id, requester_name, requester_email, source, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)' + )->execute([ + $newNumber, $newSubject, 'open', + $ticket['priority_id'], $ticket['department_id'], + $ticket['user_id'], $ticket['requester_name'], $ticket['requester_email'], + 'staff', db_now(), db_now() + ]); + $newId = $pdo->lastInsertId(); + + // Ausgewählte Nachrichten verschieben + $moved = 0; + foreach ($selectedMsgIds as $msgId) { + $rows = $pdo->prepare('UPDATE ticket_threads SET ticket_id=? WHERE id=? AND ticket_id=?'); + $rows->execute([$newId, $msgId, $ticketId]); + $moved += $rows->rowCount(); + } + + // Anhänge der verschobenen Nachrichten mitnehmen + if ($moved > 0) { + $placeholders = implode(',', array_fill(0, count($selectedMsgIds), '?')); + $pdo->prepare("UPDATE ticket_attachments SET ticket_id=? WHERE thread_id IN ($placeholders)") + ->execute(array_merge([$newId], $selectedMsgIds)); + } + + // Log-Einträge + log_activity($pdo, $ticketId, $staff['id'], "Ticket aufgeteilt → #{$newNumber} ({$moved} Nachrichten)"); + log_activity($pdo, $newId, $staff['id'], "Erstellt durch Aufteilen von #{$ticket['ticket_number']}"); + audit_log($staff['id'], "Ticket #{$ticket['ticket_number']} aufgeteilt → #{$newNumber}"); + + flash('success', "Ticket aufgeteilt. Neues Ticket: #{$newNumber}"); + redirect('/staff/ticket_view.php?n=' . urlencode($newNumber)); + } +} + +$pageTitle = 'Ticket aufteilen'; +$activePage = 'tickets'; +require __DIR__ . '/_header.php'; +?> + +
+
+

Ticket aufteilen

+ # +
+ ← Zurück +
+ +
+ +
+
+

Nachrichten auswählen

+

+ Wähle die Nachrichten aus die in das neue Ticket verschoben werden sollen. Die gewählten Nachrichten werden aus diesem Ticket entfernt. +

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

Hinweise

+
    +
  • Ausgewählte Nachrichten werden in ein neues Ticket verschoben
  • +
  • Anhänge der Nachrichten werden mitgenommen
  • +
  • Das ursprüngliche Ticket bleibt mit den verbleibenden Nachrichten erhalten
  • +
  • Beide Tickets erhalten einen Log-Eintrag
  • +
+
+
Ursprüngliches Ticket
+ # – +
Nachrichten gesamt
+
+
+
+ + diff --git a/DeskHub-Webseite/staff/ticket_view.php b/DeskHub-Webseite/staff/ticket_view.php new file mode 100644 index 00000000..a68af055 --- /dev/null +++ b/DeskHub-Webseite/staff/ticket_view.php @@ -0,0 +1,1625 @@ +prepare( + 'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, + s.name AS staff_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id + WHERE t.ticket_number = ?' +); +$stmt->execute([$number]); +$ticket = $stmt->fetch(); + +if (!$ticket) { + flash('error', 'Dieses Ticket wurde nicht gefunden.'); + redirect('/staff/tickets.php'); +} + +if ($myDeptIds !== null && !in_array((int) $ticket['department_id'], $myDeptIds, true)) { + flash('error', 'Sie haben keinen Zugriff auf Tickets dieser Abteilung.'); + redirect('/staff/tickets.php'); +} + +// Zusammengeführte Tickets die in dieses hier führen +$mergedFrom = []; +if (empty($ticket['merged_into'])) { + $mfStmt = $pdo->prepare( + 'SELECT ticket_number, subject, requester_name, created_at FROM tickets WHERE merged_into = ? ORDER BY created_at DESC' + ); + $mfStmt->execute([$ticket['id']]); + $mergedFrom = $mfStmt->fetchAll(); +} + +// Wenn dieses Ticket selbst zusammengeführt wurde: Banner anzeigen (NICHT umleiten) +$mergedIntoBanner = null; +if (!empty($ticket['merged_into'])) { + $stmt = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id = ?'); + $stmt->execute([$ticket['merged_into']]); + $mergedIntoBanner = $stmt->fetchColumn() ?: null; +} + +// Ticket-Sperre: verhindert, dass zwei Mitarbeiter unbemerkt gleichzeitig am selben Ticket +// arbeiten (geteilte Logik mit der Mobile-App, siehe ticket_locked_by_other() in +// includes/functions.php). Eine Sperre gilt als abgelaufen, wenn sie laenger als +// TICKET_LOCK_TIMEOUT_MINUTES nicht mehr aufgefrischt wurde (z.B. weil der Tab einfach +// geschlossen wurde). +$lockedByOther = null; +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $lockedByName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo); + if ($lockedByName) { + flash('error', "Dieses Ticket wird gerade von \"{$lockedByName}\" bearbeitet und kann nicht gleichzeitig geöffnet werden. Bitte warten Sie, bis die Bearbeitung abgeschlossen ist, oder bitten Sie {$lockedByName}, das Ticket freizugeben."); + flash_locked_ticket($number, $lockedByName); + redirect('/staff/tickets.php'); + } else { + ticket_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo); + $ticket['locked_by'] = $staff['id']; + + // Automatische Zeiterfassung: Bearbeitung dieses Tickets beginnt (oder wird + // aufgefrischt, falls schon eine Sitzung läuft) – jede andere noch offene + // Sitzung desselben Mitarbeiters wird abgeschlossen, da er nun hier weiterarbeitet. + start_or_touch_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); + finalize_other_open_sessions($pdo, (int) $staff['id'], (int) $ticket['id']); + } +} +$lockedByOther = null; // wird ab jetzt nicht mehr gebraucht (kein Banner-Modus mehr) + +// Für den "Zeiterfassung läuft"-Hinweis in der Titelzeile +$myWorkSessionStmt = $pdo->prepare('SELECT started_at FROM ticket_work_sessions WHERE ticket_id = ? AND staff_id = ?'); +$myWorkSessionStmt->execute([$ticket['id'], $staff['id']]); +$myWorkSessionStartedAt = $myWorkSessionStmt->fetchColumn() ?: null; + +$collabStmt = $pdo->prepare('SELECT * FROM ticket_collaborators WHERE ticket_id = ? ORDER BY created_at'); +$collabStmt->execute([$ticket['id']]); +$collaborators = $collabStmt->fetchAll(); + +$departments = departments_for_select($pdo, [(int) $ticket['department_id']]); +$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll(); +$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); +$teams = $pdo->query('SELECT id, name FROM teams WHERE is_active = 1 ORDER BY name')->fetchAll(); +$cannedResponses = $pdo->query('SELECT id, title, body FROM canned_responses ORDER BY title')->fetchAll(); + +$helpTopicName = null; +if (!empty($ticket['help_topic_id'])) { + $stmt = $pdo->prepare('SELECT name FROM help_topics WHERE id = ?'); + $stmt->execute([$ticket['help_topic_id']]); + $helpTopicName = $stmt->fetchColumn() ?: null; +} + +$organizationName = null; +if (!empty($ticket['user_id'])) { + $stmt = $pdo->prepare('SELECT o.name FROM users u JOIN organizations o ON o.id = u.organization_id WHERE u.id = ?'); + $stmt->execute([$ticket['user_id']]); + $organizationName = $stmt->fetchColumn() ?: null; +} + +$fieldStmt = $pdo->prepare( + "SELECT cf.label, cf.field_type, v.value FROM ticket_field_values v + JOIN custom_fields cf ON cf.id = v.custom_field_id + WHERE v.ticket_id = ? ORDER BY cf.sort_order, cf.label" +); +$fieldStmt->execute([$ticket['id']]); +$customFieldValues = $fieldStmt->fetchAll(); + +$tagStmt = $pdo->prepare( + "SELECT t.id, t.name, t.color FROM tags t + JOIN ticket_tags tt ON tt.tag_id = t.id + WHERE tt.ticket_id = ? ORDER BY t.name" +); +$tagStmt->execute([$ticket['id']]); +$ticketTags = $tagStmt->fetchAll(); +$allTags = $pdo->query('SELECT name FROM tags ORDER BY name')->fetchAll(PDO::FETCH_COLUMN); + +$errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // Kunden sperren (eigener Block ohne globale verify_csrf) + if (($_POST['action'] ?? '') === 'ban_user') { + verify_csrf(); + $banEmail = trim($_POST['ban_email'] ?? ''); + $banDomain = trim($_POST['ban_domain'] ?? ''); + $banReason = trim($_POST['ban_reason'] ?? ''); + $banned = []; + foreach (array_filter([$banEmail, $banDomain]) as $val) { + $type = str_contains($val, '@') ? 'email' : 'domain'; + $chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?'); + $chk->execute([$val]); + if (!$chk->fetchColumn()) { + $pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)') + ->execute([$type, $val, $banReason ?: null, $staff['id'], db_now()]); + $banned[] = $val; + audit_log($staff['id'], "Gesperrt: $val (Ticket #{$ticket['ticket_number']})"); + } + } + if ($banEmail) { + $affected = $pdo->prepare( + "UPDATE tickets SET status='closed', closed_at=?, updated_at=? WHERE requester_email=? AND status NOT IN ('closed')" + ); + $affected->execute([db_now(), db_now(), $banEmail]); + } + if ($banDomain) { + $affected2 = $pdo->prepare( + "UPDATE tickets SET status='closed', closed_at=?, updated_at=? WHERE " . sql_email_domain("requester_email") . "=? AND status NOT IN ('closed')" + ); + $affected2->execute([db_now(), db_now(), $banDomain]); + } + flash('success', 'Gesperrt und alle zugehörigen Tickets geschlossen.'); + redirect('/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number'])); + } + + verify_csrf(); + $action = $_POST['action'] ?? ''; + $now = db_now(); + + // Rechte gelten pro Abteilung des Tickets (Rolle des Mitarbeiters in dieser Abteilung). + $requirePerm = function (string $perm) use ($staff, $ticket, $number): void { + if (!has_permission($staff, $perm, (int) $ticket['department_id'])) { + flash('error', 'Für diese Aktion fehlt die erforderliche Berechtigung.'); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + }; + if ($action === 'reply') $requirePerm('reply_ticket'); + if ($action === 'note') $requirePerm('post_note'); + if ($action === 'delete_ticket') $requirePerm('delete_ticket'); + if ($action === 'mark_spam' || $action === 'restore_spam') $requirePerm('close_ticket'); + + if ($action === 'add_comment') { + $threadId = (int)($_POST['thread_id'] ?? 0); + $message = trim($_POST['comment_message'] ?? ''); + if ($threadId && $message !== '') { + // Verify thread belongs to this ticket + $chk = $pdo->prepare('SELECT id FROM ticket_threads WHERE id = ? AND ticket_id = ?'); + $chk->execute([$threadId, $ticket['id']]); + if ($chk->fetchColumn()) { + $pdo->prepare( + 'INSERT INTO ticket_thread_comments (thread_id, staff_id, message, created_at) VALUES (?,?,?,?)' + )->execute([$threadId, $staff['id'], htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), $now]); + } + } + redirect('/staff/ticket_view.php?n=' . urlencode($number) . '#msg-' . $threadId); + } + + if ($action === 'delete_comment') { + $commentId = (int)($_POST['comment_id'] ?? 0); + // Only allow deletion by the comment author or admin + $chk = $pdo->prepare( + 'SELECT tc.id FROM ticket_thread_comments tc + JOIN ticket_threads tt ON tt.id = tc.thread_id + WHERE tc.id = ? AND tt.ticket_id = ? AND (tc.staff_id = ? OR ? = \'admin\')' + ); + $chk->execute([$commentId, $ticket['id'], $staff['id'], $staff['role']]); + if ($chk->fetchColumn()) { + $pdo->prepare('DELETE FROM ticket_thread_comments WHERE id = ?')->execute([$commentId]); + } + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'reply' || $action === 'note') { + $raw = trim($_POST['message'] ?? ''); + // Variablen serverseitig auflösen (Fallback falls JS deaktiviert) + $raw = resolve_canned_vars($raw, $ticket, $staff); + $message = sanitize_staff_html($raw); + if (trim(strip_tags($message)) === '') { + $errors[] = 'Bitte geben Sie eine Nachricht ein.'; + } else { + $isNote = $action === 'note' ? 1 : 0; + $stmt = $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) + VALUES (?,?,?,?,?,?)' + ); + $stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote, $now]); + $threadId = (int) $pdo->lastInsertId(); + + if (!empty($_FILES['attachments']['name'][0])) { + $upResults = handle_ticket_uploads_multi($_FILES['attachments']); + foreach ($upResults as $up) { + if (isset($up['error'])) { + $errors[] = $up['error']; + } else { + $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)') + ->execute([$threadId, $up['original_name'], $up['stored_name'], $up['filesize'], $now]); + } + } + } + + if (!$isNote) { + $newStatus = $_POST['set_status'] ?: 'answered'; + $updateFirstResponse = $ticket['first_responded_at'] === null; + + // Auto-Zuweisung an antwortenden Mitarbeiter, falls Ticket noch niemandem zugewiesen ist + $autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === ''); + $stmt = $pdo->prepare( + 'UPDATE tickets SET status = ?, updated_at = ?' . + ($updateFirstResponse ? ', first_responded_at = ?' : '') . + ($autoAssign ? ', assigned_staff_id = ?' : '') . + ' WHERE id = ?' + ); + $args = []; + $args[] = $newStatus; + $args[] = $now; + if ($updateFirstResponse) $args[] = $now; + if ($autoAssign) $args[] = $staff['id']; + $args[] = $ticket['id']; + $stmt->execute($args); + if ($autoAssign) { + log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (erste Antwort)'); + } + + $signature = trim($staff['signature'] ?? ''); + if ($signature === '') { + $deptSigStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?'); + $deptSigStmt->execute([$ticket['department_id']]); + $signature = (string) $deptSigStmt->fetchColumn(); + } + $ccEmails = array_column($collaborators, 'email'); + notify_customer_new_reply($ticket, $message, $signature, $ccEmails); + save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket); + log_activity($pdo, $ticket['id'], $staff['id'], 'Öffentliche Antwort gesendet'); + // Browser-Push bei neuer Kundenantwort + if (file_exists(__DIR__ . '/../includes/push.php')) { + require_once __DIR__ . '/../includes/push.php'; + push_new_reply($ticket, $staff['name']); + } + } else { + save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket); + log_activity($pdo, $ticket['id'], $staff['id'], 'Interne Notiz hinzugefügt'); + } + + if (!$errors) { + // Sperre freigeben: die aktive Bearbeitung ist abgeschlossen, ein anderer + // Mitarbeiter kann das Ticket sofort uebernehmen, ohne auf das Timeout zu warten. + $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]); + finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); + webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => (bool) $isNote]); + flash('success', $isNote ? 'Notiz hinzugefügt.' : 'Antwort gesendet.'); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + } + } + + if ($action === 'mark_spam' || $action === 'restore_spam') { + $result = set_ticket_spam($pdo, $ticket, (int) $staff['id'], $action === 'restore_spam'); + + if ($result['error']) { + flash('error', $result['error']); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($result['failed']) { + $verb = $action === 'restore_spam' ? 'zurückgeholt' : 'als Spam markiert'; + flash('error', "Ticket wurde $verb, aber nicht alle Original-Mails konnten verschoben werden: " . implode(' | ', $result['failed'])); + } elseif ($action === 'restore_spam') { + flash('success', "Ticket #{$number} ist nicht mehr als Spam markiert. {$result['moved']} E-Mail(s) zurückverschoben" . ($result['already'] ? ", {$result['already']} bereits dort." : '.')); + } else { + flash('success', "Ticket #{$number} als Spam markiert. {$result['moved']} E-Mail(s) nach Spam/Junk verschoben" . ($result['already'] ? ", {$result['already']} bereits dort." : '.')); + } + redirect($action === 'restore_spam' ? '/staff/ticket_view.php?n=' . urlencode($number) : '/staff/tickets.php'); + } + + if ($action === 'delete_ticket') { + delete_ticket($pdo, $ticket, $staff['id']); + flash('success', "Ticket #{$number} wurde gelöscht."); + redirect('/staff/tickets.php'); + } + + if ($action === 'add_tag') { + $tagName = trim($_POST['tag_name'] ?? ''); + if ($tagName !== '') { + apply_tag_to_ticket($pdo, $ticket['id'], $tagName); + log_activity($pdo, $ticket['id'], $staff['id'], "Tag hinzugefügt: {$tagName}"); + } + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'remove_tag') { + $tagId = (int) ($_POST['tag_id'] ?? 0); + $pdo->prepare('DELETE FROM ticket_tags WHERE ticket_id = ? AND tag_id = ?')->execute([$ticket['id'], $tagId]); + log_activity($pdo, $ticket['id'], $staff['id'], 'Tag entfernt'); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'add_collaborator') { + $ccEmail = trim($_POST['collaborator_email'] ?? ''); + $ccName = trim($_POST['collaborator_name'] ?? ''); + if (!filter_var($ccEmail, FILTER_VALIDATE_EMAIL)) { + flash('error', 'Bitte eine gültige E-Mail-Adresse für den Kollaborator angeben.'); + } else { + $dupStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = ? AND email = ?'); + $dupStmt->execute([$ticket['id'], $ccEmail]); + if ((int) $dupStmt->fetchColumn() === 0) { + $pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, added_by, created_at) VALUES (?,?,?,?,?)') + ->execute([$ticket['id'], $ccEmail, $ccName ?: null, $staff['id'], db_now()]); + log_activity($pdo, $ticket['id'], $staff['id'], "Kollaborator hinzugefügt: {$ccEmail}"); + flash('success', 'Kollaborator hinzugefügt.'); + } + } + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'remove_collaborator') { + $ccId = (int) ($_POST['collaborator_id'] ?? 0); + $emailStmt = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE id = ? AND ticket_id = ?'); + $emailStmt->execute([$ccId, $ticket['id']]); + $ccEmail = $emailStmt->fetchColumn(); + $pdo->prepare('DELETE FROM ticket_collaborators WHERE id = ? AND ticket_id = ?')->execute([$ccId, $ticket['id']]); + if ($ccEmail) log_activity($pdo, $ticket['id'], $staff['id'], "Kollaborator entfernt: {$ccEmail}"); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'release_lock') { + $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]); + finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); + log_activity($pdo, $ticket['id'], $staff['id'], 'Sperre manuell aufgehoben'); + flash('success', 'Sperre aufgehoben.'); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + // Admin-Override: hebt die Sperre eines ANDEREN Mitarbeiters sofort auf, statt bis zum + // automatischen Timeout zu warten (siehe flash_locked_ticket() / TICKET_LOCK_TIMEOUT_MINUTES + // in includes/functions.php). Läuft bewusst über POST statt über die GET-Sperrprüfung + // oben, da genau die hier umgangen werden soll – daher die eigene Rechteprüfung hier. + if ($action === 'admin_force_unlock') { + if ($staff['role'] !== 'admin') { + flash('error', 'Nur Admins können fremde Sperren aufheben.'); + redirect('/staff/tickets.php'); + } + $unlockedFromName = $_POST['locked_by_name'] ?? 'einem anderen Mitarbeiter'; + $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]); + log_activity($pdo, $ticket['id'], $staff['id'], "Sperre von \"{$unlockedFromName}\" durch Admin aufgehoben"); + flash('success', "Sperre aufgehoben – Sie können das Ticket jetzt öffnen."); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'merge_ticket') { + $targetNumber = trim($_POST['target_ticket_number'] ?? ''); + $direction = $_POST['merge_direction'] ?? 'into'; // 'into' = dieses → Ziel + $stmt = $pdo->prepare( + 'SELECT t.*, p.name AS priority_name, d.name AS department_name, s.name AS staff_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id + WHERE t.ticket_number = ?' + ); + $stmt->execute([$targetNumber]); + $target = $stmt->fetch(); + + if (!$target) { + flash('error', 'Ziel-Ticket #' . e($targetNumber) . ' wurde nicht gefunden.'); + } elseif ($target['id'] === $ticket['id']) { + flash('error', 'Ein Ticket kann nicht mit sich selbst zusammengeführt werden.'); + } elseif (!empty($target['merged_into'])) { + flash('error', 'Das Ziel-Ticket wurde bereits in ein anderes Ticket zusammengeführt.'); + } elseif ($myDeptIds !== null && !in_array((int) $target['department_id'], $myDeptIds, true)) { + flash('error', 'Sie haben keinen Zugriff auf die Abteilung des Ziel-Tickets.'); + } else { + // Bestimme Quelle und Ziel je nach Richtung + if ($direction === 'from') { + // Ziel-Ticket wird in dieses zusammengeführt + [$src, $dst] = [$target, $ticket]; + } else { + // Dieses Ticket wird ins Ziel zusammengeführt (Standard) + [$src, $dst] = [$ticket, $target]; + } + + $pdo->beginTransaction(); + try { + // Threads verschieben + $pdo->prepare('UPDATE ticket_threads SET ticket_id = ? WHERE ticket_id = ?') + ->execute([$dst['id'], $src['id']]); + + // Collaborators übernehmen (cross-DB) + $isSqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite'; + $insertIgnore = $isSqlite ? 'INSERT OR IGNORE' : 'INSERT IGNORE'; + try { + $pdo->prepare( + "$insertIgnore INTO ticket_collaborators (ticket_id, user_id, created_at) + SELECT ?, user_id, created_at FROM ticket_collaborators WHERE ticket_id = ?" + )->execute([$dst['id'], $src['id']]); + } catch (Throwable $e) { /* Tabelle leer oder keine Collaborators */ } + + // Tags übernehmen + try { + $pdo->prepare( + "$insertIgnore INTO ticket_tags (ticket_id, tag_id) + SELECT ?, tag_id FROM ticket_tags WHERE ticket_id = ?" + )->execute([$dst['id'], $src['id']]); + } catch (Throwable $e) { /* ignorieren */ } + + // System-Notiz im Ziel-Ticket + $note = "Ticket #{$src['ticket_number']} wurde hierher zusammengeführt" + . " (Betreff: " . htmlspecialchars($src['subject'], ENT_QUOTES) . ")"; + $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) + VALUES (?,?,?,?,1,?)' + )->execute([$dst['id'], 'staff', $staff['id'], $note, $now]); + + // Quell-Ticket schließen + merged_into setzen + $pdo->prepare('UPDATE tickets SET status = ?, merged_into = ?, updated_at = ? WHERE id = ?') + ->execute(['closed', $dst['id'], $now, $src['id']]); + + // Ziel-Ticket aktualisieren (updated_at) + $pdo->prepare('UPDATE tickets SET updated_at = ? WHERE id = ?') + ->execute([$now, $dst['id']]); + + $pdo->commit(); + log_activity($pdo, $dst['id'], $staff['id'], "Ticket #{$src['ticket_number']} hierher zusammengeführt"); + log_activity($pdo, $src['id'], $staff['id'], "Zusammengeführt mit Ticket #{$dst['ticket_number']}"); + audit_log($staff['id'], "Merge: #{$src['ticket_number']} → #{$dst['ticket_number']}"); + flash('success', "Ticket #{$src['ticket_number']} wurde erfolgreich mit #{$dst['ticket_number']} zusammengeführt."); + redirect('/staff/ticket_view.php?n=' . urlencode($dst['ticket_number'])); + } catch (Throwable $e) { + $pdo->rollBack(); + flash('error', 'Fehler beim Zusammenführen: ' . $e->getMessage()); + } + } + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'update_props') { + $deptId = (int) $_POST['department_id']; + if ($myDeptIds !== null && !in_array($deptId, $myDeptIds, true) && $deptId !== (int) $ticket['department_id']) { + flash('error', 'Sie haben keinen Zugriff auf diese Abteilung.'); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + $prioId = (int) $_POST['priority_id']; + $status = $_POST['status']; + $assignedTo = $_POST['assigned_staff_id'] !== '' ? (int) $_POST['assigned_staff_id'] : null; + $teamId = ($_POST['team_id'] ?? '') !== '' ? (int) $_POST['team_id'] : null; + + if ($deptId !== (int) $ticket['department_id']) $requirePerm('transfer_ticket'); + if ($assignedTo !== ($ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id']) || $teamId !== ($ticket['team_id'] === null ? null : (int) $ticket['team_id'])) $requirePerm('assign_ticket'); + if ($status !== $ticket['status'] && in_array($status, ['resolved', 'closed'], true)) $requirePerm('close_ticket'); + + $prio = null; + foreach ($priorities as $p) { if ($p['id'] == $prioId) $prio = $p; } + + $sla = $prio ? calc_sla_dates($prio, $ticket['created_at'], $deptId, !empty($ticket['help_topic_id']) ? (int) $ticket['help_topic_id'] : null) : null; + + $resolvedAt = $ticket['resolved_at']; + $closedAt = $ticket['closed_at']; + if ($status === 'resolved' && $ticket['status'] !== 'resolved') $resolvedAt = $now; + if ($status === 'closed' && $ticket['status'] !== 'closed') $closedAt = $now; + + $stmt = $pdo->prepare( + 'UPDATE tickets SET department_id=?, priority_id=?, status=?, assigned_staff_id=?, team_id=?, + first_response_due_at=?, resolution_due_at=?, resolved_at=?, closed_at=?, updated_at=? WHERE id=?' + ); + $stmt->execute([ + $deptId, $prioId, $status, $assignedTo, $teamId, + $sla['first_response_due_at'] ?? $ticket['first_response_due_at'], + $sla['resolution_due_at'] ?? $ticket['resolution_due_at'], + $resolvedAt, $closedAt, $now, $ticket['id'], + ]); + + if ($assignedTo !== (int) ($ticket['assigned_staff_id'] ?? 0) && $assignedTo !== null) { + $agentName = ''; + foreach ($agents as $a) { if ($a['id'] == $assignedTo) $agentName = $a['name']; } + log_activity($pdo, $ticket['id'], $staff['id'], "Mitarbeiter zugewiesen: {$agentName}"); + } elseif ($assignedTo === null && !empty($ticket['assigned_staff_id'])) { + log_activity($pdo, $ticket['id'], $staff['id'], 'Zuweisung entfernt'); + } + if ($teamId !== null && $teamId !== (int) ($ticket['team_id'] ?? 0)) { + log_activity($pdo, $ticket['id'], $staff['id'], 'Team zugewiesen'); + notify_team_assignment($pdo, $ticket, $teamId, (int) $staff['id']); + } + if ($status !== $ticket['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => $ticket['status']]); + if ($assignedTo !== ($ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id'])) webhook_dispatch($pdo, 'ticket.assigned', (int) $ticket['id']); + if ($status !== $ticket['status']) { + [$newLabel] = status_label($status); + log_activity($pdo, $ticket['id'], $staff['id'], "Status geändert: {$newLabel}"); + } + if ($prioId != $ticket['priority_id']) { + log_activity($pdo, $ticket['id'], $staff['id'], 'Priorität geändert'); + } + if ($deptId != $ticket['department_id']) { + log_activity($pdo, $ticket['id'], $staff['id'], 'Abteilung geändert'); + } + + flash('success', 'Ticket aktualisiert.'); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + $stmt = $pdo->prepare( + 'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, + s.name AS staff_name + FROM tickets t + JOIN priorities p ON p.id=t.priority_id + JOIN departments d ON d.id=t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id + WHERE t.ticket_number = ?' + ); + $stmt->execute([$number]); + $ticket = $stmt->fetch(); +} + + if ($action === 'log_time') { + $minutes = max(1, (int) ($_POST['minutes'] ?? 0)); + $note = trim($_POST['time_note'] ?? ''); + $pdo->prepare('INSERT INTO ticket_time_entries (ticket_id,staff_id,minutes,note,logged_at) VALUES (?,?,?,?,?)') + ->execute([$ticket['id'], $staff['id'], $minutes, $note ?: null, db_now()]); + log_activity($pdo, $ticket['id'], $staff['id'], "Zeit erfasst: {$minutes} Min." . ($note ? " ({$note})" : '')); + flash('success', 'Zeit erfasst.'); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'delete_time_entry') { + $pdo->prepare('DELETE FROM ticket_time_entries WHERE id=? AND staff_id=?') + ->execute([(int)$_POST['entry_id'], $staff['id']]); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + +// Thread (inkl. interner Notizen) laden +$stmt = $pdo->prepare( + "SELECT tt.*, s.name AS staff_name, u.name AS user_name + FROM ticket_threads tt + LEFT JOIN staff s ON s.id = tt.staff_id + LEFT JOIN users u ON u.id = tt.user_id + WHERE tt.ticket_id = ? + ORDER BY tt.created_at ASC" +); +$stmt->execute([$ticket['id']]); +$thread = $stmt->fetchAll(); +$attStmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE thread_id = ?'); +$commentStmt = $pdo->prepare( + 'SELECT tc.*, s.name AS staff_name, s.avatar_path + FROM ticket_thread_comments tc + JOIN staff s ON s.id = tc.staff_id + WHERE tc.thread_id = ? + ORDER BY tc.created_at ASC' +); + +$logStmt = $pdo->prepare( + "SELECT al.*, s.name AS staff_name FROM activity_log al LEFT JOIN staff s ON s.id=al.staff_id + WHERE al.ticket_id=? ORDER BY al.created_at DESC LIMIT 20" +); +$logStmt->execute([$ticket['id']]); +$activity = $logStmt->fetchAll(); + +// Zeiterfassung laden +$timeEntries = $pdo->prepare( + "SELECT te.*, s.name AS staff_name FROM ticket_time_entries te + JOIN staff s ON s.id=te.staff_id WHERE te.ticket_id=? ORDER BY te.logged_at DESC" +); +$timeEntries->execute([$ticket['id']]); +$timeEntries = $timeEntries->fetchAll(); +$totalMinutes = array_sum(array_column($timeEntries, 'minutes')); + +$mentionNames = $pdo->query('SELECT name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll(PDO::FETCH_COLUMN); + +$pageTitle = "Ticket #{$ticket['ticket_number']}"; +$activePage = 'tickets'; +require __DIR__ . '/_header.php'; +[$statusLabel, $statusColor] = status_label($ticket['status']); +$isOverdue = $ticket['resolution_due_at'] && strtotime($ticket['resolution_due_at']) < time() && !in_array($ticket['status'], ['resolved','closed']); +// Bewertung laden +$ticketRating = null; +if (in_array($ticket['status'], ['resolved','closed'])) { + $rStmt = $pdo->prepare('SELECT * FROM ticket_ratings WHERE ticket_id=?'); + $rStmt->execute([$ticket['id']]); + $ticketRating = $rStmt->fetch(); +} +?> + + + + +'🌐 Web','email'=>'📧 E-Mail','phone'=>'📞 Telefon','staff'=>'🖥 Intern','chat'=>'💬 Live-Chat']; +$sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source']; +?> + + +
+ + +
+
+

+
+ # + + 🚫 SPAM + + ÜBERFÄLLIG + + ⏱ Zeiterfassung läuft + + +
+
+
+ +
+ + + +
+ +
+ + + +
+ + 🖨 Drucken +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + +
Absender: <>
Erstellt:
Abteilung:
Hilfethema:
+
+
+ + + + + + + + + + + + + + + + + + + +
Status:
Priorität:
Zugewiesen:
Fällig:
+
+
+ +
+ + +
+ + + + + + + +
+ ⚠️ +
+ Dieses Ticket wurde zusammengeführt. + Der Verlauf wurde in + # + verschoben. Dieses Ticket ist jetzt schreibgeschützt. +
+
+ + + +
+
📥 Zusammengeführte Tickets
+
+ + + # – + + +
+
+ + +
+
+ + !$m['is_internal_note']); + ?> +
+
💬 Live-Chat-Protokoll
+
+ ]*>/i', $body); + $attStmt->execute([$m['id']]); + $msgAttachments = $attStmt->fetchAll(); + ?> +
+
+ · +
+
+ + + + + +
+ +
+ + 📎 + +
+ +
+ + +
Noch keine Nachrichten.
+ +
+
+ + + execute([$msg['id']]); + $attachments = $attStmt->fetchAll(); + $isStaff = $msg['author_type'] === 'staff'; + $authorName = $isStaff + ? ($msg['staff_name'] ?? 'Mitarbeiter') + : ($msg['user_name'] ?? $ticket['requester_name']); + ?> +
+ +
+
+
+ +
+
+
+ + + Interne Notiz + +
+
+
+
+ +
+ +
+ +
+ + ... Linien) + $isHtml = preg_match('/<[a-zA-Z][^>]*>/i', $msgBody); + $msgId = $msg['id']; + ?> + +
+ +
+ + + + + +
+ + ]*)>(.*?)<\/blockquote>/is', + '', + $safe + ); + ?> + + + 1) { + foreach ($parts as $p) { + $p = trim($p); + if ($p !== '') echo '

' . nl2br(htmlspecialchars($p, ENT_QUOTES, 'UTF-8')) . '

'; + } + } else { + echo '

' . nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8')) . '

'; + } + ?> + +
+
+ +
+ + in_array(strtolower(pathinfo($a['original_name'],PATHINFO_EXTENSION)), $imgExts)); + $otherAtts = array_filter($attachments, fn($a) => !in_array(strtolower(pathinfo($a['original_name'],PATHINFO_EXTENSION)), $imgExts)); + ?> + +
+ + + <?= e($a['original_name']) ?> + + +
+ + + +
+ '📄', + in_array($ext,['doc','docx']) => '📝', + in_array($ext,['xls','xlsx','csv']) => '📊', + in_array($ext,['zip','rar','tar','gz']) => '📦', + in_array($ext,['mp4','mov','avi','mkv'])=> '🎥', + in_array($ext,['mp3','wav','ogg']) => '🎵', + default => '📎', + }; + $sizeFmt = $a['filesize'] > 1048576 + ? round($a['filesize']/1048576,1).' MB' + : round($a['filesize']/1024).' KB'; + ?> + + + + + + Öffnen + + + +
+ +
+ + execute([$msg['id']]); + $threadComments = $commentStmt->fetchAll(); + ?> + +
+ + +
+
+ +
+
+
+ + + +
+ + + + +
+ +
+
+
+
+ + + + +
+ +
+
+ +
+ + + + +
+

Antworten / Notiz

+
+ + + + $ticket['requester_name'] ?? '', + 'vorname' => explode(' ', trim($ticket['requester_name'] ?? ''))[0] ?? '', + 'email' => $ticket['requester_email'] ?? '', + 'ticketnummer' => '#' . ($ticket['ticket_number'] ?? ''), + 'betreff' => $ticket['subject'] ?? '', + 'abteilung' => $ticket['department_name'] ?? '', + 'prioritaet' => $ticket['priority_name'] ?? '', + 'status' => $ticket['status'] ?? '', + 'mitarbeiter' => $ticket['staff_name'] ?? $staff['name'] ?? '', + 'datum' => date('d.m.Y'), + 'uhrzeit' => date('H:i'), + 'signatur' => $staff['signature'] ?? '', + ]; + ?> + +
+ + +
+ + + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+

Aktivität

+ +
— :
+ +
+ +
+ + +
+ + +
+
+ Eigenschaften + +
+ + + +
+ +
+
+ + +
+ ['Offen'=>'open','Wartet auf Kunde'=>'pending','Beantwortet'=>'answered','Pausiert'=>'on_hold','Gelöst'=>'resolved','Geschlossen'=>'closed']] as $k=>$opts): ?> +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
SLA-Fristen
+
+
+
Erstreaktion
+ +
+
+
+
+
Lösung
+ +
+
+
+
+ +
+
Weitere Felder
+ +
+
+
+
+ +
+ +
+ + +
+
+ Kollaboratoren + +
+ + +
+
+
+
+
+
+ + + + +
+
+ + +

Erhalten eine Kopie jeder öffentlichen Antwort per E-Mail.

+ + +
+ + +
+ + Weitere Aktionen▾ + +
+
+
Tickets zusammenführen
+

Verlauf, Tags und Collaborators werden ins Ziel-Ticket verschoben. Dieses Ticket wird geschlossen.

+ + + + + + + + +
+ + + +
+ +
+ + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+
+ ⏱ Zeiterfassung + 0): ?> + + Gesamt: = 60 ? floor($totalMinutes/60).'h '.($totalMinutes%60).'min' : $totalMinutes.' Min.' ?> + + +
+
+ + + + + +
+ + +
+
+ = 60 ? floor($te['minutes']/60).'h '.($te['minutes']%60).'min' : $te['minutes'].' Min.' ?> + · + · +
+ +
+ + + + +
+ +
+ + +

Noch keine Zeit erfasst.

+ +
+ + + prepare('SELECT id FROM banlist WHERE value IN (?,?) LIMIT 1'); + $bs->execute([$requesterEmail, $requesterDomain]); + $alreadyBanned = (bool)$bs->fetchColumn(); + } + ?> + (int) $ticket['id'], 'ticket' => $ticket, 'staff' => $staff]) /* Plugins liefern fertiges HTML (vertrauenswürdiger Code) */ ?> + +
+
Absender
+
+ + ⛔ Gesperrt + +
+ Absender sperren… +
+ + + + + + +
+
+ +
+ + + + +
+
Kundenbewertung
+ +
+
+ +
+ /5 +
+ +

""

+ +
+ +

Noch keine Bewertung abgegeben.

+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/DeskHub-Webseite/staff/tickets.php b/DeskHub-Webseite/staff/tickets.php new file mode 100644 index 00000000..16c54ce5 --- /dev/null +++ b/DeskHub-Webseite/staff/tickets.php @@ -0,0 +1,634 @@ +query('SELECT * FROM departments ORDER BY name')->fetchAll(); +$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll(); +$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); + +$bulkErrors = []; + +// --------------------------------------------------------------- +// Gespeicherte Ansichten: speichern / löschen +// --------------------------------------------------------------- +if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['queue_action'] ?? '', ['save', 'delete'], true)) { + verify_csrf(); + $back = '/staff/tickets.php' . (($_POST['return_query'] ?? '') !== '' ? '?' . ltrim((string) $_POST['return_query'], '?') : ''); + try { + if ($_POST['queue_action'] === 'save') { + parse_str((string) ($_POST['return_query'] ?? ''), $currentGet); + queue_create($pdo, (int) $staff['id'], (string) ($_POST['queue_name'] ?? ''), queue_filters_from_web($currentGet), !empty($_POST['queue_shared'])); + flash('success', 'Ansicht gespeichert.'); + } else { + $qid = (int) ($_POST['queue_id'] ?? 0); + $row = $pdo->prepare('SELECT * FROM saved_queues WHERE id = ?'); + $row->execute([$qid]); + $row = $row->fetch(); + if ($row && queue_can_edit($row, $staff)) { + $pdo->prepare('DELETE FROM saved_queues WHERE id = ?')->execute([$qid]); + flash('success', 'Ansicht gelöscht.'); + } + $back = '/staff/tickets.php'; + } + } catch (AdminModelException $e) { + flash('error', $e->getMessage()); + } + redirect($back); +} + +// --------------------------------------------------------------- +// Sammelaktionen +// --------------------------------------------------------------- +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== '') { + verify_csrf(); + $ticketIds = array_filter(array_map('intval', $_POST['ticket_ids'] ?? [])); + $bulkAction = $_POST['bulk_action']; + + if (!$ticketIds) { + $bulkErrors[] = 'Bitte wählen Sie mindestens ein Ticket aus.'; + } else { + // Zugriff prüfen: nur Tickets aus erlaubten Abteilungen dürfen verändert werden + $placeholders = implode(',', array_fill(0, count($ticketIds), '?')); + [$deptFilter, $deptParams] = dept_filter_sql($myDeptIds, 'department_id'); + $stmt = $pdo->prepare("SELECT * FROM tickets WHERE id IN ({$placeholders})" . $deptFilter); + $stmt->execute(array_merge($ticketIds, $deptParams)); + $allowedTickets = $stmt->fetchAll(); + + if (count($allowedTickets) < count($ticketIds)) { + $bulkErrors[] = 'Einige ausgewählte Tickets gehören zu Abteilungen, auf die Sie keinen Zugriff haben, und wurden übersprungen.'; + } + + $now = db_now(); + foreach ($allowedTickets as $t) { + if ($bulkAction === 'assign') { + $newAgent = $_POST['bulk_assigned_staff_id'] !== '' ? (int) $_POST['bulk_assigned_staff_id'] : null; + $pdo->prepare('UPDATE tickets SET assigned_staff_id=?, updated_at=? WHERE id=?')->execute([$newAgent, $now, $t['id']]); + log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Mitarbeiter zugewiesen'); + } elseif ($bulkAction === 'priority') { + $newPrio = (int) $_POST['bulk_priority_id']; + $pdo->prepare('UPDATE tickets SET priority_id=?, updated_at=? WHERE id=?')->execute([$newPrio, $now, $t['id']]); + log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Priorität geändert'); + } elseif ($bulkAction === 'status') { + $newStatus = $_POST['bulk_status']; + $resolvedAt = $newStatus === 'resolved' ? $now : $t['resolved_at']; + $closedAt = $newStatus === 'closed' ? $now : $t['closed_at']; + $pdo->prepare('UPDATE tickets SET status=?, resolved_at=?, closed_at=?, updated_at=? WHERE id=?') + ->execute([$newStatus, $resolvedAt, $closedAt, $now, $t['id']]); + log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Status geändert'); + } elseif ($bulkAction === 'delete') { + delete_ticket($pdo, $t, $staff['id']); + } elseif ($bulkAction === 'ban') { + $email = $t['requester_email']; + $domain = $email ? substr($email, strpos($email,'@')+1) : ''; + if ($email) { + $chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?'); + $chk->execute([$email]); + if (!$chk->fetchColumn()) { + $pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)') + ->execute(['email', $email, 'Bulk-Sperre', $staff['id'], $now]); + } + // Alle Tickets dieses Absenders schließen + $pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE requester_email=? AND status NOT IN ('closed')") + ->execute([$now, $now, $email]); + } + } + } + + if (!$bulkErrors) { + $verb = ['assign' => 'zugewiesen', 'priority' => 'Priorität geändert', 'status' => 'Status geändert', 'delete' => 'gelöscht'][$bulkAction] ?? 'aktualisiert'; + flash('success', count($allowedTickets) . ' Ticket(s) ' . $verb . '.'); + } else { + foreach ($bulkErrors as $e) flash('error', $e); + } + redirect('/staff/tickets.php?' . http_build_query($_GET)); + } +} + +$filterDept = $_GET['department_id'] ?? ''; +$filterStatus = $_GET['status'] ?? ''; +$filterPrio = $_GET['priority_id'] ?? ''; +$filterAssign = $_GET['assigned'] ?? ''; +$filterTag = $_GET['tag_id'] ?? ''; +$filterSpam = $_GET['spam'] ?? ''; +$filterSla = $_GET['sla'] ?? ''; + +$search = trim($_GET['q'] ?? ''); +$activeQuick = $_GET['quick'] ?? ''; // 'me', 'unassigned', 'overdue', 'highprio' + +$where = []; +$params = []; + +// Spam-Tickets sind standardmäßig aus der normalen Ticketliste ausgeblendet. +if ($filterSpam === '1') { + $where[] = 't.is_spam = 1'; +} else { + $where[] = 't.is_spam = 0'; +} + +[$deptFilter, $deptParams] = dept_filter_sql($myDeptIds, 't.department_id'); +if ($deptFilter !== '') { $where[] = substr($deptFilter, 5); array_push($params, ...$deptParams); } + +// Schnellfilter-Tabs +if ($activeQuick === 'me') { + $where[] = 't.assigned_staff_id = ?'; $params[] = $staff['id']; + $where[] = "t.status NOT IN ('resolved','closed')"; +} elseif ($activeQuick === 'unassigned') { + $where[] = 't.assigned_staff_id IS NULL'; + $where[] = "t.status NOT IN ('resolved','closed')"; +} elseif ($activeQuick === 'overdue') { + $where[] = 't.resolution_due_at < ?'; $params[] = db_now(); + $where[] = "t.status NOT IN ('resolved','closed')"; +} elseif ($activeQuick === 'highprio') { + $where[] = 't.priority_id IN (SELECT id FROM (SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 2) AS top_priorities)'; + $where[] = "t.status NOT IN ('resolved','closed')"; +} + +if ($filterDept !== '') { $where[] = 't.department_id = ?'; $params[] = $filterDept; } +if ($filterStatus !== '' && !isset($_GET['tab'])) { $where[] = 't.status = ?'; $params[] = $filterStatus; } +if ($filterPrio !== '') { $where[] = 't.priority_id = ?'; $params[] = $filterPrio; } +if ($filterAssign === 'me') { $where[] = 't.assigned_staff_id = ?'; $params[] = $staff['id']; } +elseif ($filterAssign === 'unassigned') { $where[] = 't.assigned_staff_id IS NULL'; } +elseif ($filterAssign !== '') { $where[] = 't.assigned_staff_id = ?'; $params[] = $filterAssign; } +if ($filterTag !== '') { + $where[] = 't.id IN (SELECT ticket_id FROM ticket_tags WHERE tag_id = ?)'; + $params[] = $filterTag; +} +// SLA-Status: "at_risk" = Lösungsfrist läuft innerhalb der nächsten 4 Stunden ab, aber +// noch nicht überschritten. "overdue"/"on_time" ergänzen sich mit is_overdue (siehe +// cron/check_sla.php), berücksichtigen aber auch Tickets, deren Frist gerade erst +// (zwischen zwei Cron-Läufen) verstrichen ist, statt sich blind auf das Flag zu verlassen. +if ($filterSla !== '') { + $now = db_now(); + $riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600); + if ($filterSla === 'overdue') { + $where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; + $params[] = $now; + } elseif ($filterSla === 'at_risk') { + $where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; + array_push($params, $now, $riskThreshold); + } elseif ($filterSla === 'on_time') { + $where[] = "(t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')"; + $params[] = $riskThreshold; + } +} +if ($search !== '') { + $like = '%' . $search . '%'; + if (!empty($_GET['fulltext'])) { + $where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ? OR t.requester_name LIKE ? + OR t.id IN (SELECT ticket_id FROM ticket_threads WHERE message LIKE ?))'; + array_push($params, $like, $like, $like, $like, $like); + } else { + $where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ? OR t.requester_name LIKE ?)'; + array_push($params, $like, $like, $like, $like); + } +} +$activeTab = $_GET['tab'] ?? 'open'; + +// Bedingung: Ticket ist von gesperrtem Absender +$notBannedSql = "(t.requester_email NOT IN (SELECT value FROM banlist WHERE match_type='email') + AND " . sql_email_domain("t.requester_email") . " NOT IN (SELECT value FROM banlist WHERE match_type='domain'))"; + +if ($activeTab === 'banned') { + // Nur gesperrte Absender zeigen + $where[] = "(t.requester_email IN (SELECT value FROM banlist WHERE match_type='email') + OR " . sql_email_domain("t.requester_email") . " IN (SELECT value FROM banlist WHERE match_type='domain'))"; +} else { + // Gesperrte Absender in ALLEN anderen Tabs ausblenden + $where[] = $notBannedSql; + + if ($activeTab === 'open') { + $where[] = "t.status IN ('open','pending','answered')"; + } elseif ($activeTab === 'on_hold') { + $where[] = "t.status = 'on_hold'"; + } elseif ($activeTab === 'resolved') { + $where[] = "t.status = 'resolved'"; + } elseif ($activeTab === 'closed') { + $where[] = "t.status = 'closed'"; + } elseif ($activeTab === 'all') { + // kein weiterer Filter + } else { + $where[] = "t.status IN ('open','pending','answered')"; + } +} + +$sql = "SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, s.name AS staff_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id"; +if ($where) { + $sql .= ' WHERE ' . implode(' AND ', $where); +} +$page = max(1, (int)($_GET['page'] ?? 1)); +$perPage = 50; +$offset = ($page - 1) * $perPage; + +// Gesamtanzahl +$countSql = "SELECT COUNT(*) FROM tickets t JOIN priorities p ON p.id = t.priority_id JOIN departments d ON d.id = t.department_id LEFT JOIN staff s ON s.id = t.assigned_staff_id" + . ($where ? ' WHERE ' . implode(' AND ', $where) : ''); +$countStmt = $pdo->prepare($countSql); +$countStmt->execute($params); +$totalCount = (int)$countStmt->fetchColumn(); +$totalPages = max(1, (int)ceil($totalCount / $perPage)); + +$sql .= ' ORDER BY t.created_at DESC LIMIT ' . $perPage . ' OFFSET ' . $offset; + +$stmt = $pdo->prepare($sql); +$stmt->execute($params); +$tickets = $stmt->fetchAll(); + +// Tags je angezeigtem Ticket nachladen (eine Abfrage für alle sichtbaren Tickets) +$ticketTags = []; +if ($tickets) { + $ids = array_column($tickets, 'id'); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $tagStmt = $pdo->prepare( + "SELECT tt.ticket_id, tg.id, tg.name, tg.color FROM ticket_tags tt + JOIN tags tg ON tg.id = tt.tag_id WHERE tt.ticket_id IN ({$placeholders})" + ); + $tagStmt->execute($ids); + foreach ($tagStmt->fetchAll() as $row) { + $ticketTags[$row['ticket_id']][] = $row; + } +} +$allTags = $pdo->query('SELECT * FROM tags ORDER BY name')->fetchAll(); + +// Sichtbare Abteilungen für das Filter-Dropdown auf Zugriffsrechte beschränken +$visibleDepartments = $myDeptIds === null ? $departments : array_filter($departments, fn($d) => in_array((int) $d['id'], $myDeptIds, true)); + +// Schnellfilter-Zähler +$_stMe = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id=? AND status NOT IN ('resolved','closed')"); +$_stMe->execute([$staff['id']]); +$countMe = (int)$_stMe->fetchColumn(); +$countUnassigned= (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')")->fetchColumn(); +$_stOd = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolution_due_at < ? AND status NOT IN ('resolved','closed')"); +$_stOd->execute([db_now()]); +$countOverdue = (int)$_stOd->fetchColumn(); +$countHighPrio = (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND priority_id IN (SELECT id FROM (SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 2) AS top_priorities)")->fetchColumn(); + +// Live-Refresh der Ticketliste ohne Seiten-Reload (siehe + + +
+ + + +
+
+ Ansichten + Standard + $v !== '' && $v !== null && $v !== []); + ?> + + + +
+ + +
+ +
+ +
+ Aktuelle Filter als Ansicht speichern +
+ + + + + + +

Gespeichert werden:

+
+
+
+
+
+ +
+ + +
+
+
+ + > +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+ +
+ + ↓ CSV exportieren + + + ✕ Filter zurücksetzen + +
+
+ + + +
+ + +
+ Auswahl: + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + + + + + diff --git a/DeskHub-Webseite/staff/time_entries.php b/DeskHub-Webseite/staff/time_entries.php new file mode 100644 index 00000000..0982c883 --- /dev/null +++ b/DeskHub-Webseite/staff/time_entries.php @@ -0,0 +1,120 @@ +query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll(); + +$where = ['te.logged_at BETWEEN ? AND ?']; +$params = [$filterFrom . ' 00:00:00', $filterTo . ' 23:59:59']; + +if ($filterStaff !== '') { + $where[] = 'te.staff_id = ?'; + $params[] = $filterStaff; +} + +$stmt = $pdo->prepare( + "SELECT te.*, s.name AS staff_name, t.ticket_number, t.subject + FROM ticket_time_entries te + JOIN staff s ON s.id = te.staff_id + JOIN tickets t ON t.id = te.ticket_id + WHERE " . implode(' AND ', $where) . " + ORDER BY te.logged_at DESC + LIMIT 500" +); +$stmt->execute($params); +$entries = $stmt->fetchAll(); + +// Summen je Mitarbeiter +$byStaff = []; +foreach ($entries as $e) { + $byStaff[$e['staff_name']] = ($byStaff[$e['staff_name']] ?? 0) + (int)$e['minutes']; +} + +$totalMinutes = array_sum(array_column($entries, 'minutes')); + +$pageTitle = 'Zeiterfassung'; +$activePage = 'time_entries'; +require __DIR__ . '/_header.php'; + +function fmt_minutes(int $m): string { + return floor($m / 60) . 'h ' . ($m % 60) . 'min'; +} +?> + +
+

⏱ Zeiterfassung

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ $mins): ?> +
+
+
+
+ +
+
+
Gesamt
+
+
+ + +
+ +
Keine Zeiteinträge im gewählten Zeitraum.
+ + + + + + + + + + + + + + + + + + + + + + + + +
DatumMitarbeiterTicketBetreffZeitNotiz
#
+ +
+ + diff --git a/DeskHub-Webseite/staff/update.php b/DeskHub-Webseite/staff/update.php new file mode 100644 index 00000000..75f64383 --- /dev/null +++ b/DeskHub-Webseite/staff/update.php @@ -0,0 +1,420 @@ + true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_TIMEOUT => $timeout, + CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_HTTPHEADER => $headers, + ]); + $data = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + return [$code, is_string($data) ? $data : '', $err]; +} + +// ── ZIP installieren ───────────────────────────────────────────────────────── +function do_install(string $zipPath): array +{ + $zip = new ZipArchive(); + $openResult = $zip->open($zipPath); + if ($openResult !== true) { + return ['ok' => false, 'error' => 'ZIP konnte nicht geöffnet werden (Code: ' . $openResult . ', Datei: ' . $zipPath . ', Größe: ' . filesize($zipPath) . ')']; + } + + $rootDir = str_replace('/', DIRECTORY_SEPARATOR, realpath(__DIR__ . '/..')); + $protected = [ + 'config/config.php', + 'database/deskhub.sqlite', + 'database/deskhub.db', + '.env', + 'version.php', + 'staff/update.php', // Sich selbst nicht überschreiben während des Updates + 'includes/bootstrap.php', // Bootstrap nicht mid-request überschreiben + ]; + + // Gitea-Prefix erkennen (z.B. "deskhub/") + $prefix = ''; + for ($i = 0; $i < min($zip->numFiles, 5); $i++) { + $n = $zip->getNameIndex($i); + if (substr($n, -1) === '/' && substr_count(rtrim($n, '/'), '/') === 0) { + $prefix = $n; + break; + } + } + + $extracted = 0; + $failed = []; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + + // Verzeichnisse überspringen + if (substr($name, -1) === '/') continue; + + // Prefix entfernen + if ($prefix) { + if (strpos($name, $prefix) !== 0) continue; + $rel = substr($name, strlen($prefix)); + } else { + $rel = $name; + } + + if ($rel === '') continue; + + // Pfad prüfen: kein Verlassen des Installationsordners (Zip-Slip), keine absoluten Pfade + $relNorm = str_replace(chr(92), '/', $rel); + if ($relNorm[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $relNorm) || preg_match('#^[A-Za-z]:#', $relNorm) || strpos($relNorm, "\0") !== false) { + $failed[] = $rel; + continue; + } + // Die Plugin-Webseite und Entwickler-Werkzeuge gehören nicht auf Kunden-Installationen; Plugins, App-Dateien, Uploads und Backups gehören dem System und werden von Updates nie angefasst + if (preg_match('#^(plugins|uploads|backups|app_downloads)/#', $relNorm) || preg_match('#^(plugin-webseite|tools|tests|docs|desktop|mobile|theme-packages|vorschaubilder|\.gitea|\.github)/#', $relNorm)) continue; + + // Geschützte Dateien überspringen + $skip = false; + foreach ($protected as $p) { + if ($rel === $p || str_replace('/', DIRECTORY_SEPARATOR, $rel) === str_replace('/', DIRECTORY_SEPARATOR, $p)) { + $skip = true; + break; + } + } + if ($skip) continue; + + // Windows-Pfad + $dest = $rootDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel); + $dir = dirname($dest); + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $data = $zip->getFromIndex($i); + if ($data === false) continue; + + $written = file_put_contents($dest, $data); + if ($written !== false) { + $extracted++; + } else { + $failed[] = $rel; + } + } + + $zip->close(); + + if ($extracted === 0) { + $msg = 'Keine Dateien extrahiert!'; + if ($prefix) $msg .= ' Prefix: "' . $prefix . '"'; + if ($failed) $msg .= ' Fehlgeschlagen: ' . implode(', ', array_slice($failed, 0, 3)); + return ['ok' => false, 'error' => $msg]; + } + + return ['ok' => true, 'extracted' => $extracted, 'failed' => $failed]; +} + +// ── POST ───────────────────────────────────────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // CSRF prüfen ohne Token-Rotation (wegen Datei-Upload multipart) + $csrfToken = $_POST['csrf_token'] ?? ''; + if (!$csrfToken || !hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) { + http_response_code(403); + die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.'); + } + $action = $_POST['action'] ?? ''; + + // Manueller Upload + if ($action === 'upload_update') { + $f = $_FILES['update_zip'] ?? []; + if (($f['error'] ?? 99) !== UPLOAD_ERR_OK) { + $errors[] = 'Upload-Fehler Code: ' . ($f['error'] ?? 'unbekannt'); + } elseif (strtolower(pathinfo($f['name'] ?? '', PATHINFO_EXTENSION)) !== 'zip') { + $errors[] = 'Nur ZIP-Dateien erlaubt.'; + } else { + $tmp = $f['tmp_name']; + $bk = create_backup(); + $res = do_install($tmp); + if (!$res['ok']) { + $errors[] = $res['error']; + } else { + // Schema-Migrationen nach Update einspielen + require_once __DIR__ . '/../database/migrate.php'; + $mig = run_migrations(get_db()); + $migNote = $mig['applied'] > 0 ? " · {$mig['applied']} Migration(en) eingespielt" : ''; + if ($mig['errors']) $errors = array_merge($errors, $mig['errors']); + audit_log($staff['id'], "Update installiert: {$res['extracted']} Dateien{$migNote}"); + flash('success', "✅ {$res['extracted']} Dateien aktualisiert.{$migNote}" . ($bk['ok'] ? " Backup: {$bk['filename']}" : '')); + redirect('/staff/update.php'); + } + } + } + + // Nur die Datenbank auf den Stand des installierten Codes bringen (z. B. nach Datei-Upload per FTP) + if ($action === 'migrate_db') { + require_once __DIR__ . '/../database/migrate.php'; + $pending = migrations_pending($pdo); + if (!$pending) { + flash('success', 'Die Datenbank ist bereits auf dem aktuellen Stand.'); + redirect('/staff/update.php'); + } + $bk = create_backup(); + if (!$bk['ok']) { + $errors[] = 'Backup fehlgeschlagen – die Datenbank wurde nicht verändert: ' . ($bk['error'] ?? 'unbekannter Fehler'); + } else { + $mig = run_migrations($pdo); + if ($mig['errors']) { + $errors = array_merge($errors, $mig['errors']); + audit_log($staff['id'], 'Datenbank-Update mit Fehlern: ' . implode('; ', $mig['errors'])); + } else { + audit_log($staff['id'], "Datenbank aktualisiert: {$mig['applied']} Migration(en)"); + flash('success', "✅ Datenbank aktualisiert ({$mig['applied']} Migration(en)). Backup: {$bk['filename']}"); + redirect('/staff/update.php'); + } + } + } + + // Auto-Download vom Update-Server. Ist ein Herausgeber-Schlüssel eingebaut, wird nur ein signiertes Release installiert. + if ($action === 'auto_update') { + $tagName = trim($_POST['tag_name'] ?? ''); + $headers = update_http_headers(); + if (!preg_match('/^v?\d+\.\d+\.\d+([\-+][0-9A-Za-z.\-]+)?$/', $tagName)) { + $errors[] = 'Kein gültiges Release gewählt.'; + } else { + $verifyRequired = update_signature_required(); + $zipUrl = UPDATE_REPO_API === '' ? '' : 'https://git.viper.ipv64.net/M_Viper/deskhub/archive/' . rawurlencode($tagName) . '.zip'; + $sigUrl = null; + [$relCode, $relBody] = update_download(UPDATE_REPO_API . '/releases/tags/' . rawurlencode($tagName), $headers, 10); + $release = $relCode === 200 ? (json_decode($relBody, true) ?: []) : []; + $assets = update_find_assets($release, $tagName); + if ($assets['zip']) { $zipUrl = $assets['zip']; $sigUrl = $assets['sig']; } + + if ($verifyRequired && (!$assets['zip'] || !$sigUrl)) { + $errors[] = 'Dieses Release ist nicht signiert (Anhänge deskhub-' . ltrim($tagName, 'vV') . '.zip und .zip.sig fehlen). Aus Sicherheitsgründen wird es nicht installiert.'; + } else { + [$code, $data, $err] = update_download($zipUrl, $headers, 120); + if ($err) { + $errors[] = 'cURL-Fehler: ' . $err . (stripos($err, 'certificate') !== false || stripos($err, 'SSL') !== false ? ' – Das Serverzertifikat konnte nicht geprüft werden. Bitte das CA-Zertifikatspaket des Servers aktualisieren (php.ini: curl.cainfo). Die Prüfung wird aus Sicherheitsgründen nicht abgeschaltet.' : ''); + } elseif ($code !== 200) { + $errors[] = "Download fehlgeschlagen (HTTP $code)."; + } else { + $tmp = tempnam(sys_get_temp_dir(), 'dh_') . '.zip'; + file_put_contents($tmp, $data); + $sigError = null; + if ($verifyRequired) { + [$sc, $sigBody] = update_download($sigUrl, $headers, 20); + $sigError = $sc === 200 ? update_verify_package($tagName, $tmp, $sigBody) : "Die Signatur konnte nicht geladen werden (HTTP $sc)."; + } + if ($sigError !== null) { + @unlink($tmp); + audit_log($staff['id'], "Update $tagName abgelehnt: $sigError"); + $errors[] = $sigError; + } else { + $bk = create_backup(); + $res = do_install($tmp); + @unlink($tmp); + if (!$res['ok']) { + $errors[] = $res['error']; + } else { + // Schema-Migrationen nach Update einspielen + require_once __DIR__ . '/../database/migrate.php'; + $mig = run_migrations(get_db()); + $migNote = $mig['applied'] > 0 ? " · {$mig['applied']} Migration(en) eingespielt" : ''; + if ($mig['errors']) $errors = array_merge($errors, $mig['errors']); + audit_log($staff['id'], "Auto-Update $tagName: {$res['extracted']} Dateien{$migNote}" . ($verifyRequired ? ' (Signatur geprüft)' : '')); + // Versionsnummer in version.php aktualisieren + $newVer = ltrim($tagName, 'v'); + $verFile = realpath(__DIR__ . '/../version.php'); + if ($verFile && $newVer) { + $vc = file_get_contents($verFile); + $vc = preg_replace("/define\('DESKHUB_VERSION',\s*'[^']+'\)/", "define('DESKHUB_VERSION', '$newVer')", $vc); + file_put_contents($verFile, $vc); + } + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + flash('success', "✅ Update auf $newVer: {$res['extracted']} Dateien." . ($verifyRequired ? ' Signatur geprüft.' : '') . ($bk['ok'] ? " Backup: {$bk['filename']}" : '')); + redirect('/staff/update.php'); + } + } + } + } + } + } +} + +// ── Releases laden ──────────────────────────────────────────────────────────── +$releases = []; +$relErr = ''; +[$code, $resp] = update_download(UPDATE_REPO_API . '/releases?limit=20', update_http_headers(), 8); +if ($code === 200) { + $all = json_decode($resp, true) ?: []; + usort($all, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name'])); + $releases = $all; +} else { + $relErr = "API HTTP $code" . (update_http_headers() === [] ? ' (Ist das Repository privat? Dann in config/config.php unter update → token ein Lese-Token eintragen.)' : ''); +} +$signedOnly = update_signature_required(); +// Version frisch aus Datei lesen +$verFile = realpath(__DIR__ . '/../version.php') ?: ''; +$verData = $verFile ? file_get_contents($verFile) : ''; +preg_match("/define\s*\(\s*'DESKHUB_VERSION'\s*,\s*'([^']+)'/", $verData, $vm); +$curVer = $vm[1] ?? DESKHUB_VERSION; +$latest = $releases[0] ?? null; +$hasUpdate = $latest && version_compare($latest['tag_name'], $curVer, '>'); + +require_once __DIR__ . '/../database/migrate.php'; +$pendingMigrations = migrations_pending($pdo); + +$pageTitle = 'System-Update'; +$activePage = 'update'; +require __DIR__ . '/_header.php'; +?> + +

🔄 System-Update

+ + +
+ + + +
+
+ Update verfügbar: Version + vom +
+
+ + + + +
+
+ + +
+
+
+
Datenbank
+ + ausstehende Datenbank-Änderung(en) +
Der Code ist neuer als die Datenbank. Vor dem Einspielen wird automatisch ein Backup erstellt.
+
Details
+ + ✓ Die Datenbank ist aktuell +
Alle bekannten Datenbank-Änderungen sind eingespielt.
+ +
+
+ + + +
+
+
+ +
+ +
+
Installierte Version
+
+
DeskHub Ticketsystem
+ + + +
+ +
+
Manuelles Update
+

ZIP-Datei von Gitea hochladen und installieren.

+
+ + + + +
+
+ +
+ + 1): ?> +
+

Bestimmte Version installieren

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

Hinweise

+ +
+ + + + + + diff --git a/DeskHub-Webseite/staff/update_download.php b/DeskHub-Webseite/staff/update_download.php new file mode 100644 index 00000000..cee40b00 --- /dev/null +++ b/DeskHub-Webseite/staff/update_download.php @@ -0,0 +1,24 @@ + 'Nur POST erlaubt.']); + exit; +} + +verify_csrf(); + +if (empty($_FILES['image'])) { + http_response_code(422); + echo json_encode(['error' => 'Bitte eine Bilddatei auswählen.']); + exit; +} + +$file = $_FILES['image']; +$allowedImageExts = array_values(array_intersect(get_allowed_extensions(), ['png', 'jpg', 'jpeg', 'gif'])); +$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); + +if ($file['error'] !== UPLOAD_ERR_OK) { + http_response_code(422); + echo json_encode(['error' => 'Der Upload ist fehlgeschlagen.']); + exit; +} +if (!in_array($ext, $allowedImageExts, true)) { + http_response_code(422); + echo json_encode(['error' => 'Nur Bilder erlaubt (' . implode(', ', array_map('strtoupper', $allowedImageExts)) . ').']); + exit; +} +if ($file['size'] > 5 * 1024 * 1024) { + http_response_code(422); + echo json_encode(['error' => 'Die Datei ist zu groß (max. 5 MB).']); + exit; +} + +$result = handle_ticket_upload($file); +if (isset($result['error'])) { + http_response_code(422); + echo json_encode(['error' => $result['error']]); + exit; +} + +$staff = current_staff(); +audit_log($staff['id'], "Bild hochgeladen: {$result['stored_name']}"); + +// verify_csrf() hat den Session-Token bereits rotiert – der neue Wert muss zurück an die Seite, +// sonst schlägt das nachfolgende Absenden des umgebenden Formulars (z.B. Signatur speichern) fehl. +echo json_encode([ + 'url' => base_url() . '/uploads/' . $result['stored_name'], + 'csrfToken' => csrf_token(), +]); diff --git a/DeskHub-Webseite/staff/webhooks.php b/DeskHub-Webseite/staff/webhooks.php new file mode 100644 index 00000000..01c9b8fb --- /dev/null +++ b/DeskHub-Webseite/staff/webhooks.php @@ -0,0 +1,160 @@ + $_POST['name'] ?? '', 'url' => $_POST['url'] ?? '', 'secret' => $_POST['secret'] ?? '', + 'is_active' => ($_POST['status'] ?? 'active') === 'active', 'events' => $_POST['events'] ?? [], + ]); + audit_log($staff['id'], ($action === 'create' ? 'Webhook erstellt: ' : 'Webhook aktualisiert: ') . trim($_POST['name'] ?? '')); + flash('success', $action === 'create' ? 'Webhook erstellt.' : 'Änderungen gespeichert.'); + redirect('/staff/webhooks.php'); + } catch (AdminModelException $e) { + $errors[] = $e->getMessage(); + } + } + if ($action === 'delete') { + $pdo->prepare('DELETE FROM webhooks WHERE id = ?')->execute([(int) $_POST['id']]); + audit_log($staff['id'], 'Webhook gelöscht: #' . (int) $_POST['id']); + flash('success', 'Webhook gelöscht.'); + redirect('/staff/webhooks.php'); + } + if ($action === 'test') { + $hook = $pdo->prepare('SELECT * FROM webhooks WHERE id = ?'); + $hook->execute([(int) $_POST['id']]); + if ($row = $hook->fetch()) { + $payload = ['event' => 'test', 'occurredAt' => gmdate('c'), 'message' => 'Test-Ereignis von DeskHub']; + [$status, $error] = webhook_deliver($row, $payload); + $error === null ? flash('success', "Test erfolgreich zugestellt (HTTP {$status}).") : flash('error', 'Test fehlgeschlagen: ' . $error); + } + redirect('/staff/webhooks.php?edit=' . (int) $_POST['id']); + } + if ($action === 'reactivate') { + $pdo->prepare('UPDATE webhooks SET is_active = 1, fail_count = 0 WHERE id = ?')->execute([(int) $_POST['id']]); + redirect('/staff/webhooks.php'); + } +} + +$isNew = isset($_GET['new']); +$editId = (int) ($_GET['edit'] ?? 0); +$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true)); +$hooks = $pdo->query('SELECT * FROM webhooks ORDER BY name')->fetchAll(); +$editHook = null; +if ($editId) { + $stmt = $pdo->prepare('SELECT * FROM webhooks WHERE id = ?'); + $stmt->execute([$editId]); + $editHook = $stmt->fetch() ?: null; +} + +$pageTitle = 'Webhooks'; +$activePage = 'webhooks'; +require __DIR__ . '/_header.php'; +?> + +
+ + + $_POST[$k] ?? $default; + $v = fn(string $k, string $col, $default = '') => $f($k, $editHook[$col] ?? $default); + $curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editHook['is_active'] ?? 1); + $curEvents = $_POST ? (array) ($_POST['events'] ?? []) : ($editHook ? explode(',', $editHook['events']) : array_keys($eventLabels)); +?> +

+ + — +

+ +
+ + + + + 'Webhook', 'events' => 'Ereignisse']); ?> + +
+

Ziel

+ + + + + + +

Jede Zustellung enthält den Header X-DeskHub-Signature: sha256=… (HMAC-SHA256 des Körpers mit diesem Geheimnis), damit dein System die Herkunft prüfen kann.

+ + +

+ Letzte Zustellung: + + ' . e($editHook['last_error']) . '' : '' ?> + 0 ? ' · ' . (int) $editHook['fail_count'] . ' Fehler in Folge (nach 10 wird der Webhook automatisch deaktiviert)' : '' ?> +

+ +
+ + + +
+ + Abbrechen +
+
+ +
+ (verwendet die gespeicherten Werte)
+ + + + +
+

Webhooks

+ Neuen Webhook hinzufügen +
+

DeskHub meldet Ereignisse (Ticket erstellt, beantwortet, Status geändert, zugewiesen) per HTTP-POST an die hinterlegten URLs – z. B. für Chat-Benachrichtigungen oder Ihr eigenes System. Tickets von außen anlegen: API-Schlüssel.

+ +
+ + + + + + + + + + + + + + + +
NameURLEreignisseStatusLetzte Zustellung
$eventLabels[$k] ?? $k, explode(',', $h['events'])))) ?>= 10 ? '
wegen Fehlern abgeschaltet
' : '' ?>
' . e($h['last_error']) . '' : ($h['last_status'] ? ' · HTTP ' . (int) $h['last_status'] : '') ?> + Bearbeiten + ·
· +
+
+
Noch keine Webhooks angelegt.
+
+ + diff --git a/DeskHub-Webseite/sw-staff.js b/DeskHub-Webseite/sw-staff.js new file mode 100644 index 00000000..be68598c --- /dev/null +++ b/DeskHub-Webseite/sw-staff.js @@ -0,0 +1,40 @@ +/** + * DeskHub Service Worker – Mitarbeiterbereich (scope: /staff/) + */ +const CACHE = 'deskhub-staff-v1'; +const OFFLINE_URL = '/offline.html'; +const PRECACHE = ['/offline.html', '/assets/css/style.css']; + +self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE))); + self.skipWaiting(); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', e => { + if (e.request.method !== 'GET') return; + + if (e.request.url.match(/\.(css|js|woff2?|png|svg|ico|webp)(\?.*)?$/)) { + e.respondWith( + caches.match(e.request).then(cached => + cached || fetch(e.request).then(resp => { + caches.open(CACHE).then(c => c.put(e.request, resp.clone())); + return resp; + }) + ) + ); + return; + } + + e.respondWith( + fetch(e.request).catch(() => caches.match(OFFLINE_URL)) + ); +}); diff --git a/DeskHub-Webseite/sw.js b/DeskHub-Webseite/sw.js new file mode 100644 index 00000000..5667cc6a --- /dev/null +++ b/DeskHub-Webseite/sw.js @@ -0,0 +1,42 @@ +/** + * DeskHub Service Worker – Kundenportal (scope: /) + */ +const CACHE = 'deskhub-customer-v1'; +const OFFLINE_URL = '/offline.html'; +const PRECACHE = ['/offline.html', '/assets/css/style.css']; + +self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE))); + self.skipWaiting(); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', e => { + if (e.request.method !== 'GET') return; + // Staff-Bereich nicht vom Kunden-SW abfangen + if (e.request.url.includes('/staff/')) return; + + if (e.request.url.match(/\.(css|js|woff2?|png|svg|ico|webp)(\?.*)?$/)) { + e.respondWith( + caches.match(e.request).then(cached => + cached || fetch(e.request).then(resp => { + caches.open(CACHE).then(c => c.put(e.request, resp.clone())); + return resp; + }) + ) + ); + return; + } + + e.respondWith( + fetch(e.request).catch(() => caches.match(OFFLINE_URL)) + ); +}); diff --git a/DeskHub-Webseite/tests/admin_models_integration.php b/DeskHub-Webseite/tests/admin_models_integration.php new file mode 100644 index 00000000..e3f2f24f --- /dev/null +++ b/DeskHub-Webseite/tests/admin_models_integration.php @@ -0,0 +1,205 @@ +errorCode() === $code, "Fehlercode {$code}, erhalten {$e->errorCode()}"); } +}; + +$pdo = get_db(); +$now = db_now(); +$dept1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Zweite Abteilung', '', $now]); +$dept2 = (int) $pdo->lastInsertId(); +$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn(); + +// ── E-Mail-Adressen ───────────────────────────────────────────────────────── +$id = email_address_save($pdo, 0, ['email' => 'Support@Example.Test', 'label' => 'Support', 'department_id' => $dept1, 'priority_id' => $prio, 'is_default' => true]); +$row = $pdo->query("SELECT * FROM email_addresses WHERE id = {$id}")->fetch(); +$check($row['email'] === 'support@example.test', 'E-Mail wird kleingeschrieben gespeichert'); +$check((int) $row['is_active'] === 1, 'neue Adresse ist aktiv'); + +$throws(fn() => email_address_save($pdo, 0, ['email' => 'kaputt', 'department_id' => $dept1]), 'INVALID_EMAIL'); +$throws(fn() => email_address_save($pdo, 0, ['email' => 'support@example.test', 'department_id' => $dept1]), 'DUPLICATE_EMAIL'); +$throws(fn() => email_address_save($pdo, 0, ['email' => 'x@example.test', 'department_id' => 9999]), 'INVALID_DEPARTMENT'); +$throws(fn() => email_address_save($pdo, 0, ['email' => 'imap@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => '']), 'IMAP_HOST_REQUIRED'); +$throws(fn() => email_address_save($pdo, 0, ['email' => 'imap@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => 'imap.example.test']), 'IMAP_PASSWORD_REQUIRED'); +$throws(fn() => email_address_save($pdo, 0, ['email' => 'smtp@example.test', 'department_id' => $dept1, 'smtp_enabled' => true, 'smtp_host' => '']), 'SMTP_HOST_REQUIRED'); + +// IMAP anlegen, Passwort bleibt bei leerem Feld erhalten, Deaktivieren pausiert den Abruf +email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => 'imap.example.test', 'imap_password' => 'geheim']); +$imap = email_address_imap($pdo, $id); +$check($imap !== null && (int) $imap['is_active'] === 1 && (int) $imap['department_id'] === $dept1, 'IMAP-Postfach angelegt und verknüpft'); +$check(decrypt_secret($imap['password_enc']) === 'geheim', 'IMAP-Passwort verschlüsselt gespeichert'); +email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test', 'imap_password' => '']); +$imap = email_address_imap($pdo, $id); +$check(decrypt_secret($imap['password_enc']) === 'geheim', 'leeres Passwortfeld lässt das Passwort unverändert'); +$check((int) $imap['department_id'] === $dept2 && $imap['host'] === 'imap2.example.test', 'Abteilung/Host werden am Postfach nachgezogen'); +email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'is_active' => false, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test']); +$check((int) email_address_imap($pdo, $id)['is_active'] === 0, 'deaktivierte Adresse pausiert den IMAP-Abruf'); +email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test']); +$check((int) $pdo->query("SELECT is_active FROM email_addresses WHERE id = {$id}")->fetchColumn() === 0, 'fehlendes is_active lässt den Status unverändert'); + +// SMTP pro Adresse: nur aktive Adresse mit aktiviertem SMTP wird verwendet; Daten bleiben beim Abschalten erhalten +email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'is_active' => true, 'smtp_enabled' => true, 'smtp_host' => 'smtp.example.test', 'smtp_port' => 465, 'smtp_encryption' => 'ssl', 'smtp_username' => 'u', 'smtp_password' => 'pw']); +$smtp = email_address_smtp_for($pdo, 'Support '); +$check($smtp !== null && $smtp['smtp_host'] === 'smtp.example.test' && (int) $smtp['smtp_port'] === 465, 'eigener SMTP wird für die Absenderadresse gefunden'); +$check($smtp !== null && decrypt_secret($smtp['smtp_password_enc']) === 'pw', 'SMTP-Passwort verschlüsselt gespeichert'); +$check(email_address_smtp_for($pdo, 'andere@example.test') === null, 'fremde Absenderadresse nutzt keinen eigenen SMTP'); +email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'smtp_enabled' => false]); +$check(email_address_smtp_for($pdo, 'support@example.test') === null, 'ausgeschalteter SMTP wird nicht verwendet'); +$kept = $pdo->query("SELECT smtp_host, smtp_password_enc FROM email_addresses WHERE id = {$id}")->fetch(); +$check($kept['smtp_host'] === 'smtp.example.test' && $kept['smtp_password_enc'] !== null, 'SMTP-Zugangsdaten bleiben beim Ausschalten erhalten'); + +// Absenderauswahl ignoriert deaktivierte Adressen +$pdo->prepare('UPDATE email_addresses SET is_active = 0 WHERE id = ?')->execute([$id]); +$check(resolve_from_address($dept2) === null, 'deaktivierte Adresse wird nicht als Absender gewählt'); +$pdo->prepare('UPDATE email_addresses SET is_active = 1 WHERE id = ?')->execute([$id]); +$check(resolve_from_address($dept2) !== null, 'aktive Adresse wird als Absender gewählt'); + +// Löschen entfernt das Postfach mit +$deleted = email_address_delete($pdo, $id); +$check($deleted === 'support@example.test', 'Löschen gibt die E-Mail zurück'); +$check(email_address_imap($pdo, $id) === null, 'Postfach wird mit der Adresse gelöscht'); + +// ── Abteilungen: Status ───────────────────────────────────────────────────── +$pdo->prepare('UPDATE departments SET is_active = 0 WHERE id = ?')->execute([$dept2]); +$select = array_map('intval', array_column(departments_for_select($pdo), 'id')); +$check(!in_array($dept2, $select, true), 'deaktivierte Abteilung fehlt in Auswahllisten'); +$select = array_map('intval', array_column(departments_for_select($pdo, [$dept2]), 'id')); +$check(in_array($dept2, $select, true), 'bereits gewählte deaktivierte Abteilung bleibt auswählbar'); +$check(active_department_id($pdo, $dept2) === $dept1, 'deaktivierte Abteilung weicht auf eine aktive aus'); +$check(active_department_id($pdo, $dept1) === $dept1, 'aktive Abteilung bleibt'); +$pdo->prepare('UPDATE departments SET is_active = 1 WHERE id = ?')->execute([$dept2]); + +// ── Hilfethemen: Hierarchie, Typ, Abteilungsstatus ────────────────────────── +$ins = $pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, is_public, parent_id, sort_order) VALUES (?,?,?,?,?,?,?)'); +$ins->execute(['Eltern', $dept1, $prio, 1, 1, null, 1]); +$parent = (int) $pdo->lastInsertId(); +$ins->execute(['Kind', $dept1, $prio, 1, 1, $parent, 2]); +$child = (int) $pdo->lastInsertId(); +$ins->execute(['Intern', $dept1, $prio, 1, 0, null, 3]); +$ins->execute(['In zweiter Abteilung', $dept2, $prio, 1, 1, null, 4]); +$paths = array_column(help_topic_options($pdo, true), 'path'); +$check(in_array('Eltern / Kind', $paths, true), 'Kind-Thema hat den Pfad "Eltern / Kind"'); +$check(!in_array('Intern', $paths, true), 'interne Themen sind für Kunden nicht sichtbar'); +$check(in_array('Intern', array_column(help_topic_options($pdo, false), 'path'), true), 'interne Themen sind für Mitarbeiter sichtbar'); +$check(array_search('Eltern', $paths, true) < array_search('Eltern / Kind', $paths, true), 'Eltern stehen vor ihren Kindern'); +$pdo->prepare('UPDATE departments SET is_active = 0 WHERE id = ?')->execute([$dept2]); +$check(!in_array('In zweiter Abteilung', array_column(help_topic_options($pdo, true), 'path'), true), 'Themen deaktivierter Abteilungen sind im Kundenportal verborgen'); +$pdo->prepare('UPDATE help_topics SET parent_id = ? WHERE id = ?')->execute([$child, $parent]); // Zyklus +$check(count(help_topic_options($pdo, false)) >= 1, 'zyklische Hierarchie lässt die Auflistung nicht hängen'); +$pdo->prepare('UPDATE help_topics SET parent_id = NULL WHERE id = ?')->execute([$parent]); +$pdo->prepare('UPDATE departments SET is_active = 1 WHERE id = ?')->execute([$dept2]); + +// ── Rollen pro Abteilung ──────────────────────────────────────────────────── +$pdo->prepare('INSERT INTO roles (name, permissions, is_active, created_at) VALUES (?,?,1,?)') + ->execute(['Nur lesen', json_encode(['reply_ticket' => false, 'delete_ticket' => false, 'close_ticket' => false]), $now]); +$readOnly = (int) $pdo->lastInsertId(); +$pdo->prepare('INSERT INTO roles (name, permissions, is_active, created_at) VALUES (?,?,1,?)') + ->execute(['Löscher', json_encode(['reply_ticket' => true, 'delete_ticket' => true]), $now]); +$deleter = (int) $pdo->lastInsertId(); +$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES ('Agent','agent@example.test','x','agent',?,?,1,?)") + ->execute([$readOnly, $dept1, $now]); +$agentId = (int) $pdo->lastInsertId(); +$pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,1,?)')->execute([$agentId, $dept2, $deleter]); +$agent = $pdo->query("SELECT * FROM staff WHERE id = {$agentId}")->fetch(); +$check(!has_permission($agent, 'reply_ticket'), 'Hauptrolle gilt ohne Abteilungsangabe'); +$check(!has_permission($agent, 'reply_ticket', $dept1), 'Hauptrolle gilt in der Hauptabteilung'); +$check(has_permission($agent, 'reply_ticket', $dept2) && has_permission($agent, 'delete_ticket', $dept2), 'eigene Rolle gilt in der erweiterten Abteilung'); +$pdo->prepare('UPDATE staff_department SET role_id = NULL WHERE staff_id = ? AND department_id = ?')->execute([$agentId, $dept2]); +// Cache der Funktion ist pro Prozess: neuen Mitarbeiter mit gleicher Konstellation prüfen +$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES ('Agent2','agent2@example.test','x','agent',?,?,1,?)") + ->execute([$readOnly, $dept1, $now]); +$agent2Id = (int) $pdo->lastInsertId(); +$pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,1,NULL)')->execute([$agent2Id, $dept2]); +$agent2 = $pdo->query("SELECT * FROM staff WHERE id = {$agent2Id}")->fetch(); +$check(!has_permission($agent2, 'reply_ticket', $dept2), 'ohne eigene Rolle gilt in der erweiterten Abteilung die Hauptrolle'); +$pdo->prepare('UPDATE roles SET is_active = 0 WHERE id = ?')->execute([$readOnly]); +$agent3 = $agent2; $agent3['id'] = 987654; // frischer Cache-Schlüssel, Rollen-Cache bleibt: neue Rolle prüfen +$pdo->prepare("INSERT INTO roles (name, permissions, is_active, created_at) VALUES ('Aus', ?, 0, ?)")->execute([json_encode(['reply_ticket' => false]), $now]); +$offId = (int) $pdo->lastInsertId(); +$agent3['role_id'] = $offId; $agent3['department_id'] = $dept1; +$check(has_permission($agent3, 'reply_ticket') === (bool) default_permissions('agent')['reply_ticket'], 'deaktivierte Rolle fällt auf Standardrechte zurück'); +$admin = ['id' => 1, 'role' => 'admin', 'role_id' => null, 'department_id' => $dept1]; +$check(has_permission($admin, 'delete_ticket', $dept2), 'Administrator darf immer'); + +// ── Team-Benachrichtigung ─────────────────────────────────────────────────── +$pdo->prepare('INSERT INTO teams (name, is_active, no_alerts, created_at) VALUES (?,?,?,?)')->execute(['Team A', 1, 1, $now]); +$teamQuiet = (int) $pdo->lastInsertId(); +$pdo->prepare('INSERT INTO teams (name, is_active, no_alerts, created_at) VALUES (?,?,?,?)')->execute(['Team B', 0, 0, $now]); +$teamOff = (int) $pdo->lastInsertId(); +$ticket = ['ticket_number' => 'T-1', 'subject' => 'Test']; +$check(notify_team_assignment($pdo, $ticket, $teamQuiet) === 0, 'Team mit deaktivierter Benachrichtigung sendet nichts'); +$check(notify_team_assignment($pdo, $ticket, $teamOff) === 0, 'deaktiviertes Team sendet nichts'); +$check(notify_team_assignment($pdo, $ticket, 99999) === 0, 'unbekanntes Team sendet nichts'); + +// ── Abteilung löschen: nie ein Absturz, immer eine verständliche Meldung ────────── +$deptCode = function (callable $fn): string { + try { $fn(); return 'OK'; } + catch (AdminModelException $e) { return $e->errorCode() . '|' . $e->getMessage(); } + catch (Throwable $e) { return 'ABSTURZ ' . get_class($e) . ': ' . $e->getMessage(); } +}; +$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Zum Löschen', '', $now]); +$delDept = (int) $pdo->lastInsertId(); +$pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, created_at) VALUES (?,?,?,1,?)')->execute(['Thema der Abteilung', $delDept, $prio, $now]); +$topicOfDept = (int) $pdo->lastInsertId(); +$res = $deptCode(fn() => department_delete($pdo, $delDept)); +$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, '1 Hilfethemen'), 'Abteilung mit Hilfethema: verständliche Meldung statt Absturz: ' . $res); +$check((int) $pdo->query("SELECT COUNT(*) FROM departments WHERE id = {$delDept}")->fetchColumn() === 1, 'die Abteilung bleibt dabei erhalten'); +$pdo->exec("DELETE FROM help_topics WHERE id = {$topicOfDept}"); + +$pdo->prepare('INSERT INTO email_addresses (email, label, department_id, is_active, created_at) VALUES (?,?,?,1,?)')->execute(['zum-loeschen@example.test', 'X', $delDept, $now]); +$mailId = (int) $pdo->lastInsertId(); +$res = $deptCode(fn() => department_delete($pdo, $delDept)); +$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, 'E-Mail-Adressen'), 'Abteilung mit E-Mail-Adresse wird nicht gelöscht (die Adresse ginge sonst verloren): ' . $res); +$pdo->exec("DELETE FROM email_addresses WHERE id = {$mailId}"); + +$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)')->execute(['DEL-1', 'x', 'k', 'k@example.test', $delDept, $prio, 'open', $now, $now]); +$ticketOfDept = (int) $pdo->lastInsertId(); +$res = $deptCode(fn() => department_delete($pdo, $delDept)); +$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, '1 Tickets'), 'Abteilung mit Tickets wird nicht gelöscht: ' . $res); +$pdo->exec("DELETE FROM tickets WHERE id = {$ticketOfDept}"); + +$check(str_starts_with($deptCode(fn() => department_delete($pdo, $dept1)), 'DEPARTMENT_IS_DEFAULT|') || str_starts_with($deptCode(fn() => department_delete($pdo, $dept1)), 'DEPARTMENT_IN_USE|'), 'Standardabteilung kann nicht gelöscht werden'); +$check(str_starts_with($deptCode(fn() => department_delete($pdo, 999999)), 'NOT_FOUND|'), 'unbekannte Abteilung liefert eine Meldung'); + +// zusätzliche Mitgliedschaft und Textbaustein: werden beim Löschen aufgeräumt +$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Mitglied','mitglied@example.test','x','agent',?,1,?)")->execute([$dept1, $now]); +$memberId = (int) $pdo->lastInsertId(); +$pdo->prepare('INSERT INTO staff_department (staff_id, department_id) VALUES (?,?)')->execute([$memberId, $delDept]); +$pdo->prepare('INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)')->execute(['Baustein', 'Text', $delDept, $now]); +$canId = (int) $pdo->lastInsertId(); +$res = $deptCode(fn() => department_delete($pdo, $delDept)); +$check($res === 'OK', 'leere Abteilung lässt sich löschen: ' . $res); +$check((int) $pdo->query("SELECT COUNT(*) FROM departments WHERE id = {$delDept}")->fetchColumn() === 0, 'Abteilung ist weg'); +$check((int) $pdo->query("SELECT COUNT(*) FROM staff_department WHERE department_id = {$delDept}")->fetchColumn() === 0, 'zusätzliche Mitgliedschaften wurden mit aufgeräumt'); +$check($pdo->query("SELECT department_id FROM canned_responses WHERE id = {$canId}")->fetchColumn() === null, 'Textbausteine der Abteilung gelten danach für alle'); + +// Mitarbeiter mit dieser Hauptabteilung blockieren das Löschen +$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Mit Mitarbeiter', '', $now]); +$withStaff = (int) $pdo->lastInsertId(); +$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Haupt','haupt@example.test','x','agent',?,1,?)")->execute([$withStaff, $now]); +$res = $deptCode(fn() => department_delete($pdo, $withStaff)); +$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, 'Hauptabteilung'), 'Mitarbeiter mit dieser Hauptabteilung blockieren das Löschen: ' . $res); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Admin models integration checks passed.\n"; diff --git a/DeskHub-Webseite/tests/admin_shared_logic.php b/DeskHub-Webseite/tests/admin_shared_logic.php new file mode 100644 index 00000000..405d0fcf --- /dev/null +++ b/DeskHub-Webseite/tests/admin_shared_logic.php @@ -0,0 +1,144 @@ +errorCode() === $code, "Fehlercode {$code}, erhalten {$e->errorCode()}"); } +}; + +$pdo = get_db(); +$now = db_now(); +$dept1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn(); +$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Agent','agent@example.test','x','agent',?,1,?)")->execute([$dept1, $now]); +$agentId = (int) $pdo->lastInsertId(); + +// ── Abteilungen ───────────────────────────────────────────────────────────── +$newDept = department_save($pdo, 0, ['name' => 'Vertrieb', 'email' => '', 'is_default' => false]); +$check($newDept > 0, 'Abteilung wird angelegt'); +$check((int) $pdo->query("SELECT is_active FROM departments WHERE id = {$newDept}")->fetchColumn() === 1, 'neue Abteilung ist aktiv'); +$throwsAm(fn() => department_save($pdo, 0, ['name' => '']), 'NAME_REQUIRED'); +$throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'is_default' => true, 'is_active' => false]), 'DEFAULT_INACTIVE'); +$throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'manager_id' => 99999]), 'INVALID_MANAGER'); +$throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'sla_plan_id' => 99999]), 'INVALID_SLA_PLAN'); +department_save($pdo, $newDept, ['name' => 'Vertrieb Nord', 'is_active' => false]); +department_save($pdo, $newDept, ['name' => 'Vertrieb Nord']); +$check((int) $pdo->query("SELECT is_active FROM departments WHERE id = {$newDept}")->fetchColumn() === 0, 'fehlendes is_active lässt den Abteilungsstatus unverändert'); +$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id) VALUES (?,?,?,?,?,?)') + ->execute(['AM-1', 'Test', 'X', 'x@example.test', $newDept, $prio]); +$throwsAm(fn() => department_delete($pdo, $newDept), 'DEPARTMENT_IN_USE'); + +// ── Hilfethemen ───────────────────────────────────────────────────────────── +$tParent = help_topic_save($pdo, 0, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio]); +$tChild = help_topic_save($pdo, 0, ['name' => 'B', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tParent]); +$throwsAm(fn() => help_topic_save($pdo, $tParent, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tChild]), 'INVALID_PARENT'); +$throwsAm(fn() => help_topic_save($pdo, $tParent, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tParent]), 'INVALID_PARENT'); +$throwsAm(fn() => help_topic_save($pdo, 0, ['name' => 'C', 'department_id' => 99999, 'priority_id' => $prio]), 'INVALID_DEPARTMENT'); +help_topic_delete($pdo, $tParent); +$check((int) $pdo->query("SELECT COALESCE(parent_id, 0) FROM help_topics WHERE id = {$tChild}")->fetchColumn() === 0, 'Unterthema rückt beim Löschen des Elternthemas nach oben'); + +// ── Teams ─────────────────────────────────────────────────────────────────── +$teamA = team_save($pdo, 0, ['name' => 'Alpha', 'members' => [$agentId, $agentId, 0]]); +$check((int) $pdo->query("SELECT COUNT(*) FROM team_members WHERE team_id = {$teamA}")->fetchColumn() === 1, 'Teammitglieder werden ohne Duplikate gespeichert'); +$throwsAm(fn() => team_save($pdo, 0, ['name' => 'alpha']), 'NAME_TAKEN'); +$throwsAm(fn() => team_save($pdo, 0, ['name' => 'Beta', 'lead_id' => 99999]), 'INVALID_LEAD'); +team_save($pdo, $teamA, ['name' => 'Alpha', 'is_active' => false, 'no_alerts' => true]); +team_save($pdo, $teamA, ['name' => 'Alpha 2']); +$row = $pdo->query("SELECT is_active, no_alerts FROM teams WHERE id = {$teamA}")->fetch(); +$check((int) $row['is_active'] === 0 && (int) $row['no_alerts'] === 1, 'fehlende Team-Schalter bleiben unverändert'); + +// ── SLA-Pläne ─────────────────────────────────────────────────────────────── +$throwsAm(fn() => sla_plan_save($pdo, 0, ['name' => 'Ungültig', 'response_hours' => 10, 'resolution_hours' => 5]), 'INVALID_HOURS'); +$planDept = sla_plan_save($pdo, 0, ['name' => 'Abteilungsplan', 'response_hours' => 2, 'resolution_hours' => 8]); +$planTopic = sla_plan_save($pdo, 0, ['name' => 'Themenplan', 'response_hours' => 1, 'resolution_hours' => 3]); +$throwsAm(fn() => sla_plan_save($pdo, 0, ['name' => 'abteilungsplan', 'response_hours' => 1, 'resolution_hours' => 2]), 'NAME_TAKEN'); +$pdo->prepare('UPDATE departments SET sla_plan_id = ? WHERE id = ?')->execute([$planDept, $dept1]); +$topicSla = help_topic_save($pdo, 0, ['name' => 'Mit Plan', 'department_id' => $dept1, 'priority_id' => $prio, 'sla_plan_id' => $planTopic]); +$prioRow = $pdo->query("SELECT * FROM priorities WHERE id = {$prio}")->fetch(); +$from = '2026-01-05 08:00:00'; +$none = calc_sla_dates($prioRow, $from); +$viaDept = calc_sla_dates($prioRow, $from, $dept1, null); +$viaTopic = calc_sla_dates($prioRow, $from, $dept1, $topicSla); +$check(strtotime($viaDept['resolution_due_at']) - strtotime($from) === 8 * 3600, 'Abteilungs-SLA-Plan ersetzt die Lösungszeit der Priorität'); +$check(strtotime($viaTopic['resolution_due_at']) - strtotime($from) === 3 * 3600 && strtotime($viaTopic['first_response_due_at']) - strtotime($from) === 3600, 'Themen-SLA-Plan hat Vorrang vor dem der Abteilung'); +$check($none['resolution_due_at'] !== $viaDept['resolution_due_at'], 'ohne Plan gelten die Zeiten der Priorität'); +$pdo->prepare('UPDATE sla_plans SET is_active = 0 WHERE id = ?')->execute([$planTopic]); +$viaInactive = calc_sla_dates($prioRow, $from, $dept1, $topicSla); +$check(strtotime($viaInactive['resolution_due_at']) - strtotime($from) === 8 * 3600, 'deaktivierter Themenplan fällt auf den Abteilungsplan zurück'); +sla_plan_delete($pdo, $planDept); +$check(calc_sla_dates($prioRow, $from, $dept1, null)['resolution_due_at'] === $none['resolution_due_at'], 'nach dem Löschen gelten wieder die Zeiten der Priorität'); + +// ── Überwachung des Mail-Abrufs ───────────────────────────────────────────── +$pdo->prepare("INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, spam_folder, is_active, created_at) VALUES ('Test','imap.example.test',993,'ssl','u','x','INBOX','seen','Processed','Junk',1,?)")->execute([$now]); +$accId = (int) $pdo->lastInsertId(); +$acc = fn() => $pdo->query("SELECT * FROM imap_accounts WHERE id = {$accId}")->fetch(); +$hasCode = fn(string $code) => in_array($code, array_column(system_health($pdo), 'code'), true); +health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt'); +$check($hasCode('mailbox_flaky') && !$hasCode('mailbox_down'), 'ein einzelner Fehlversuch ist nur ein Hinweis'); +health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt'); +health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt'); +$check($hasCode('mailbox_down') && !empty($acc()['alert_sent_at']), 'nach drei Fehlversuchen: Störung gemeldet und Alarm vermerkt'); +$alertAt = $acc()['alert_sent_at']; +health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt'); +$check($acc()['alert_sent_at'] === $alertAt, 'der Alarm wird nicht bei jedem Fehlversuch wiederholt'); +health_record_mail_result($pdo, $acc(), null); +$check(!$hasCode('mailbox_down') && !$hasCode('mailbox_flaky') && (int) $acc()['fail_count'] === 0 && empty($acc()['alert_sent_at']), 'nach Erfolg ist die Störung behoben'); +set_setting('cron_fetch_emails_last_run', date('Y-m-d H:i:s', time() - 3600)); +$check($hasCode('fetch_stale'), 'veralteter Abruf-Job wird gemeldet'); +set_setting('cron_fetch_emails_last_run', db_now()); +$check(!$hasCode('fetch_stale'), 'frischer Abruf-Job ist unauffällig'); + +// ── Bericht-Baukasten ─────────────────────────────────────────────────────── +$cfg = report_clean_config($pdo, ['dimension' => "x'; DROP TABLE tickets;--", 'metrics' => ['total', 'bogus', 'x) FROM tickets--'], 'filters' => ['departmentId' => '1', 'evil_column' => '1', 'status' => 'open'], 'sort' => 'evil', 'from' => 'kein-datum', 'to' => '2026-02-01']); +$check($cfg['dimension'] === 'department', 'unbekannte Gruppierung fällt auf den Standard zurück'); +$check($cfg['metrics'] === ['total'], 'unbekannte Kennzahlen werden verworfen'); +$check(array_keys($cfg['filters']) === ['departmentId', 'status'], 'unbekannte Filter werden verworfen'); +$check($cfg['sort'] === 'label', 'unbekannte Sortierung fällt auf "Gruppe" zurück'); +$check(preg_match('/^\d{4}-\d{2}-\d{2}$/', $cfg['from']) === 1, 'ungültiges Datum wird durch einen Standard ersetzt'); +$swapped = report_clean_config($pdo, ['from' => '2026-03-10', 'to' => '2026-03-01']); +$check($swapped['from'] === '2026-03-01' && $swapped['to'] === '2026-03-10', 'vertauschter Zeitraum wird korrigiert'); +$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)') + ->execute(['RB-1', 'Bericht', 'X', 'x@example.test', $dept1, $prio, 'resolved', '2026-03-05 10:00:00', '2026-03-05 10:00:00']); +$run = report_run($pdo, ['dimension' => 'status', 'metrics' => ['total', 'resolved'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null); +$check(count($run['rows']) === 1 && $run['rows'][0]['label'] === 'resolved' && $run['rows'][0]['total'] === 1 && $run['totals']['resolved'] === 1, 'Bericht zählt Tickets im Zeitraum'); +$none = report_run($pdo, ['dimension' => 'status', 'from' => '2025-01-01', 'to' => '2025-01-31'], null); +$check($none['rows'] === [], 'ohne Tickets im Zeitraum bleibt der Bericht leer'); +$scoped = report_run($pdo, ['dimension' => 'status', 'from' => '2026-03-01', 'to' => '2026-03-31'], [999999]); +$check($scoped['rows'] === [], 'der Abteilungsbereich des Mitarbeiters schränkt den Bericht ein'); +$monthRun = report_run($pdo, ['dimension' => 'month', 'metrics' => ['total'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null); +$check(($monthRun['rows'][0]['label'] ?? '') === '2026-03', 'Gruppierung nach Monat liefert "JJJJ-MM"'); +$pdo->prepare('INSERT INTO departments (name, email, created_at) VALUES (?,?,?)')->execute(['=HYPERLINK("x")', '', $now]); +$csvDept = (int) $pdo->lastInsertId(); +$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)') + ->execute(['RB-2', 'Bericht', 'X', 'x@example.test', $csvDept, $prio, 'open', '2026-03-06 10:00:00', '2026-03-06 10:00:00']); +$csv = report_to_csv(report_run($pdo, ['dimension' => 'department', 'metrics' => ['total'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null)); +$check(str_contains($csv, "\"'=HYPERLINK") || str_contains($csv, "'=HYPERLINK"), 'CSV schützt vor Formel-Injektion in Gruppennamen'); +$check(str_starts_with($csv, "\xEF\xBB\xBF"), 'CSV beginnt mit UTF-8-BOM für Excel'); + +// ── Datenbank-Update (Erkennung ausstehender Migrationen) ──────────────────── +require_once dirname(__DIR__) . '/database/migrate.php'; +$check(count(migrations_known()) >= 30, 'bekannte Migrationen werden erkannt'); +$check(migrations_pending($pdo) === [], 'frische Datenbank hat keine ausstehenden Migrationen'); +$pdo->exec("DELETE FROM schema_migrations WHERE migration = 'php_0030_saved_reports'"); +$check(migrations_pending($pdo) === ['php_0030_saved_reports'], 'fehlende Migration wird als ausstehend gemeldet'); +$pdo->exec("INSERT INTO schema_migrations (migration) VALUES ('php_0030_saved_reports')"); +$check(migrations_pending($pdo) === [], 'nach dem Einspielen ist nichts mehr ausstehend'); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Admin shared logic checks passed.\n"; diff --git a/DeskHub-Webseite/tests/app_downloads.php b/DeskHub-Webseite/tests/app_downloads.php new file mode 100644 index 00000000..09fab871 --- /dev/null +++ b/DeskHub-Webseite/tests/app_downloads.php @@ -0,0 +1,56 @@ + gleiche Prüfsumme ohne Neuberechnung; geänderte Datei -> neue Prüfsumme +$cacheValue = get_setting('app_android_hash_cache', ''); +app_download_info('android'); +$check(get_setting('app_android_hash_cache', '') === $cacheValue && $cacheValue !== '', 'Prüfsumme wird zwischengespeichert'); +$apk2 = 'PK' . random_bytes(5000); +file_put_contents($dir . '/DeskHub.apk', $apk2); +touch($dir . '/DeskHub.apk', time() + 5); +$info2 = app_download_info('android'); +$check($info2['sha256'] === hash('sha256', $apk2) && $info2['size'] === strlen($apk2), 'ausgetauschte Datei bekommt eine neue Prüfsumme'); + +file_put_contents($dir . '/DeskHub.apk', 'PK'); +$check(app_download_info('android') === null, 'abgebrochene/winzige Datei wird nicht angeboten'); + +$check(app_format_size(110 * 1048576) === '110,0 MB' && app_format_size(2048) === '2 KB', 'Größenformat'); + +foreach (scandir($dir) ?: [] as $e) { if ($e !== '.' && $e !== '..') @unlink($dir . '/' . $e); } +@rmdir($dir); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "App downloads checks passed.\n"; diff --git a/DeskHub-Webseite/tests/backup_restore.php b/DeskHub-Webseite/tests/backup_restore.php new file mode 100644 index 00000000..00168667 --- /dev/null +++ b/DeskHub-Webseite/tests/backup_restore.php @@ -0,0 +1,169 @@ + Daten verändern/zerstören -> wiederherstellen -> alles ist wieder da. Dazu beschädigte, unvollständige und + * manipulierte Backups, die nicht zurückgespielt werden dürfen. + */ + +if (!class_exists('ZipArchive')) { echo "Backup-Tests übersprungen: PHP-ZIP fehlt.\n"; exit(0); } + +$tmp = sys_get_temp_dir() . '/dh-bk-' . bin2hex(random_bytes(4)); +mkdir($tmp . '/root/uploads', 0755, true); +mkdir($tmp . '/root/plugins/demo', 0755, true); +mkdir($tmp . '/root/config', 0755, true); +mkdir($tmp . '/root/includes', 0755, true); +mkdir($tmp . '/backups', 0755, true); +putenv('DESKHUB_SQLITE_PATH=' . $tmp . '/live.sqlite'); +putenv('DESKHUB_TESTING=1'); +putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php'); +require_once dirname(__DIR__) . '/includes/bootstrap.php'; +require_once dirname(__DIR__) . '/includes/backup_restore.php'; + +$failures = []; +$total = 0; +$check = function (bool $ok, string $what) use (&$failures, &$total): void { + $total++; + if (!$ok) $failures[] = $what; +}; +$pdo = get_db(); +$root = $tmp . '/root'; +$backupDir = $tmp . '/backups'; + +// ── Ausgangsdaten ──────────────────────────────────────────────────────────── +file_put_contents($root . '/uploads/anhang.txt', "Wichtiger Anhang \u{00e4}\u{00f6}\u{00fc}"); +file_put_contents($root . '/plugins/demo/plugin.json', '{"slug":"demo"}'); +file_put_contents($root . '/config/config.php', "query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn(); +$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$ins = $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)'); +foreach ([['B-1', "Drucker 'kaputt', (dringend) ✓"], ['B-2', 'Rechnung \\ fehlt'], ['B-3', 'Normal']] as [$num, $subj]) $ins->execute([$num, $subj, 'Kunde', 'k@example.test', $dept, $prio, 'open', $now, $now]); +set_setting('display_name', 'Vor dem Backup'); +$ticketsBefore = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(); +$usersBefore = (int) $pdo->query('SELECT COUNT(*) FROM staff')->fetchColumn(); +$maxIdBefore = (int) $pdo->query('SELECT MAX(id) FROM tickets')->fetchColumn(); + +// ── Backup erstellen und prüfen ────────────────────────────────────────────── +$bk = create_backup($root, $backupDir, $pdo); +$check($bk['ok'] && is_file($bk['path']), 'Backup wird erstellt: ' . json_encode($bk)); +$check(basename($bk['path']) === $bk['filename'] && str_starts_with($bk['filename'], 'backup_'), 'Backup hat den üblichen Dateinamen'); +$zip = new ZipArchive(); +$zip->open($bk['path']); +$names = []; +for ($i = 0; $i < $zip->numFiles; $i++) $names[] = $zip->getNameIndex($i); +$manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true); +$zip->close(); +$check(in_array('database_backup.sqlite', $names, true) && in_array('uploads/anhang.txt', $names, true) && in_array('backup_manifest.json', $names, true), 'Backup enthält Datenbank-Abzug, Dateien und Manifest'); +$check(($manifest['driver'] ?? '') === 'sqlite' && (int) $manifest['tables'] > 20 && ($manifest['version'] ?? '') === DESKHUB_VERSION, 'Manifest nennt Datenbanktyp, Tabellenzahl und Programmversion'); +$check(!in_array('live.sqlite', $names, true) && !array_filter($names, fn($n) => str_ends_with($n, '.sqlite') && $n !== 'database_backup.sqlite'), 'die laufende Datenbankdatei wird nicht als Kopie ins Backup gelegt'); + +$v = backup_verify($bk['path']); +$check($v['ok'] && $v['info']['driver'] === 'sqlite' && $v['info']['tickets'] === $ticketsBefore && $v['info']['staff'] === $usersBefore, 'Prüfung bestätigt das Backup und nennt die Anzahl Tickets und Mitarbeiter: ' . json_encode($v)); + +// ── Beschädigte oder manipulierte Backups werden erkannt ───────────────────── +$makeZip = function (array $files): string { + $p = tempnam(sys_get_temp_dir(), 'bz') . '.zip'; + $z = new ZipArchive(); + $z->open($p, ZipArchive::CREATE | ZipArchive::OVERWRITE); + foreach ($files as $n => $c) $z->addFromString($n, $c); + $z->close(); + return $p; +}; +$check(!backup_verify($tmp . '/gibtesnicht.zip')['ok'], 'fehlende Datei wird erkannt'); +$trunc = $tmp . '/abgeschnitten.zip'; +file_put_contents($trunc, substr((string) file_get_contents($bk['path']), 0, (int) (filesize($bk['path']) / 2))); +$check(!backup_verify($trunc)['ok'], 'abgeschnittenes Backup wird erkannt'); +$check(!backup_verify($makeZip(['uploads/a.txt' => 'x']))['ok'], 'Backup ohne Datenbank-Abzug wird erkannt'); +$check(!backup_verify($makeZip(['database_backup.sqlite' => str_repeat('kein sqlite ', 200)]))['ok'], 'Datenbank-Abzug, der keine SQLite-Datei ist, wird erkannt'); +$check(!backup_verify($makeZip(['../evil.txt' => 'x', 'database_backup.sqlite' => 'x']))['ok'], 'Pfad mit ".." im Backup wird abgelehnt'); +$corrupt = $tmp . '/korrupt.sqlite'; +$z0 = new ZipArchive(); $z0->open($bk['path']); file_put_contents($corrupt, $z0->getFromName('database_backup.sqlite')); $z0->close(); +$raw = (string) file_get_contents($corrupt); +file_put_contents($corrupt, substr($raw, 0, 100) . str_repeat("\0", 2000) . substr($raw, 2100)); +$check(!backup_verify($makeZip(['database_backup.sqlite' => (string) file_get_contents($corrupt)]))['ok'], 'beschädigte Datenbank im Backup wird erkannt (Integritätsprüfung)'); +$noTickets = new PDO('sqlite::memory:'); +$onlySettings = tempnam(sys_get_temp_dir(), 'ns'); +$o = new PDO('sqlite:' . $onlySettings); $o->exec('CREATE TABLE settings (k TEXT)'); $o = null; +$check(!backup_verify($makeZip(['database_backup.sqlite' => (string) file_get_contents($onlySettings)]))['ok'], 'Datenbank ohne die Kerntabellen (tickets, users …) wird erkannt'); + +// ── Zerstören und wiederherstellen ─────────────────────────────────────────── +$pdo->exec('DELETE FROM tickets'); +$pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES ('NACH-1', 'Nach dem Backup', 'X', 'x@example.test', {$dept}, {$prio}, 'open', '{$now}', '{$now}')"); +set_setting('display_name', 'Nach dem Backup'); +unlink($root . '/uploads/anhang.txt'); +unlink($root . '/plugins/demo/plugin.json'); +file_put_contents($root . '/config/config.php', "exec("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Nur nach Backup', 'neu@example.test', 'x', 'agent', {$dept}, 1, '{$now}')"); +$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 1, 'Ausgangslage: Daten wurden verändert'); + +$countBackups = fn() => count(glob($backupDir . '/backup_*.zip')); +$beforeCount = $countBackups(); +$r = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir]); +$check($r['ok'] && $r['tables'] > 20 && $r['rows'] > 0 && $r['files'] >= 2, 'Wiederherstellung läuft durch: ' . json_encode($r)); +$check($countBackups() === $beforeCount + 1 && !empty($r['safety']) && is_file($backupDir . '/' . $r['safety']), 'vor der Wiederherstellung wird ein Sicherheits-Backup erstellt'); +$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore, 'alle Tickets sind wieder da'); +$subjects = $pdo->query('SELECT subject FROM tickets ORDER BY ticket_number')->fetchAll(PDO::FETCH_COLUMN); +$check($subjects === ["Drucker 'kaputt', (dringend) ✓", 'Rechnung \\ fehlt', 'Normal'], 'Inhalte mit Sonderzeichen sind unverändert: ' . json_encode($subjects, JSON_UNESCAPED_UNICODE)); +$check((int) $pdo->query("SELECT COUNT(*) FROM tickets WHERE ticket_number = 'NACH-1'")->fetchColumn() === 0, 'Daten, die erst nach dem Backup entstanden, sind weg'); +$check((int) $pdo->query('SELECT COUNT(*) FROM staff')->fetchColumn() === $usersBefore, 'Mitarbeiter-Stand entspricht dem Backup'); +get_setting('display_name'); // Zwischenspeicher der Einstellungen +$check($pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'display_name'")->fetchColumn() === 'Vor dem Backup', 'Einstellungen sind zurückgesetzt'); +$pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES ('B-4', 'Neu', 'X', 'x@example.test', {$dept}, {$prio}, 'open', '{$now}', '{$now}')"); +$check((int) $pdo->query("SELECT id FROM tickets WHERE ticket_number = 'B-4'")->fetchColumn() === $maxIdBefore + 1, 'die Nummernvergabe läuft nach der Wiederherstellung am richtigen Punkt weiter'); +$check(file_exists($root . '/uploads/anhang.txt') && str_contains((string) file_get_contents($root . '/uploads/anhang.txt'), 'Wichtiger Anhang'), 'hochgeladene Dateien sind wieder da'); +$check(is_file($root . '/plugins/demo/plugin.json'), 'Plugins sind wieder da'); +$check(str_contains((string) file_get_contents($root . '/config/config.php'), 'NACH dem Backup'), 'die Konfiguration wird nie überschrieben'); +$check(str_contains((string) file_get_contents($root . '/includes/code.php'), 'NACH dem Backup'), 'Programmdateien werden nie überschrieben (dafür gibt es das System-Update)'); + +// ── Optionen und Schutz ────────────────────────────────────────────────────── +unlink($root . '/uploads/anhang.txt'); +$pdo->exec("UPDATE tickets SET subject = 'geändert'"); +$r2 = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir, 'dirs' => [], 'database' => true]); +$check($r2['ok'] && !file_exists($root . '/uploads/anhang.txt') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'geändert'")->fetchColumn() == 0, 'ohne Dateien wird nur die Datenbank zurückgespielt'); +$pdo->exec("UPDATE tickets SET subject = 'noch einmal geändert'"); +$r3 = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir, 'database' => false, 'dirs' => ['uploads']]); +$check($r3['ok'] && file_exists($root . '/uploads/anhang.txt') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'noch einmal geändert'")->fetchColumn() > 0, 'ohne Datenbank werden nur die gewählten Ordner zurückgespielt'); + +$bad = backup_restore($pdo, $trunc, ['root' => $root, 'backup_dir' => $backupDir]); +$check(!$bad['ok'] && str_contains($bad['error'], 'nicht wiederherstellbar') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'noch einmal geändert'")->fetchColumn() > 0, 'ein beschädigtes Backup wird nicht zurückgespielt, die Daten bleiben unangetastet'); +$foreign = $makeZip(['database_backup.sql' => "-- x\nSET FOREIGN_KEY_CHECKS=0;\nDROP TABLE IF EXISTS `tickets`;\nCREATE TABLE `tickets` (id int);\nCREATE TABLE `users` (id int);\nCREATE TABLE `staff` (id int);\nCREATE TABLE `settings` (id int);\nSET FOREIGN_KEY_CHECKS=1;\n"]); +$vm = backup_verify($foreign); +$check($vm['ok'] && $vm['info']['driver'] === 'mysql' && $vm['info']['tables'] === 4, 'MySQL-Abzug wird erkannt und geprüft'); +$mismatch = backup_restore($pdo, $foreign, ['root' => $root, 'backup_dir' => $backupDir]); +$check(!$mismatch['ok'] && str_contains($mismatch['error'], 'MYSQL') && str_contains($mismatch['error'], 'SQLITE'), 'ein Backup eines anderen Datenbanktyps wird abgelehnt'); +$incomplete = $makeZip(['database_backup.sql' => "SET FOREIGN_KEY_CHECKS=0;\nCREATE TABLE `tickets` (id int);\nCREATE TABLE `users` (id int);\nCREATE TABLE `staff` (id int);\nCREATE TABLE `settings` (id int);\nINSERT INTO `tickets` (id) VALUES\n(1),\n"]); +$check(!backup_verify($incomplete)['ok'], 'abgebrochener MySQL-Abzug (Ende fehlt) wird erkannt'); + +// SQL-Zerlegung: Semikolons und Zeilenumbrüche in Texten +$sqlFile = $tmp . '/t.sql'; +file_put_contents($sqlFile, "-- Kommentar\nSET A=1;\n\nINSERT INTO t (a) VALUES\n('x; y'),\n('it\\'s; here'),\n('z');\nSELECT 2;\n"); +$stmts = iterator_to_array(backup_sql_statements($sqlFile), false); +$check(count($stmts) === 3 && str_contains($stmts[1], "it\\'s; here") && $stmts[2] === 'SELECT 2;', 'SQL-Anweisungen werden richtig getrennt (Semikolon in Texten, Anführungszeichen mit Backslash)'); + +// Prüfergebnis merken, Hinweis im System, sicherer Zugriff auf Backup-Dateien +$rec = backup_verify_and_record($bk['path']); +$saved = json_decode((string) get_setting('backup_last_check', ''), true); +$check($rec['ok'] && ($saved['ok'] ?? null) === true && $saved['file'] === $bk['filename'], 'das Prüfergebnis wird gespeichert'); +$check(!array_filter(system_health($pdo), fn($i) => $i['code'] === 'backup_broken'), 'ein gutes Backup löst keinen Hinweis aus'); +backup_verify_and_record($trunc); +$broken = array_values(array_filter(system_health($pdo), fn($i) => $i['code'] === 'backup_broken')); +$check($broken && $broken[0]['level'] === 'error' && str_contains($broken[0]['message'], 'nicht wiederherstellbar'), 'ein nicht wiederherstellbares Backup erzeugt einen Fehler-Hinweis im System'); +backup_verify_and_record($bk['path']); +$check(backup_path_for($bk['filename'], $backupDir) !== null && backup_path_for('../../etc/passwd', $backupDir) === null && backup_path_for('backup_x.zip', $backupDir) === null && backup_path_for('config.php', $backupDir) === null, 'Backups werden nur über echte Dateinamen im Backup-Ordner angesprochen'); + +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +$rm($tmp); +@unlink($onlySettings); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Backup restore checks passed ({$total} Prüfungen).\n"; diff --git a/DeskHub-Webseite/tests/catalog_preview.php b/DeskHub-Webseite/tests/catalog_preview.php new file mode 100644 index 00000000..0ea24a3f --- /dev/null +++ b/DeskHub-Webseite/tests/catalog_preview.php @@ -0,0 +1,176 @@ +open($z, ZipArchive::CREATE); +$zip->addFromString('theme.css', "/*\n * Version: 1.0.0\n */\na{}"); +$zip->close(); + +// Webseite: Vorschau-Adresse im Katalog nur bei Einträgen mit Bild +$check(site_publish('theme', $z, ['slug' => 'mitbild', 'name' => 'Mit Bild'], $pngFile, 'vorschau.png')['ok'], 'Theme mit Vorschaubild wird veröffentlicht'); +$zip2 = tempnam(sys_get_temp_dir(), 'pk') . '.zip'; +$zz = new ZipArchive(); $zz->open($zip2, ZipArchive::CREATE); $zz->addFromString('theme.css', "/*\n * Version: 1.0.0\n */\nb{}"); $zz->close(); +$check(site_publish('theme', $zip2, ['slug' => 'ohnebild', 'name' => 'Ohne Bild'])['ok'], 'Theme ohne Vorschaubild wird veröffentlicht'); +$cat = site_catalog('theme'); +$byslug = array_column($cat['themes'], null, 'slug'); +$check(isset($byslug['mitbild']['previewUrl']) && str_starts_with($byslug['mitbild']['previewUrl'], 'https://plugins.example.test/preview.php?file=') && !isset($byslug['ohnebild']['previewUrl']), 'Katalog nennt die Vorschau-Adresse nur für Einträge mit Bild'); + +// DeskHub: Adresse prüfen +$base = 'https://plugins.example.test/catalog.php?type=themes'; +$check(catalog_preview_url($base, 'https://plugins.example.test/preview.php?file=a.png') !== '', 'Vorschau auf derselben Domain wird übernommen'); +$check(catalog_preview_url($base, 'https://anderer.example.test/a.png') === '', 'Vorschau auf einer anderen Domain wird verworfen'); +$check(catalog_preview_url($base, 'http://plugins.example.test/a.png') === '' && catalog_preview_url($base, '') === '', 'http und leere Adressen werden verworfen'); + +// DeskHub: Katalog lesen, Bild laden, prüfen, zwischenspeichern +set_setting('theme_catalog_url', $base); +$requests = []; +$serve = ['body' => $png]; +$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use ($cat, &$requests, &$serve): array { + $requests[] = $url; + if (str_contains($url, 'catalog.php')) return [200, json_encode($cat), null]; + if (str_contains($url, 'preview.php')) return [200, $serve['body'], null]; + return [404, null, 'HTTP 404']; +}; +$tc = theme_catalog_fetch(); +$by = array_column($tc['themes'], null, 'slug'); +$check(($by['mitbild']['preview'] ?? '') !== '' && ($by['ohnebild']['preview'] ?? 'x') === '', 'DeskHub übernimmt die Vorschau-Adresse aus dem Theme-Katalog'); + +foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f); +$img = catalog_preview_image('theme', 'mitbild'); +$check($img !== null && $img['mime'] === 'image/png' && $img['data'] === $png, 'Vorschaubild wird geladen und mit dem richtigen Typ geliefert'); +$n = count($requests); +$img2 = catalog_preview_image('theme', 'mitbild'); +$check($img2 !== null && count($requests) === $n, 'zweiter Aufruf kommt aus dem Zwischenspeicher (kein neuer Abruf)'); +$check(catalog_preview_image('theme', 'ohnebild') === null, 'Eintrag ohne Vorschau liefert kein Bild'); +$check(catalog_preview_image('theme', 'gibtesnicht') === null && catalog_preview_image('theme', '../x') === null && catalog_preview_image('auto', 'mitbild') === null, 'unbekannte Einträge, Pfadtricks und falsche Arten liefern nichts'); + +foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f); +$serve['body'] = ''; +$check(catalog_preview_image('theme', 'mitbild') === null, 'eine Datei, die kein Bild ist, wird nicht ausgeliefert'); +foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f); +$serve['body'] = "GIF89a" . str_repeat("\0", 30); +$check(catalog_preview_image('theme', 'mitbild') === null, 'andere Bildtypen (z. B. GIF) werden nicht ausgeliefert'); + +// Ein fehlgeschlagener Abruf wird nur 5 Minuten gemerkt (früher einen Tag): Sobald das Bild da ist, erscheint es +foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f); +$serve['body'] = 'kein Bild'; +$check(catalog_preview_image('theme', 'mitbild') === null, 'Abruf ohne gültiges Bild liefert nichts'); +$serve['body'] = $png; +$check(catalog_preview_image('theme', 'mitbild') === null, 'der Fehlschlag wird kurz gemerkt (kein Dauerabruf bei kaputtem Bild)'); +foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) touch($f, time() - 400); +$check(catalog_preview_image('theme', 'mitbild') !== null, 'nach 5 Minuten wird erneut geladen und das Bild erscheint'); +foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) touch($f, time() - 400); +$check(catalog_preview_image('theme', 'mitbild') !== null, 'ein erfolgreich geladenes Bild bleibt länger als 5 Minuten im Zwischenspeicher'); + +// Installierte Themes: Vorschau aus dem Katalog (gleicher Name) oder aus preview.png im Theme-Ordner +$themesDir = sys_get_temp_dir() . '/dh-thumb-themes-' . bin2hex(random_bytes(3)); +mkdir($themesDir, 0755, true); +putenv('DESKHUB_THEMES_DIR=' . $themesDir); +$zt = tempnam(sys_get_temp_dir(), 'th') . '.zip'; +$q = new ZipArchive(); $q->open($zt, ZipArchive::CREATE); $q->addFromString('theme.css', "/* + * Version: 1.0.0 + */ +a{}"); $q->addFromString('preview.png', $png); $q->close(); +$inst = install_custom_theme_archive($zt, 'Mit Bild'); +$ohne = tempnam(sys_get_temp_dir(), 'th') . '.zip'; +$q = new ZipArchive(); $q->open($ohne, ZipArchive::CREATE); $q->addFromString('theme.css', "/* + * Version: 1.0.0 + */ +c{}"); $q->close(); +$inst2 = install_custom_theme_archive($ohne, 'Ganz Ohne'); +$catThemes = [['slug' => 'mitbild', 'name' => 'Mit-Bild', 'preview' => 'https://plugins.example.test/preview.php?file=a.png']]; +$p1 = theme_installed_preview(['id' => $inst['id'], 'name' => 'Mit Bild'], $catThemes); +$check($p1 && $p1['kind'] === 'catalog' && $p1['slug'] === 'mitbild', 'installiertes Theme bekommt die Vorschau aus dem Katalog (Name "Mit Bild" = "Mit-Bild")'); +$p2 = theme_installed_preview(['id' => $inst['id'], 'name' => 'Mit Bild'], []); +$check($p2 && $p2['kind'] === 'file' && str_ends_with($p2['url'], '/preview.png'), 'ohne Katalog gilt die preview.png aus dem Theme-Ordner'); +$check(theme_installed_preview(['id' => $inst2['id'], 'name' => 'Ganz Ohne'], $catThemes) === null, 'ohne Katalogbild und ohne preview.png gibt es keine Vorschau'); +$check(str_contains(installed_theme_thumb(['id' => $inst2['id'], 'name' => 'Ganz Ohne'], []), '>G<'), 'Platzhalter zeigt den Anfangsbuchstaben'); +$check(str_contains(installed_theme_thumb(['id' => $inst['id'], 'name' => 'Mit Bild'], $catThemes), 'catalog_preview.php?kind=theme&slug=mitbild'), 'Mini-Vorschau verweist auf die Auslieferung durch DeskHub'); +$rmT = function (string $d) use (&$rmT) { foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rmT("$d/$e") : @unlink("$d/$e"); } @rmdir($d); }; +$rmT($themesDir); +putenv('DESKHUB_THEMES_DIR'); + +// Platzhalter und Bild-Markup +$check(str_contains(catalog_thumb('theme', ['slug' => 'mitbild', 'name' => 'Mit Bild', 'preview' => 'https://x']), 'catalog_preview.php?kind=theme&slug=mitbild'), 'Mini-Vorschau zeigt das Bild über die DeskHub-Adresse'); +$ph = catalog_thumb('plugin', ['slug' => 'x', 'name' => ''], $de), 'Hallo', + 'script verschachtelt' => 'ipt>alert(1)ipt>', + 'img onerror' => '', + 'img onerror quoted' => '', + 'unterminiert' => 'Text ' '<', + 'a javascript' => 'x', + 'a javascript entity' => 'x', + 'a javascript tab' => 'x', + 'a data' => 'x', + 'a onclick' => 'x', + 'a attribut ausbruch' => 'x', + 'img svg data' => '', + 'style expression' => '
x
', + 'style url' => '
x
', + 'style url entity' => '
x
', + 'style position' => '
Bitte Passwort eingeben
', + 'style ausbruch' => '
x
', + 'iframe' => '', + 'iframe srcdoc' => '', + 'form' => '
', + 'meta refresh' => '', + 'base' => '', + 'object' => '', + 'math' => 'x', + 'kommentar' => '-->', + 'bedingt' => '', + 'cdata' => 'alert(1)]]>', + 'attribut ohne quotes' => '', + 'grossschreibung' => '', + 'null byte' => "alert(1)", + 'tag mit newline' => "", + 'slash statt space' => '', + 'details ontoggle' => '
x
', + 'td background' => '
x
', +]; +foreach ($payloads as $name => $input) { + $out = sanitize_staff_html($input); + $why = $dangerous($out); + $check($why === null, "Angriff '{$name}' nicht entschärft ({$why}): {$out}"); +} + +// Konkrete Erwartungen +$out = sanitize_staff_html('Text x'); +$check(!str_contains($out, 'javascript') && str_contains($out, 'rel="noopener noreferrer"'), 'javascript-Link: ' . $out); +$out = sanitize_staff_html('
x
'); +$check(!str_contains($out, 'position') && str_contains($out, 'color: red'), 'position muss entfernt, color bleiben: ' . $out); +$out = sanitize_staff_html('
x
'); +$check(!str_contains($out, 'url') && !str_contains($out, 'evil'), 'url() im Stil: ' . $out); +$out = sanitize_staff_html(''); +$check(!str_contains($out, 'tracker.example'), 'http-Bild muss ersetzt werden: ' . $out); +$out = sanitize_staff_html(''); +$check(!str_contains($out, 'svg'), 'svg-Datenbild muss ersetzt werden: ' . $out); + +// Normales Rich-Text-HTML bleibt erhalten +$ok = '

Hallo Welt, kursiv und ein Link.

  • Eins
  • Zwei
' + . '
Zelle
Zitat

'; +$out = sanitize_staff_html($ok); +foreach (['

', 'Welt', 'kursiv', 'href="https://example.org/a?x=1&y=2"', '

  • Zwei
  • ', 'colspan="2"', 'style="color: red"', '
    Zitat
    ', '
    '] as $needle) { + $check(str_contains($out, $needle), "Erlaubtes HTML ging verloren ({$needle}): {$out}"); +} +$check(sanitize_staff_html(' ') === '', 'Leerer Text'); +$check(sanitize_staff_html('Preis < 5 und > 3') === 'Preis < 5 und > 3', 'Kleiner/Größer im Fließtext: ' . sanitize_staff_html('Preis < 5 und > 3')); + +if ($failures) { + fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n"); + exit(1); +} +echo 'Sanitize HTML checks passed (' . count($payloads) . " Angriffe).\n"; diff --git a/DeskHub-Webseite/tests/schema_foreign_keys.php b/DeskHub-Webseite/tests/schema_foreign_keys.php new file mode 100644 index 00000000..11fd7127 --- /dev/null +++ b/DeskHub-Webseite/tests/schema_foreign_keys.php @@ -0,0 +1,125 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->exec('PRAGMA foreign_keys = ON'); + +foreach (['departments', 'organizations', 'staff', 'users', 'customer_notes'] as $tableName) { + $pdo->exec(convert_schema_to_sqlite($definitions[$tableName])); +} + +$pdo->exec( + "INSERT INTO staff (id, name, email, password_hash, role, is_active, created_at) + VALUES (1, 'Testautor', 'autor@example.test', 'hash', 'agent', 1, CURRENT_TIMESTAMP)" +); +$pdo->exec( + "INSERT INTO users (id, name, email, password_hash, created_at) + VALUES (1, 'Testkunde', 'kunde@example.test', 'hash', CURRENT_TIMESTAMP)" +); +$pdo->exec( + "INSERT INTO customer_notes (user_id, staff_id, note, created_at) + VALUES (1, 1, 'Historische Notiz', CURRENT_TIMESTAMP)" +); +$pdo->exec('DELETE FROM staff WHERE id = 1'); + +$note = $pdo->query('SELECT staff_id, note FROM customer_notes WHERE user_id = 1')->fetch(PDO::FETCH_ASSOC); +if ($note === false || $note['staff_id'] !== null || $note['note'] !== 'Historische Notiz') { + fail('Kundennotizen bleiben beim Löschen eines Mitarbeiters nicht mit staff_id = NULL erhalten.'); +} + +$pdo->exec('CREATE TABLE tickets (id INTEGER PRIMARY KEY)'); +$pdo->exec(convert_schema_to_sqlite($definitions['ticket_ratings'])); + +$pdo->exec("INSERT INTO users (id, name, email, password_hash, created_at) + VALUES (2, 'Bewertungskunde', 'rating@example.test', 'hash', CURRENT_TIMESTAMP)"); +$pdo->exec('INSERT INTO tickets (id) VALUES (1)'); +$pdo->exec("INSERT INTO ticket_ratings (ticket_id, user_id, rating, comment, created_at) + VALUES (1, 2, 5, 'Sehr gut', CURRENT_TIMESTAMP)"); + +$rating = $pdo->query('SELECT id, user_id, rating FROM ticket_ratings WHERE ticket_id = 1')->fetch(PDO::FETCH_ASSOC); +if ($rating === false || (int) $rating['id'] !== 1 || (int) $rating['user_id'] !== 2 || (int) $rating['rating'] !== 5) { + fail('Die SQLite-Konvertierung von ticket_ratings erzeugt keine funktionsfähige Tabelle.'); +} + +$pdo->exec('DELETE FROM users WHERE id = 2'); +$ratingUserId = $pdo->query('SELECT user_id FROM ticket_ratings WHERE ticket_id = 1')->fetchColumn(); +if ($ratingUserId !== null) { + fail('ticket_ratings.user_id bleibt beim Löschen eines Kunden nicht als NULL erhalten.'); +} + +$pdo->exec('DELETE FROM tickets WHERE id = 1'); +if ((int) $pdo->query('SELECT COUNT(*) FROM ticket_ratings')->fetchColumn() !== 0) { + fail('Ticket-Bewertungen werden beim Löschen des Tickets nicht mit entfernt.'); +} + +$fullSchemaPdo = new PDO('sqlite::memory:'); +$fullSchemaPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$fullSchemaPdo->exec('PRAGMA foreign_keys = ON'); +$fullSchemaPdo->exec(convert_schema_to_sqlite($schema)); +$organizationColumns = $fullSchemaPdo->query('PRAGMA table_info(organizations)')->fetchAll(PDO::FETCH_ASSOC); +$createdAt = array_values(array_filter($organizationColumns, fn(array $column): bool => $column['name'] === 'created_at'))[0] ?? null; +if ($createdAt === null || (int) $createdAt['notnull'] !== 1 || stripos((string) $createdAt['dflt_value'], 'CURRENT_TIMESTAMP') === false) { + fail('Die SQLite-Konvertierung bewahrt NOT NULL/DEFAULT CURRENT_TIMESTAMP nicht.'); +} + +echo "Schema-Fremdschlüssel sind SET-NULL-kompatibel.\n"; + +function fail(string $message): never +{ + fwrite(STDERR, $message . "\n"); + exit(1); +} diff --git a/DeskHub-Webseite/tests/security_check.php b/DeskHub-Webseite/tests/security_check.php new file mode 100644 index 00000000..ce5054b3 --- /dev/null +++ b/DeskHub-Webseite/tests/security_check.php @@ -0,0 +1,84 @@ + array_column($rows, 'level'); + +// Gut konfigurierter Server: alles Interne 403, Unbekanntes 404 +$secure = fn(string $url) => str_contains($url, 'deskhub-pruefung') ? [404, ''] : [403, '']; +$r = security_check_exposure('https://support.example.org', $secure); +$check($levels($r) === ['ok'], 'Gesperrter Server: nur "ok" erwartet, erhalten ' . implode(',', $levels($r))); + +// Server ohne Sperrregeln (z. B. nginx ohne Konfiguration): Konfiguration und Datenbank liefern 200 +$open = fn(string $url) => str_contains($url, 'deskhub-pruefung') ? [404, ''] : [200, ' str_contains($url, '/backups/') ? [200, 'Index of'] : (str_contains($url, 'deskhub-pruefung') ? [404, ''] : [403, '']); +$r = security_check_exposure('https://support.example.org/', $partial); +$check($levels($r) === ['error'] && str_contains($r[0]['detail'], '/backups/') && !str_contains($r[0]['detail'], 'config.php'), 'Genau die offene Adresse wird gemeldet'); + +// Server antwortet auf alles mit 200 (Catch-all): nicht aussagekräftig statt falschem Alarm +$catchAll = fn(string $url) => [200, 'Startseite']; +$r = security_check_exposure('https://support.example.org', $catchAll); +$check($levels($r) === ['info'], 'Catch-all-Server: nur Hinweis "nicht prüfbar"'); + +// Nicht erreichbar +$down = fn(string $url) => [0, '']; +$r = security_check_exposure('https://support.example.org', $down); +$check($levels($r) === ['info'], 'Nicht erreichbar: nur Hinweis'); + +// Upload-Ausführung: simulierter Server führt die Datei aus (liefert den Marker) bzw. nicht +$dir = sys_get_temp_dir() . '/deskhub_sc_' . bin2hex(random_bytes(4)); +mkdir($dir); +$executing = function (string $url) use ($dir): array { + $f = $dir . '/' . basename($url); + if (!is_file($f)) return [404, '']; + preg_match('/echo "([a-f0-9]+)"/', (string) file_get_contents($f), $m); + return [200, $m[1] ?? '']; +}; +$notExecuting = function (string $url) use ($dir): array { + $f = $dir . '/' . basename($url); + return is_file($f) ? [200, (string) file_get_contents($f)] : [404, '']; // liefert den Quelltext aus, führt ihn nicht aus +}; +$denied = fn(string $url) => [403, '']; +$r = security_check_upload_execution('https://support.example.org', $dir, $executing); +$check($r['level'] === 'error', 'Ausführbare Uploads müssen als Fehler gemeldet werden'); +$r = security_check_upload_execution('https://support.example.org', $dir, $notExecuting); +$check($r['level'] === 'ok', 'Nicht ausgeführte Uploads: ok (Quelltext ist kein Marker)'); +$r = security_check_upload_execution('https://support.example.org', $dir, $denied); +$check($r['level'] === 'ok', 'Gesperrte Uploads: ok'); +$check(glob($dir . '/*') === [], 'Die Testdatei wird wieder gelöscht'); +$r = security_check_upload_execution('https://support.example.org', $dir . '/gibt-es-nicht', $denied); +$check($r['level'] === 'info', 'Nicht beschreibbarer Ordner: Hinweis'); +rmdir($dir); + +// Einstellungsprüfungen laufen ohne Fehler und liefern Einträge mit gültigen Stufen +$pdo = get_db(); +$rows = security_check_settings($pdo); +$check(count($rows) >= 3, 'Einstellungsprüfung liefert Ergebnisse'); +foreach ($rows as $row) $check(in_array($row['level'], ['ok', 'warn', 'error', 'info'], true) && $row['title'] !== '', 'Gültiger Eintrag: ' . json_encode($row)); +$all = security_check_all($pdo, false); +$check($all !== [] && $all[0]['level'] !== 'ok' || count(array_unique(array_column($all, 'level'))) === 1, 'Fehler stehen vor "ok" (Sortierung)'); +$sorted = array_column($all, 'level'); +$rank = ['error' => 0, 'warn' => 1, 'info' => 2, 'ok' => 3]; +$check($sorted === array_values(array_map(fn($l) => $l, $sorted)) && array_map(fn($l) => $rank[$l], $sorted) === (function ($a) { sort($a); return $a; })(array_map(fn($l) => $rank[$l], $sorted)), 'Ergebnisse sind nach Dringlichkeit sortiert'); + +if ($failures) { + fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n"); + exit(1); +} +echo "Security check checks passed.\n"; diff --git a/DeskHub-Webseite/tests/security_helpers.php b/DeskHub-Webseite/tests/security_helpers.php new file mode 100644 index 00000000..fc2bfa79 --- /dev/null +++ b/DeskHub-Webseite/tests/security_helpers.php @@ -0,0 +1,75 @@ + ['trusted_proxies' => ['10.0.0.0/8', '192.168.1.5', 'fd00::/8']]])); +require_once dirname(__DIR__) . '/includes/bootstrap.php'; + +$failures = []; +$check = function (bool $ok, string $what) use (&$failures): void { + if (!$ok) $failures[] = $what; +}; + +// CIDR-Vergleich +$check(ip_in_list('10.1.2.3', ['10.0.0.0/8']), '10.1.2.3 in 10.0.0.0/8'); +$check(!ip_in_list('11.1.2.3', ['10.0.0.0/8']), '11.1.2.3 nicht in 10.0.0.0/8'); +$check(ip_in_list('192.168.1.5', ['192.168.1.5']), 'Einzeladresse'); +$check(!ip_in_list('192.168.1.6', ['192.168.1.5']), 'andere Einzeladresse'); +$check(ip_in_list('172.17.0.3', ['172.16.0.0/12']), '/12-Bereich'); +$check(!ip_in_list('172.32.0.1', ['172.16.0.0/12']), 'außerhalb /12'); +$check(ip_in_list('fd00::1234', ['fd00::/8']), 'IPv6 im Bereich'); +$check(!ip_in_list('2001:db8::1', ['fd00::/8']), 'IPv6 außerhalb'); +$check(!ip_in_list('10.0.0.1', ['fd00::/8']), 'IPv4 gegen IPv6-Eintrag'); +$check(!ip_in_list('kaputt', ['10.0.0.0/8']), 'ungültige IP'); + +// Direkter Zugriff: X-Forwarded-For wird ignoriert (sonst könnte jeder die Sperre umgehen) +$_SERVER['REMOTE_ADDR'] = '203.0.113.9'; +$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.2.3.4'; +$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; +$check(client_ip() === '203.0.113.9', 'Ohne vertrauenswürdigen Proxy zählt REMOTE_ADDR: ' . client_ip()); +$check(!request_is_https(), 'Ohne vertrauenswürdigen Proxy wird X-Forwarded-Proto ignoriert'); + +// Über vertrauenswürdigen Proxy +$_SERVER['REMOTE_ADDR'] = '10.0.0.7'; +$_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.20'; +$check(client_ip() === '198.51.100.20', 'Proxy: Client-IP aus X-Forwarded-For: ' . client_ip()); +$check(request_is_https(), 'Proxy: X-Forwarded-Proto https wird erkannt'); +// Der Angreifer hängt eine eigene Adresse vorne an: maßgeblich ist die letzte nicht vertrauenswürdige von rechts +$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.1.1.1, 198.51.100.20, 10.0.0.3'; +$check(client_ip() === '198.51.100.20', 'Gefälschte Adresse vorne wird ignoriert: ' . client_ip()); +$_SERVER['HTTP_X_FORWARDED_FOR'] = 'kein-ip-wert'; +$check(client_ip() === '10.0.0.7', 'Ungültiger Header fällt auf REMOTE_ADDR zurück'); + +// Login-Zähler: nach Erfolg zurücksetzbar +$_SERVER['REMOTE_ADDR'] = '203.0.113.9'; +unset($_SERVER['HTTP_X_FORWARDED_FOR']); +for ($i = 0; $i < 5; $i++) $check(rate_limit_check('login_email', 'a@example.org', 5, 15), "Versuch {$i} erlaubt"); +$check(!rate_limit_check('login_email', 'a@example.org', 5, 15), 'sechster Versuch gesperrt'); +rate_limit_reset('login_email', 'a@example.org'); +$check(rate_limit_check('login_email', 'a@example.org', 5, 15), 'nach dem Zurücksetzen wieder erlaubt'); +$check(!rate_limit_check('login_email', 'b@example.org', 0, 15), 'anderer Schlüssel unabhängig'); + +// Abteilungsprüfung für Mitarbeiter (verhindert Zugriff auf Tickets/Anhänge fremder Abteilungen) +$pdo = get_db(); +$pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Zweite Abteilung', 'zweite@example.org', 0)"); +$deptA = (int) $pdo->query("SELECT id FROM departments ORDER BY id LIMIT 1")->fetchColumn(); +$deptB = (int) $pdo->query("SELECT id FROM departments ORDER BY id DESC LIMIT 1")->fetchColumn(); +$check($deptA !== $deptB, "zwei Abteilungen angelegt"); +$admin = ["id" => 1, "role" => "admin", "department_id" => null]; +$agentA = ["id" => 2, "role" => "agent", "department_id" => $deptA]; +$agentNone = ["id" => 3, "role" => "agent", "department_id" => null]; +$check(staff_can_access_department($admin, $deptB), "Admin darf jede Abteilung"); +$check(staff_can_access_department($agentA, $deptA), "Agent darf eigene Abteilung"); +$check(!staff_can_access_department($agentA, $deptB), "Agent darf fremde Abteilung nicht"); +$check(!staff_can_access_department($agentNone, $deptA), "Agent ohne Abteilung darf nichts"); +if ($failures) { + fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n"); + exit(1); +} +echo "Security helpers checks passed.\n"; diff --git a/DeskHub-Webseite/tests/theme_catalog.php b/DeskHub-Webseite/tests/theme_catalog.php new file mode 100644 index 00000000..4f80d25a --- /dev/null +++ b/DeskHub-Webseite/tests/theme_catalog.php @@ -0,0 +1,106 @@ + $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); +openssl_pkey_export($k, $priv, null, ['config' => $cnf]); +$pub = openssl_pkey_get_details($k)['key']; + +$zipPath = tempnam(sys_get_temp_dir(), 'th') . '.zip'; +$z = new ZipArchive(); +$z->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); +$z->addFromString('theme.css', "/*\n * Author: Studio\n * Version: 1.2.0\n */\n:root { --primary: #0a5; }"); +$z->close(); +$sha = hash_file('sha256', $zipPath); +openssl_sign(plugin_signature_message('theme-nordlicht', '1.2.0', $sha), $sigRaw, $priv, OPENSSL_ALGO_SHA256); +$sig = base64_encode($sigRaw); + +$entry = ['slug' => 'nordlicht', 'name' => 'Nordlicht', 'version' => '1.2.0', 'description' => 'Hell und ruhig', 'author' => 'Studio', + 'downloadUrl' => 'https://plugins.example.de/dl/nordlicht-1.2.0.zip', 'sha256' => $sha, 'signature' => $sig]; +$served = ['themes' => [$entry, ['slug' => 'kaputt', 'version' => 'x', 'sha256' => 'y'], array_merge($entry, ['slug' => 'httpdl', 'downloadUrl' => 'http://x.de/a.zip'])]]; +$body = file_get_contents($zipPath); +$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, &$body): array { + if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null]; + return [200, $body, null]; +}; + +$check(!theme_catalog_fetch()['ok'], 'ohne Katalog-Adresse gibt es einen Hinweis'); +set_setting('theme_catalog_url', 'https://plugins.example.de/catalog.php?type=themes'); +$cat = theme_catalog_fetch(); +$check($cat['ok'] && count($cat['themes']) === 1 && $cat['themes'][0]['slug'] === 'nordlicht', 'Katalog wird gelesen, ungültige Einträge werden verworfen'); + +$r = theme_catalog_install('nordlicht'); +$check(!$r['ok'] && str_contains($r['error'], 'kein Herausgeber-Schlüssel'), 'Signatur ohne hinterlegten Schlüssel kann nicht geprüft werden'); +$check(!theme_catalog_install('gibtesnicht')['ok'], 'Theme außerhalb des Katalogs kann nicht installiert werden'); + +set_setting('plugin_publisher_key', $pub); +$r = theme_catalog_install('nordlicht'); +$check($r['ok'] && $r['signed'] === true, 'mit Herausgeber-Schlüssel wird die Signatur geprüft'); +$again = theme_catalog_install('nordlicht'); +$check(!$again['ok'] && str_contains($again['error'], 'bereits'), 'ein bereits installiertes Theme lässt sich nicht noch einmal installieren'); +$state = theme_catalog_state($served['themes'][0]); +$check($state['installed'] && !$state['update'] && $state['version'] === '1.2.0', 'Katalog erkennt das Theme als installiert (gleicher Name, gleiche Version)'); +$check(!theme_catalog_state(['name' => 'Anderes Theme', 'version' => '1.0.0'])['installed'], 'ein nicht installiertes Theme wird nicht als installiert gezeigt'); +$check(theme_catalog_state(['name' => 'Nordlicht', 'version' => '1.3.0'])['update'] === true, 'eine neuere Katalogversion wird als Update erkannt'); +$themes = list_custom_themes(); +$check(count($themes) === 1 && $themes[0]['name'] === 'Nordlicht' && is_file($themesDir . '/' . $themes[0]['id'] . '/theme.css'), 'Theme liegt im Theme-Ordner und wird aufgelistet'); +$check($themes && $themes[0]['theme_version'] === '1.2.0' && $themes[0]['author'] === 'Studio', 'Version und Autor stammen aus theme.css'); +$check(get_setting('active_custom_theme', '') === '', 'das Theme wird nicht automatisch aktiviert'); + +foreach (list_custom_themes() as $__t) delete_custom_theme($__t['id']); +$served['themes'][0]['signature'] = base64_encode(random_bytes(256)); +$r = theme_catalog_install('nordlicht'); +$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'ungültige Signatur wird abgelehnt'); + +// Plugin-Signatur darf nicht als Theme-Signatur durchgehen +openssl_sign(plugin_signature_message('nordlicht', '1.2.0', $sha), $pluginSig, $priv, OPENSSL_ALGO_SHA256); +$served['themes'][0]['signature'] = base64_encode($pluginSig); +$check(!theme_catalog_install('nordlicht')['ok'], 'eine Plugin-Signatur gilt nicht für ein Theme'); + +$served['themes'][0]['signature'] = $sig; +$body = file_get_contents($zipPath) . 'x'; // Download wurde verändert +$r = theme_catalog_install('nordlicht'); +$check(!$r['ok'] && str_contains($r['error'], 'Prüfsumme'), 'veränderter Download wird abgelehnt'); +$body = file_get_contents($zipPath); + +$served['themes'][0]['signature'] = ''; +set_setting('plugin_require_signature', '1'); +$r = theme_catalog_install('nordlicht'); +$check(!$r['ok'] && str_contains($r['error'], 'müssen signiert sein'), '„Signatur erforderlich“ lehnt unsignierte Themes ab'); +set_setting('plugin_require_signature', '0'); + +$served['themes'][0]['downloadUrl'] = 'https://192.168.1.5/x.zip'; +$check(!theme_catalog_install('nordlicht')['ok'], 'Download von interner Adresse wird abgelehnt'); + +unset($GLOBALS['deskhub_http_get']); +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +$rm($themesDir); +@unlink($zipPath); +@unlink($cnf); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Theme catalog checks passed.\n"; diff --git a/DeskHub-Webseite/tests/ticket_access_integration.php b/DeskHub-Webseite/tests/ticket_access_integration.php new file mode 100644 index 00000000..b0228d4d --- /dev/null +++ b/DeskHub-Webseite/tests/ticket_access_integration.php @@ -0,0 +1,61 @@ +prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)') + ->execute(['Ticket Owner', 'ticket-owner@example.test', 'test-hash', $now]); + $ownerId = (int) $pdo->lastInsertId(); + $pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)') + ->execute(['Other User', 'other@example.test', 'test-hash', $now]); + $otherId = (int) $pdo->lastInsertId(); + + $departmentId = (int) $pdo->query('SELECT id FROM departments LIMIT 1')->fetchColumn(); + $priorityId = (int) $pdo->query('SELECT id FROM priorities LIMIT 1')->fetchColumn(); + $pdo->prepare( + 'INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id) + VALUES (?,?,?,?,?,?)' + )->execute(['TEST-ACCESS-1', 'Ticket access test', 'Ticket Owner', 'ticket-owner@example.test', $departmentId, $priorityId]); + $ticketId = (int) $pdo->lastInsertId(); + + $url = ticket_access_url(['id' => $ticketId, 'ticket_number' => 'TEST-ACCESS-1']); + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + $token = $query['token'] ?? ''; + if (!preg_match('/^[a-f0-9]{96}$/', $token)) { + throw new RuntimeException('Der E-Mail-Link enthält kein korrektes Zugriffstoken.'); + } + if (!verify_ticket_access_token($ticketId, $token)) { + throw new RuntimeException('Das generierte Zugriffstoken wird nicht akzeptiert.'); + } + if (verify_ticket_access_token($ticketId, str_repeat('0', 96))) { + throw new RuntimeException('Ein falsches Zugriffstoken wurde akzeptiert.'); + } + if (ticket_access_token($ticketId) !== $token) { + throw new RuntimeException('Das Ticket-Zugriffstoken ist nicht stabil.'); + } + + if (claim_ticket_for_customer($pdo, $ticketId, ['id' => $otherId, 'email' => 'other@example.test'])) { + throw new RuntimeException('Ein fremdes Kundenkonto konnte das Ticket beanspruchen.'); + } + if (!claim_ticket_for_customer($pdo, $ticketId, ['id' => $ownerId, 'email' => 'TICKET-OWNER@example.test'])) { + throw new RuntimeException('Der berechtigte Kunde konnte das Ticket nicht beanspruchen.'); + } + + $ownerCheck = $pdo->prepare('SELECT user_id FROM tickets WHERE id = ?'); + $ownerCheck->execute([$ticketId]); + if ((int) $ownerCheck->fetchColumn() !== $ownerId) { + throw new RuntimeException('Das Ticket wurde nicht dem richtigen Kundenkonto zugeordnet.'); + } + + echo "Ticket access integration checks passed.\n"; +} finally { + putenv($previousDbPath === false ? 'DESKHUB_SQLITE_PATH' : 'DESKHUB_SQLITE_PATH=' . $previousDbPath); +} diff --git a/DeskHub-Webseite/tests/update_security.php b/DeskHub-Webseite/tests/update_security.php new file mode 100644 index 00000000..f1a44647 --- /dev/null +++ b/DeskHub-Webseite/tests/update_security.php @@ -0,0 +1,105 @@ + $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); +openssl_pkey_export($k, $priv, null, ['config' => $cnf]); +$pub = openssl_pkey_get_details($k)['key']; +file_put_contents($tmp . '/privat.pem', $priv); +// Fremder Schlüssel (Angreifer) +$k2 = openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); +openssl_pkey_export($k2, $priv2, null, ['config' => $cnf]); +file_put_contents($tmp . '/fremd.pem', $priv2); +// Katalog-Vorgaben mit Herausgeber-Schlüssel für diesen Test +file_put_contents($tmp . '/defaults.php', " 'https://plugins.example.test/catalog.php?type=plugins', 'themes_url' => 'https://plugins.example.test/catalog.php?type=themes', 'access_key' => '', 'publisher_key' => " . var_export($pub, true) . "];"); +putenv('DESKHUB_CATALOG_DEFAULTS=' . $tmp . '/defaults.php'); + +require_once dirname(__DIR__) . '/includes/bootstrap.php'; + +$failures = []; +$check = function (bool $ok, string $what) use (&$failures): void { + if (!$ok) $failures[] = $what; +}; +$sign = function (string $zip, string $key, string $version): array { + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(dirname(__DIR__) . '/tools/release-sign.php') . ' ' . escapeshellarg($zip) . ' ' . escapeshellarg($key) . ' ' . escapeshellarg($version) . ' 2>&1', $out, $code); + return [$code, implode("\n", $out)]; +}; + +$check(update_signature_required(), 'Mit eingebautem Herausgeber-Schlüssel wird eine Signatur verlangt'); + +$zip = $tmp . '/deskhub-1.0.7.zip'; +$z = new ZipArchive(); $z->open($zip, ZipArchive::CREATE); $z->addFromString('version.php', 'close(); + +[$code, $out] = $sign($zip, $tmp . '/privat.pem', '1.0.7'); +$check($code === 0 && is_file($zip . '.sig'), 'Signier-Werkzeug erzeugt die .sig-Datei: ' . $out); +$sig = (string) @file_get_contents($zip . '.sig'); +$check(update_verify_package('1.0.7', $zip, $sig) === null, 'Gültige Signatur wird akzeptiert'); +$check(update_verify_package('v1.0.7', $zip, $sig) === null, 'Version mit führendem "v" passt ebenfalls'); +$check(update_verify_package('1.0.8', $zip, $sig) !== null, 'Signatur einer anderen Version wird abgelehnt (kein Herunterstufen/Umetikettieren)'); +$check(update_verify_package('1.0.7', $zip, '') !== null, 'Fehlende Signatur wird abgelehnt'); +$check(update_verify_package('1.0.7', $zip, 'keinebase64signatur') !== null, 'Kaputte Signatur wird abgelehnt'); + +// Paket verändert +$evil = $tmp . '/evil.zip'; +$z = new ZipArchive(); $z->open($evil, ZipArchive::CREATE); $z->addFromString('version.php', 'close(); +$check(update_verify_package('1.0.7', $evil, $sig) !== null, 'Verändertes Paket mit alter Signatur wird abgelehnt'); + +// Signatur mit fremdem Schlüssel +copy($zip, $tmp . '/zweit.zip'); +[$code] = $sign($tmp . '/zweit.zip', $tmp . '/fremd.pem', '1.0.7'); +$check($code === 0 && update_verify_package('1.0.7', $tmp . '/zweit.zip', (string) file_get_contents($tmp . '/zweit.zip.sig')) !== null, 'Signatur eines fremden Schlüssels wird abgelehnt'); + +// Plugin-Signatur darf nicht als Update-Signatur durchgehen (andere Nachricht) +openssl_sign(plugin_signature_message('deskhub', '1.0.7', hash_file('sha256', $zip)), $pluginSig, $k, OPENSSL_ALGO_SHA256); +$check(update_verify_package('1.0.7', $zip, base64_encode($pluginSig)) !== null, 'Eine Plugin-Signatur gilt nicht als Update-Signatur'); + +// Werkzeug: Eingabefehler +[$code] = $sign($zip, $tmp . '/privat.pem', 'kaputt'); +$check($code !== 0, 'Ungültige Versionsnummer wird vom Werkzeug abgelehnt'); +[$code] = $sign($tmp . '/gibt-es-nicht.zip', $tmp . '/privat.pem', '1.0.7'); +$check($code !== 0, 'Fehlende ZIP wird vom Werkzeug abgelehnt'); + +// Release-Anhänge finden (Gitea-Format) +$release = ['assets' => [ + ['name' => 'deskhub-1.0.7.zip', 'browser_download_url' => 'https://git.example.test/a/deskhub-1.0.7.zip'], + ['name' => 'deskhub-1.0.7.zip.sig', 'browser_download_url' => 'https://git.example.test/a/deskhub-1.0.7.zip.sig'], + ['name' => 'deskhub-1.0.6.zip', 'browser_download_url' => 'https://git.example.test/a/deskhub-1.0.6.zip'], +]]; +$a = update_find_assets($release, 'v1.0.7'); +$check($a['zip'] === 'https://git.example.test/a/deskhub-1.0.7.zip' && $a['sig'] === 'https://git.example.test/a/deskhub-1.0.7.zip.sig', 'Anhänge der richtigen Version werden gefunden'); +$a = update_find_assets(['assets' => [['name' => 'deskhub-1.0.7.zip', 'browser_download_url' => 'http://unsicher.example/x.zip']]], '1.0.7'); +$check($a['zip'] === null, 'Nur https-Adressen werden akzeptiert'); +$a = update_find_assets(['assets' => []], '1.0.7'); +$check($a['zip'] === null && $a['sig'] === null, 'Ohne Anhänge: nichts gefunden'); +$a = update_find_assets($release, '1.0.9'); +$check($a['zip'] === null, 'Anhänge anderer Versionen zählen nicht'); + +// Zugangstoken: nur aus der Konfiguration, nie eingebaut +$check(DESKHUB_GITEA_TOKEN === '', 'Im Programmcode steckt kein Zugangstoken mehr'); +$check(update_http_headers() === [], 'Ohne konfiguriertes Token wird kein Authorization-Header gesendet'); +$check(!str_contains((string) file_get_contents(dirname(__DIR__) . '/version.php'), 'openssl_decrypt'), 'version.php enthält keinen verschlüsselten Token mehr'); + +// Ohne Herausgeber-Schlüssel (reiner Eigenbetrieb) wird keine Signatur verlangt +putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php'); +$check(function_exists('plugin_publisher_key'), 'Hilfsfunktion vorhanden'); + +// Aufräumen +foreach (glob($tmp . '/*') ?: [] as $f) @unlink($f); +@rmdir($tmp); + +if ($failures) { + fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n"); + exit(1); +} +echo "Update security checks passed.\n"; diff --git a/DeskHub-Webseite/tests/web_smoke_integration.php b/DeskHub-Webseite/tests/web_smoke_integration.php new file mode 100644 index 00000000..2d6c05d5 --- /dev/null +++ b/DeskHub-Webseite/tests/web_smoke_integration.php @@ -0,0 +1,353 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$pdo->exec('PRAGMA foreign_keys = ON'); +$pdo->exec(convert_schema_to_sqlite($schema)); +$migrations = run_migrations($pdo, true); +if ($migrations['errors']) { + throw new RuntimeException('Frische SQLite-Migration fehlgeschlagen: ' . implode('; ', $migrations['errors'])); +} +$password = 'Web-Smoke-Password-42'; +$hash = password_hash($password, PASSWORD_DEFAULT); +$now = new DateTimeImmutable(); +$createdAt = $now->modify('-2 hours')->format('Y-m-d H:i:s'); +$respondedAt = $now->modify('-30 minutes')->format('Y-m-d H:i:s'); +$resolvedAt = $now->modify('-10 minutes')->format('Y-m-d H:i:s'); +$nowSql = $now->format('Y-m-d H:i:s'); + +// Hintergrundjobs sind nicht Teil dieses Seitentests; im Dev-Server würden sie die Antwort blockieren. +$pdo->exec("INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES ('pseudo_cron_enabled', '0')"); +$pdo->prepare('UPDATE staff SET password_hash = ?, is_active = 1 WHERE id = 1')->execute([$hash]); +$pdo->prepare( + "INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) + VALUES ('Web Agent', 'web-agent@example.test', ?, 'agent', 1, 1, ?)" +)->execute([$hash, $nowSql]); +$agentId = (int) $pdo->lastInsertId(); +$pdo->prepare( + "INSERT INTO users (name, email, password_hash, created_at) + VALUES ('Web Kunde', 'web-customer@example.test', ?, ?)" +)->execute([$hash, $nowSql]); +$userId = (int) $pdo->lastInsertId(); +$pdo->prepare( + "INSERT INTO tickets + (ticket_number, subject, user_id, requester_name, requester_email, department_id, + priority_id, assigned_staff_id, status, source, first_responded_at, resolution_due_at, + resolved_at, created_at, updated_at) + VALUES ('WEB-1001', 'Web-Smoke Ticket', ?, 'Web Kunde', 'web-customer@example.test', + 1, 2, ?, 'resolved', 'web', ?, ?, ?, ?, ?)" +)->execute([$userId, $agentId, $respondedAt, $resolvedAt, $resolvedAt, $createdAt, $nowSql]); +$ticketId = (int) $pdo->lastInsertId(); +$pdo->prepare( + "INSERT INTO ticket_threads + (ticket_id, author_type, user_id, message, is_internal_note, created_at) + VALUES (?, 'customer', ?, 'Realistische Smoke-Test-Anfrage', 0, ?)" +)->execute([$ticketId, $userId, $createdAt]); +$pdo->prepare( + "INSERT INTO activity_log (ticket_id, staff_id, action, created_at) + VALUES (?, ?, 'Mitarbeiter zugewiesen: Web Agent', ?)" +)->execute([$ticketId, $agentId, $createdAt]); +$pdo = null; + +$port = random_int(19000, 19999); +$command = [PHP_BINARY, '-d', 'max_execution_time=15', '-S', "127.0.0.1:$port", '-t', $root]; +$previousDbPath = getenv('DESKHUB_SQLITE_PATH'); +// Temporäres Plugin für den Seitentest (Plugin-Ordner des Projekts bleibt unberührt) +$smokePluginDir = sys_get_temp_dir() . '/deskhub-smoke-plugins-' . bin2hex(random_bytes(4)); +mkdir($smokePluginDir . '/smoke/pages', 0755, true); +file_put_contents($smokePluginDir . '/smoke/plugin.json', json_encode(['slug' => 'smoke', 'name' => 'Smoke', 'version' => '1.0.0', 'permissions' => ['ui.menu', 'api.endpoints']])); +file_put_contents($smokePluginDir . '/smoke/plugin.php', " ['pong' => true]);"); +file_put_contents($smokePluginDir . '/smoke/pages/hello.php', '

    Hallo aus dem Plugin

    '); +file_put_contents($smokePluginDir . '/smoke/pages/geheim.php', '

    nicht registriert

    '); +$smokePdo = new PDO('sqlite:' . $tempDb); +$smokePdo->exec("INSERT INTO plugins (slug, name, version, is_active, installed_at, approved_permissions) VALUES ('smoke', 'Smoke', '1.0.0', 1, datetime('now'), 'api.endpoints,ui.menu')"); +// Mitgeliefertes Plugin "Tagesbericht" mitladen, damit seine Einstellungsseite im echten Webserver geprüft wird +require_once __DIR__ . '/fixtures/paths.php'; +$__tagSrc = plugin_source_dir('tagesbericht'); +foreach ($__tagSrc === null ? [] : new RecursiveIteratorIterator(new RecursiveDirectoryIterator($__tagSrc, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $__f) { + $__dest = $smokePluginDir . '/tagesbericht/' . str_replace(chr(92), '/', substr(str_replace(chr(92), '/', $__f->getPathname()), strlen($__tagSrc) + 1)); + $__f->isDir() ? @mkdir($__dest, 0755, true) : (@mkdir(dirname($__dest), 0755, true) + copy($__f->getPathname(), $__dest)); +} +if ($__tagSrc !== null) $smokePdo->exec("INSERT INTO plugins (slug, name, version, is_active, installed_at, approved_permissions) VALUES ('tagesbericht', 'Tagesbericht', '1.0.0', 1, datetime('now'), 'hooks.cron,ui.menu')"); +$smokePdo = null; +putenv('DESKHUB_PLUGINS_DIR=' . $smokePluginDir); +// Offizieller Katalog (geprüfter Modus) für den Seitentest +$smokeCnf = tempnam(sys_get_temp_dir(), 'cnf'); +file_put_contents($smokeCnf, "[req] +distinguished_name=req_dn +[req_dn] +"); +$smokeKey = openssl_pkey_new(['config' => $smokeCnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); +$smokeDefaults = tempnam(sys_get_temp_dir(), 'cd'); +file_put_contents($smokeDefaults, ' 'E-Mail-Adressen', '/staff/email_addresses.php?new=1' => 'Abgehend', + '/staff/help_topics.php' => 'Hilfethemen', '/staff/help_topics.php?new=1' => 'Optionen bei neuem Ticket', + '/staff/departments.php' => 'Abteilungen', '/staff/departments.php?new=1' => 'Mitglieder', '/staff/departments.php?edit=1' => 'Abteilung aktualisieren', + '/staff/staff_manage.php' => 'Mitarbeiter', '/staff/staff_manage.php?new=1' => 'Berechtigungen', '/staff/staff_manage.php?edit=1' => 'Erweiterter Zugriff', + '/staff/teams.php' => 'Teams', '/staff/teams.php?new=1' => 'Teaminformationen', + '/staff/roles.php' => 'Rollen', '/staff/roles.php?new=1' => 'Rolleninformationen', + '/staff/priorities.php' => 'SLA', '/staff/priorities.php?new=1' => 'SLA-Zeiten', + '/staff/custom_lists.php' => 'Listen', '/staff/custom_lists.php?new=1' => 'Einträge', + '/staff/site_pages.php' => 'Seiten', '/staff/site_pages.php?new=1' => 'Seiteninformationen', + '/staff/business_hours.php' => 'Feiertage', '/staff/business_hours.php?tab=holidays' => 'Feiertage', + '/staff/update.php' => 'Datenbank aktualisieren', '/staff/plugins.php' => 'Plugin-Katalog', '/staff/backup.php' => 'Vorhandene Backups', '/staff/security_check.php' => 'Sicherheitsprüfung', '/staff/import_osticket.php' => 'osTicket Import', '/staff/ticket_rules.php' => 'Ticket-Regeln', '/staff/ticket_rules.php?new=1' => 'Regelinformationen', + ] as $path => $marker) { + $response = browser_request($port, 'GET', $path, $cookies); + assert_page($response, $marker); + foreach (['Fatal error', 'Warning:', 'Notice:', 'Deprecated:', 'Uncaught'] as $bad) { + if (stripos($response[1], $bad) !== false) { + throw new RuntimeException("Seite {$path} enthält eine PHP-Meldung ({$bad})."); + } + } + } + } + // Abteilung löschen, die noch gebraucht wird: Meldung statt HTTP 500 + if ($email === 'admin@example.com') { + $dp = browser_request($port, 'GET', '/staff/departments.php', $cookies); + preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $dp[1], $dm); + $del = browser_request($port, 'POST', '/staff/departments.php', $cookies, ['action' => 'delete', 'id' => '1', 'csrf_token' => $dm[1] ?? '']); + if ($del[0] !== 302) throw new RuntimeException('Löschen einer benutzten Abteilung liefert HTTP ' . $del[0] . ' statt einer Weiterleitung mit Meldung.'); + $after = browser_request($port, 'GET', '/staff/departments.php', $cookies); + if (stripos($after[1], 'kann nicht gelöscht werden') === false) throw new RuntimeException('Nach dem Löschversuch fehlt die verständliche Meldung.'); + } + // App-Download: Seite für alle Mitarbeiter, Datei nur mit Anmeldung + assert_page(browser_request($port, 'GET', '/staff/apps.php', $cookies), 'Android-App herunterladen'); + $dl = browser_request($port, 'GET', '/staff/app_download.php?platform=android', $cookies); + if ($dl[0] !== 200 || $dl[1] !== $smokeApk) throw new RuntimeException('App-Download liefert nicht die hinterlegte Datei.'); + if (stripos(implode(" +", $dl[2]), 'application/vnd.android.package-archive') === false) throw new RuntimeException('App-Download hat den falschen Dateityp.'); + if (browser_request($port, 'GET', '/staff/app_download.php?platform=windows', $cookies)[0] !== 404) throw new RuntimeException('Nicht hinterlegte App sollte 404 liefern.'); + if (browser_request($port, 'GET', '/staff/app_download.php?platform=../../config/config', $cookies)[0] !== 404) throw new RuntimeException('Unbekannte Plattform sollte 404 liefern.'); + $noCookies = []; + $anon = browser_request($port, 'GET', '/staff/app_download.php?platform=android', $noCookies); + if ($anon[0] !== 302 || $anon[1] === $smokeApk) throw new RuntimeException('Ohne Anmeldung darf die App nicht ladbar sein (Status ' . $anon[0] . ').'); + if (browser_request($port, 'GET', '/app_downloads/DeskHub.apk', $noCookies)[0] === 200) throw new RuntimeException('Der App-Ordner ist direkt per URL erreichbar.'); + if ($email === 'admin@example.com') { + assert_page(browser_request($port, 'GET', '/staff/plugin.php?p=smoke&page=hello', $cookies), 'Hallo aus dem Plugin'); + // Ohne Vorgaben: manueller Upload sichtbar, kein strenger Modus + $pl = browser_request($port, 'GET', '/staff/plugins.php', $cookies); + if (stripos($pl[1], 'Eigenes Plugin hochladen') === false || stripos($pl[1], 'Geprüfte Plugins') !== false) throw new RuntimeException('Plugin-Seite ohne offiziellen Katalog zeigt falschen Modus.'); + if ($__tagSrc !== null) assert_page(browser_request($port, 'GET', '/staff/plugin.php?p=tagesbericht&page=einstellungen', $cookies), 'Testbericht an mich senden'); + assert_page(browser_request($port, 'GET', '/staff/tickets.php?tab=all', $cookies), 'Smoke-Seite'); // Menüpunkt in der Navigation + foreach (['/staff/plugin.php?p=smoke&page=geheim', '/staff/plugin.php?p=smoke&page=../plugin', '/staff/plugin.php?p=unbekannt&page=hello'] as $blocked) { + if (browser_request($port, 'GET', $blocked, $cookies)[0] !== 404) throw new RuntimeException("Plugin-Seite {$blocked} sollte 404 liefern."); + } + // Mit offiziellem Katalog: geprüfter Modus, kein manueller Upload + file_put_contents($smokeDefaults, ' 'https://katalog.invalid/catalog.php?type=plugins', 'themes_url' => 'https://katalog.invalid/catalog.php?type=themes', 'publisher_key' => openssl_pkey_get_details($smokeKey)['key']], true) . ';'); + $pl = browser_request($port, 'GET', '/staff/plugins.php', $cookies); + if (stripos($pl[1], 'Geprüfte Plugins') === false || stripos($pl[1], 'Eigenes Plugin hochladen') === false || stripos($pl[1], 'Eigene Quellen erlauben') !== false) throw new RuntimeException('Plugin-Seite zeigt im geprüften Modus die falschen Bereiche.'); + file_put_contents($smokeDefaults, 'exec("UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE ticket_number = 'WEB-1001'"); + $lockPdo = null; + assert_page( + browser_request($port, 'GET', '/staff/ticket_view.php?n=WEB-1001', $cookies), + 'Web-Smoke Ticket' + ); + } + + $customerCookies = []; + login($port, '/login.php', 'web-customer@example.test', $password, $customerCookies, '/home.php'); + assert_page(browser_request($port, 'GET', '/home.php', $customerCookies), 'Meine Tickets'); + assert_page(browser_request($port, 'GET', '/dashboard.php', $customerCookies), 'WEB-1001'); + assert_page( + browser_request($port, 'GET', '/ticket_view.php?n=WEB-1001', $customerCookies), + 'Web-Smoke Ticket' + ); + + echo "Web smoke integration test passed for admin, agent, and customer.\n"; +} catch (Throwable $e) { + $serverLog = trim((string) @file_get_contents($serverLogFile)); + throw new RuntimeException( + $e->getMessage() . ($serverLog !== '' ? "\nPHP-Testserver:\n" . $serverLog : ''), + 0, + $e + ); +} finally { + proc_terminate($process); + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + proc_close($process); + @unlink($tempDb); + @unlink($serverLogFile); + @unlink($smokeAppsDir . '/DeskHub.apk'); @rmdir($smokeAppsDir); + @unlink($smokeDefaults); @unlink($smokeCnf); + foreach (['/smoke/pages/hello.php', '/smoke/pages/geheim.php', '/smoke/pages', '/smoke/plugin.json', '/smoke/plugin.php', '/smoke'] as $__p) { $__f = $smokePluginDir . $__p; is_dir($__f) ? @rmdir($__f) : @unlink($__f); } + if (is_dir($smokePluginDir)) { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($smokePluginDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $__f) { + $__f->isDir() ? @rmdir($__f->getPathname()) : @unlink($__f->getPathname()); + } + @rmdir($smokePluginDir); + } +} + +function wait_for_server(int $port): void +{ + for ($attempt = 0; $attempt < 40; $attempt++) { + $socket = @fsockopen('127.0.0.1', $port, $errno, $error, 0.1); + if ($socket) { + fclose($socket); + return; + } + usleep(100000); + } + throw new RuntimeException('PHP-Web-Testserver ist nicht erreichbar.'); +} + +function login( + int $port, + string $path, + string $email, + string $password, + array &$cookies, + string $expectedLocation +): void { + [$status, $body] = browser_request($port, 'GET', $path, $cookies); + if ($status !== 200 || !preg_match('/name="csrf_token" value="([^"]+)"/', $body, $match)) { + throw new RuntimeException("Loginformular {$path} konnte nicht geladen werden."); + } + [$status, , $headers] = browser_request($port, 'POST', $path, $cookies, [ + 'csrf_token' => html_entity_decode($match[1], ENT_QUOTES), + 'action' => 'login', + 'email' => $email, + 'password' => $password, + ]); + $location = response_header($headers, 'Location'); + if ($status !== 302 || $location !== $expectedLocation) { + throw new RuntimeException( + "Login für {$email} fehlgeschlagen (HTTP {$status}, Location " . ($location ?? 'fehlt') . ').' + ); + } +} + +function browser_request( + int $port, + string $method, + string $path, + array &$cookies, + ?array $form = null +): array { + $headers = ['Accept: text/html']; + if ($cookies) { + $cookiePairs = []; + foreach ($cookies as $name => $value) { + $cookiePairs[] = $name . '=' . $value; + } + $headers[] = 'Cookie: ' . implode('; ', $cookiePairs); + } + $content = null; + if ($form !== null) { + $content = http_build_query($form); + $headers[] = 'Content-Type: application/x-www-form-urlencoded'; + } + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headers), + 'content' => $content, + 'ignore_errors' => true, + 'follow_location' => 0, + 'timeout' => 15, + ], + ]); + $body = file_get_contents("http://127.0.0.1:$port$path", false, $context); + $responseHeaders = $http_response_header ?? []; + preg_match('/\s(\d{3})\s/', $responseHeaders[0] ?? '', $statusMatch); + foreach ($responseHeaders as $header) { + if (stripos($header, 'Set-Cookie:') !== 0) { + continue; + } + $cookie = trim(substr($header, strlen('Set-Cookie:'))); + [$pair] = explode(';', $cookie, 2); + [$name, $value] = array_pad(explode('=', $pair, 2), 2, ''); + $cookies[$name] = $value; + } + $status = (int) ($statusMatch[1] ?? 0); + echo "{$method} {$path} -> {$status}\n"; + return [$status, $body === false ? '' : $body, $responseHeaders]; +} + +function response_header(array $headers, string $name): ?string +{ + foreach ($headers as $header) { + if (stripos($header, $name . ':') === 0) { + return trim(substr($header, strlen($name) + 1)); + } + } + return null; +} + +function assert_page(array $response, string $marker): void +{ + [$status, $body] = $response; + if ($status !== 200) { + throw new RuntimeException("Seite mit Marker {$marker} liefert HTTP {$status}."); + } + if (stripos($body, $marker) === false) { + throw new RuntimeException("Erwarteter Seiteninhalt fehlt: {$marker}."); + } +} diff --git a/DeskHub-Webseite/ticket_lookup.php b/DeskHub-Webseite/ticket_lookup.php new file mode 100644 index 00000000..18bf43de --- /dev/null +++ b/DeskHub-Webseite/ticket_lookup.php @@ -0,0 +1,57 @@ +prepare('SELECT * FROM tickets WHERE ticket_number = ?'); + $stmt->execute([$number]); + $ticket = $stmt->fetch(); + + if ($ticket && hash_equals(strtolower($ticket['requester_email']), strtolower($email))) { + session_regenerate_id(true); + $_SESSION['guest_ticket_access'][$ticket['ticket_number']] = true; + redirect('/ticket_view.php?n=' . urlencode($ticket['ticket_number'])); + } + $errors[] = 'Ticketnummer und E-Mail-Adresse passen nicht zusammen. Bitte prüfen Sie Ihre Eingabe.'; + } +} + +$pageTitle = 'Ticket ansehen'; +$activePage = 'lookup'; +require __DIR__ . '/includes/header_customer.php'; +?> + + +
    > +
    + +

    Ticket ansehen

    +
    Ohne Konto: Geben Sie Ihre Ticketnummer und E-Mail-Adresse ein.
    + +
    + +
    + + + + + +
    + +
    +
    +
    +
    + +