From 57f52d5a9ffabb9914f4283cec4aaf54835e566a Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 16 Oct 2026 19:12:28 +0000 Subject: [PATCH] Upload via GUI (258 Dateien) --- DeskHub-Webseite/ticket_new.php | 378 ++++++++++++++++++++++++ DeskHub-Webseite/ticket_poll.php | 37 +++ DeskHub-Webseite/ticket_print.php | 129 ++++++++ DeskHub-Webseite/ticket_view.php | 287 ++++++++++++++++++ DeskHub-Webseite/tools/plugin-sign.php | 71 +++++ DeskHub-Webseite/tools/release-sign.php | 26 ++ DeskHub-Webseite/uploads/.htaccess | 19 ++ DeskHub-Webseite/version.php | 9 + 8 files changed, 956 insertions(+) create mode 100644 DeskHub-Webseite/ticket_new.php create mode 100644 DeskHub-Webseite/ticket_poll.php create mode 100644 DeskHub-Webseite/ticket_print.php create mode 100644 DeskHub-Webseite/ticket_view.php create mode 100644 DeskHub-Webseite/tools/plugin-sign.php create mode 100644 DeskHub-Webseite/tools/release-sign.php create mode 100644 DeskHub-Webseite/uploads/.htaccess create mode 100644 DeskHub-Webseite/version.php diff --git a/DeskHub-Webseite/ticket_new.php b/DeskHub-Webseite/ticket_new.php new file mode 100644 index 00000000..484f456f --- /dev/null +++ b/DeskHub-Webseite/ticket_new.php @@ -0,0 +1,378 @@ +query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll(); +$helpTopics = help_topic_options($pdo, true); +$useHelpTopics = count($helpTopics) > 0; +// Ohne Hilfe-Themen gibt es keine Themenauswahl auf dem Formular, daher nur themenunabhängige Felder anzeigen. +$customFields = $useHelpTopics + ? $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 ORDER BY sort_order, label')->fetchAll() + : $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND help_topic_id IS NULL ORDER BY sort_order, label')->fetchAll(); + +$errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + + // Honeypot: für Menschen unsichtbares Feld, das leer bleiben muss. Bots füllen + // häufig alle Formularfelder aus, daher dient dies als einfache Spam-Bremse. + if (!empty($_POST['website'])) { + flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'); + redirect('/ticket_new.php'); + } + if (!$user && !rate_limit_check('ticket_create', client_ip(), 8, 60)) { + $errors[] = 'Sie haben in kurzer Zeit zu viele Tickets erstellt. Bitte versuchen Sie es später erneut.'; + } + + $name = trim($_POST['name'] ?? ($user['name'] ?? '')); + $email = trim($_POST['email'] ?? ($user['email'] ?? '')); + $subject = trim($_POST['subject'] ?? ''); + $message = trim($_POST['message'] ?? ''); + + // Gesperrte Absender: Ticket wird wie beim Honeypot kommentarlos abgewiesen, + // damit nicht erkennbar ist, dass die Adresse gezielt blockiert wurde. + if ($email !== '' && is_banned($email)) { + flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'); + redirect('/ticket_new.php'); + } + + $helpTopicId = null; + $deptId = null; + $prioId = null; + + if ($useHelpTopics) { + $helpTopicId = (int) ($_POST['help_topic_id'] ?? 0); + $topic = null; + foreach ($helpTopics as $t) { + if ($t['id'] == $helpTopicId) { $topic = $t; break; } + } + if (!$topic) { + $errors[] = 'Bitte wählen Sie ein Anliegen aus.'; + } else { + $deptId = (int) $topic['department_id']; + $prioId = (int) $topic['priority_id']; + } + } else { + $deptId = (int) ($_POST['department_id'] ?? 0); + $prioId = (int) ($_POST['priority_id'] ?? 0); + if (!in_array($deptId, array_column($departments, 'id'))) $errors[] = 'Bitte wählen Sie eine Abteilung.'; + if (!in_array($prioId, array_column($priorities, 'id'))) $errors[] = 'Bitte wählen Sie eine Priorität.'; + } + + if ($name === '') $errors[] = 'Bitte geben Sie Ihren Namen an.'; + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.'; + if ($subject === '') $errors[] = 'Bitte geben Sie einen Betreff an.'; + if ($message === '') $errors[] = 'Bitte beschreiben Sie Ihr Anliegen.'; + + // Individuelle Felder validieren (nur Felder, die zum gewählten Hilfe-Thema gehören oder themenunabhängig sind) + $customValues = []; // field id => submitted value + foreach ($customFields as $f) { + if ($f['help_topic_id'] !== null && (int) $f['help_topic_id'] !== (int) $helpTopicId) continue; + $fieldName = 'custom_' . $f['id']; + $val = $f['field_type'] === 'checkbox' ? (!empty($_POST[$fieldName]) ? '1' : '0') : trim($_POST[$fieldName] ?? ''); + if ($f['is_required'] && $f['field_type'] !== 'checkbox' && $val === '') { + $errors[] = 'Bitte füllen Sie das Feld „' . $f['label'] . '“ aus.'; + } + $customValues[$f['id']] = $val; + } + + // Datei-Upload (optional, ein Anhang) + $uploadInfo = null; + if (!$errors && !empty($_FILES['attachment']['name'])) { + $uploadInfo = handle_ticket_upload($_FILES['attachment']); + if (isset($uploadInfo['error'])) { + $errors[] = $uploadInfo['error']; + } + } + + if (!$errors) { + // Ticket-Regeln auswerten: koennen Abteilung/Prioritaet/Hilfe-Thema vor dem Anlegen + // ueberschreiben, das Ticket abweisen, oder nach dem Anlegen einen Mitarbeiter zuweisen/Tags setzen. + $ruleResult = evaluate_ticket_rules($pdo, [ + 'subject' => $subject, + 'message' => $message, + 'requester_email' => $email, + 'requester_name' => $name, + 'source' => 'web', + ]); + if ($ruleResult['reject']) { + flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'); + redirect('/ticket_new.php'); + } + 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']; + + $priority = null; + foreach ($priorities as $p) { + if ($p['id'] == $prioId) { $priority = $p; break; } + } + $sla = calc_sla_dates($priority, null, (int) $deptId, !empty($helpTopicId) ? (int) $helpTopicId : null); + + $stmt = $pdo->prepare( + 'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, + help_topic_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + ); + $ticketNumber = generate_ticket_number($pdo); + $now = db_now(); + // IP kürzen wenn Anonymisierung aktiv + $clientIp = client_ip(); + if (get_setting('ip_anonymize', '0') === '1') { + $clientIp = preg_replace('/\.(\d+)$/', '.0', $clientIp); + $clientIp = preg_replace('/:[0-9a-f]{1,4}$/', ':0', $clientIp); + } + $stmt->execute([ + $ticketNumber, $subject, $user['id'] ?? null, $name, $email, $deptId, + $helpTopicId, $prioId, 'open', 'web', $clientIp, $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now, + ]); + $ticketId = (int) $pdo->lastInsertId(); + + $stmt = $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) + VALUES (?,?,?,?,0,?)' + ); + $stmt->execute([$ticketId, 'customer', $user['id'] ?? null, $message, $now]); + $threadId = (int) $pdo->lastInsertId(); + + if ($uploadInfo) { + $stmt = $pdo->prepare( + 'INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)' + ); + $stmt->execute([$threadId, $uploadInfo['original_name'], $uploadInfo['stored_name'], $uploadInfo['filesize'], $now]); + } + + if ($customValues) { + $cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)'); + foreach ($customValues as $fieldId => $val) { + if ($val === '' ) continue; + $cfStmt->execute([$ticketId, $fieldId, $val]); + } + } + + log_activity($pdo, $ticketId, null, 'Ticket erstellt'); + + // Verbleibende Regel-Aktionen, die eine vorhandene Ticket-ID benötigen + $assignTo = $ruleResult['assign_staff']; + if ($assignTo === null) { + // Auto-Zuweisung: war der Kunde schon mal einem Mitarbeiter zugewiesen? + $prevStmt = $pdo->prepare( + "SELECT assigned_staff_id FROM tickets + WHERE LOWER(requester_email) = LOWER(?) + AND assigned_staff_id IS NOT NULL + AND status NOT IN ('closed','resolved') + AND id != ? + ORDER BY updated_at DESC LIMIT 1" + ); + $prevStmt->execute([$email, $ticketId]); + $prevId = $prevStmt->fetchColumn(); + if ($prevId) { + $assignTo = (int) $prevId; + log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (letzter Bearbeiter)'); + } + } else { + log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Ticket-Regel)'); + } + if ($assignTo !== null) { + $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]); + } + foreach ($ruleResult['add_tags'] as $tagName) { + apply_tag_to_ticket($pdo, $ticketId, $tagName); + } + if ($ruleResult['matched_rules']) { + log_activity($pdo, $ticketId, null, 'Ticket-Regel angewendet: ' . implode(', ', $ruleResult['matched_rules'])); + } + apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']); + + $deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?'); + $deptStmt->execute([$deptId]); + $dept = $deptStmt->fetch(); + $ticketForNotify = [ + 'ticket_number' => $ticketNumber, + 'subject' => $subject, + 'requester_name' => $name, + 'requester_email' => $email, + 'department_id' => $deptId, + 'department_name' => $dept['name'] ?? null, + ]; + notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null); + notify_staff_new_ticket($ticketForNotify); + webhook_dispatch($pdo, 'ticket.created', $ticketId); + if (file_exists(__DIR__ . '/includes/push.php')) { + require_once __DIR__ . '/includes/push.php'; + push_new_ticket($ticketForNotify); + } + notify_customer_ticket_created($ticketForNotify); + + $_SESSION['guest_ticket_access'][$ticketNumber] = true; + flash('success', "Ihr Ticket #{$ticketNumber} wurde erstellt. Wir melden uns so schnell wie möglich."); + redirect('/ticket_view.php?n=' . urlencode($ticketNumber)); + } +} + +$pageTitle = 'Neues Ticket'; +$activePage = 'new'; +require __DIR__ . '/includes/header_customer.php'; +?> + +
+
+
+

Neues Ticket erstellen

+

Beschreiben Sie Ihr Anliegen so genau wie möglich – das hilft uns, schneller zu helfen.

+
+ Meine Tickets +
+ + +
+ + +
+
+ + + + + + +
+ +
+
+ + +
+
+ + +
+
+

+ Kostenloses Konto erstellen um alle Ihre Tickets im Überblick zu behalten. +

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

Pflichtfelder sind mit * gekennzeichnet.

+ +
+
+
+
+ + + + + + diff --git a/DeskHub-Webseite/ticket_poll.php b/DeskHub-Webseite/ticket_poll.php new file mode 100644 index 00000000..401ff72d --- /dev/null +++ b/DeskHub-Webseite/ticket_poll.php @@ -0,0 +1,37 @@ +prepare( + 'SELECT t.id, t.user_id, t.ticket_number FROM tickets t WHERE t.ticket_number = ?' +); +$stmt->execute([$number]); +$ticket = $stmt->fetch(); + +if (!$ticket) { + echo json_encode(['new_count' => $known]); + exit; +} + +// Zugriffsprüfung wie in ticket_view.php +$user = current_user(); +$allowed = ($user && $ticket['user_id'] == $user['id']) + || !empty($_SESSION['guest_ticket_access'][$ticket['ticket_number']]); + +if (!$allowed) { + echo json_encode(['new_count' => $known]); + exit; +} + +$stmt = $pdo->prepare( + 'SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = ? AND is_internal_note = 0' +); +$stmt->execute([$ticket['id']]); +$count = (int) $stmt->fetchColumn(); + +echo json_encode(['new_count' => $count]); diff --git a/DeskHub-Webseite/ticket_print.php b/DeskHub-Webseite/ticket_print.php new file mode 100644 index 00000000..bbff68a6 --- /dev/null +++ b/DeskHub-Webseite/ticket_print.php @@ -0,0 +1,129 @@ +prepare( + 'SELECT t.*, p.name AS priority_name, d.name AS department_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + WHERE t.ticket_number = ?' +); +$stmt->execute([$number]); +$ticket = $stmt->fetch(); + +if (!$ticket) { http_response_code(404); die('Ticket nicht gefunden.'); } + +// Zugangsprüfung: eingeloggter Kunde oder Gast-Zugang +$user = current_user(); +$allowed = ($user && $ticket['user_id'] == $user['id']) + || !empty($_SESSION['guest_ticket_access'][$ticket['ticket_number']]); + +if (!$allowed) { + redirect('/ticket_lookup.php?n=' . urlencode($number)); +} + +// Nur öffentliche Nachrichten +$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(); + +$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']) ?> + + + + +
+ + ← Zurück +
+ + + +
+ +
+
Ticket-Nr.:#
+
Status:
+
Abteilung:
+
Erstellt:
+
+ +
Nachrichtenverlauf
+ +]*>/i', $body); + $attStmt->execute([$msg['id']]); + $atts = $attStmt->fetchAll(); +?> +
+
+ + +
+
+ + + + +

+ + +
+ +
📎 e($a['original_name']), $atts)) ?>
+ +
+ + + + + diff --git a/DeskHub-Webseite/ticket_view.php b/DeskHub-Webseite/ticket_view.php new file mode 100644 index 00000000..8da56818 --- /dev/null +++ b/DeskHub-Webseite/ticket_view.php @@ -0,0 +1,287 @@ +prepare( + 'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + WHERE t.ticket_number = ?' +); +$stmt->execute([$number]); +$ticket = $stmt->fetch(); + +if (!$ticket) { + flash('error', 'Dieses Ticket wurde nicht gefunden.'); + redirect('/home.php'); +} + +$user = current_user(); +if (verify_ticket_access_token((int) $ticket['id'], (string) ($_GET['token'] ?? ''))) { + if ($user) { + claim_ticket_for_customer($pdo, (int) $ticket['id'], $user); + } + $_SESSION['guest_ticket_access'][$ticket['ticket_number']] = true; + session_write_close(); + redirect('/ticket_view.php?n=' . urlencode($ticket['ticket_number'])); +} + +$allowed = ($user && $ticket['user_id'] == $user['id']) + || !empty($_SESSION['guest_ticket_access'][$ticket['ticket_number']]); + +if (!$allowed) { + flash('error', 'Bitte melden Sie sich an oder bestätigen Sie Ihre E-Mail-Adresse, um dieses Ticket zu sehen.'); + redirect('/ticket_lookup.php?n=' . urlencode($number)); +} + +$errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // Ticket-Bewertung (auch bei geschlossenem Ticket erlaubt) + if (($_POST['action'] ?? '') === 'rate_ticket') { + verify_csrf(); + $rating = (int)($_POST['rating'] ?? 0); + $comment = trim($_POST['rating_comment'] ?? ''); + if ($rating >= 1 && $rating <= 5 && get_setting('ticket_ratings_enabled', '1') === '1') { + $ratingSql = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' + ? 'INSERT INTO ticket_ratings (ticket_id, user_id, rating, comment, created_at) VALUES (?,?,?,?,?) + ON CONFLICT(ticket_id) DO UPDATE SET + user_id=excluded.user_id, rating=excluded.rating, + comment=excluded.comment, created_at=excluded.created_at' + : 'INSERT INTO ticket_ratings (ticket_id, user_id, rating, comment, created_at) VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE + user_id=VALUES(user_id), rating=VALUES(rating), + comment=VALUES(comment), created_at=VALUES(created_at)'; + $pdo->prepare($ratingSql) + ->execute([$ticket['id'], $user['id'] ?? null, $rating, $comment ?: null, db_now()]); + flash('success', 'Vielen Dank für Ihre Bewertung!'); + } + redirect('/ticket_view.php?n=' . urlencode($ticket['ticket_number'])); + } +} +if ($_SERVER['REQUEST_METHOD'] === 'POST' && !in_array($ticket['status'], ['closed'])) { + verify_csrf(); + $message = trim($_POST['message'] ?? ''); + if ($message === '') { + $errors[] = 'Bitte geben Sie eine Nachricht ein.'; + } else { + $now = db_now(); + $stmt = $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) + VALUES (?,?,?,?,0,?)' + ); + $stmt->execute([$ticket['id'], 'customer', $user['id'] ?? null, $message, $now]); + $threadId = (int) $pdo->lastInsertId(); + + if (!empty($_FILES['attachments']['name'][0])) { + $uploadResults = handle_ticket_uploads_multi($_FILES['attachments']); + foreach ($uploadResults 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]); + } + } + } + + // Ein Kunden-Reply auf ein beantwortetes Ticket setzt den Status zurück auf "Offen" + $wasReopened = in_array($ticket['status'], ['answered', 'resolved']); + $newStatus = $wasReopened ? 'open' : $ticket['status']; + $stmt = $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?'); + $stmt->execute([$newStatus, $now, $ticket['id']]); + + log_activity($pdo, $ticket['id'], null, 'Kunde hat geantwortet'); + if ($wasReopened) { + log_activity($pdo, $ticket['id'], null, 'Ticket wieder geöffnet'); + } + notify_new_reply_channels($ticket, $message); + webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'customer']); + if ($newStatus !== $ticket['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => $ticket['status']]); + flash('success', 'Ihre Antwort wurde übermittelt.'); + redirect('/ticket_view.php?n=' . urlencode($number)); + } +} + +// Thread 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 = ? AND tt.is_internal_note = 0 + ORDER BY tt.created_at ASC" +); +$stmt->execute([$ticket['id']]); +$thread = $stmt->fetchAll(); + +// Anhänge je Thread-Eintrag +$attStmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE thread_id = ?'); + +// Individuelle Felder, die bei der Ticket-Erstellung ausgefüllt wurden +$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(); + +$pageTitle = "Ticket #{$ticket['ticket_number']}"; +$activePage = 'dashboard'; +require __DIR__ . '/includes/header_customer.php'; +[$statusLabel, $statusColor] = status_label($ticket['status']); +?> + +
+
+

+ # + + +
+
+ +

· erstellt am

+ + +
+ Ticket dauerhaft in Ihrem Konto speichern? +

Melden Sie sich an oder erstellen Sie ein Konto mit derselben E-Mail-Adresse. Dieses Ticket bleibt in diesem Browser freigeschaltet und kann danach in „Meine Tickets“ hinzugefügt werden.

+ Anmelden · Konto erstellen +
+ + + +
+ +
: +
+ +
+ + + +
+ + +execute([$msg['id']]); + $attachments = $attStmt->fetchAll(); + $isStaff = $msg['author_type'] === 'staff'; +?> +
+
+ + +
+
+ +
+ + 📎
+ +
+ +
+ + +
+ 🖨️ Ticket drucken +
+ + +
+

Antworten

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

Dieses Ticket ist geschlossen. Bei weiteren Fragen erstellen Sie bitte ein neues Ticket.

+ + prepare('SELECT * FROM ticket_ratings WHERE ticket_id=?'); + $rs->execute([$ticket['id']]); + $existingRating = $rs->fetch(); + } + if ($ratingsEnabled): + ?> +
+ +
+
Danke für Ihre Bewertung!
+ +

+ + +
Wie zufrieden waren Sie mit der Bearbeitung?
+
+ + + +
+ + + +
+
+ +
+ + +
+ + + + + + + + diff --git a/DeskHub-Webseite/tools/plugin-sign.php b/DeskHub-Webseite/tools/plugin-sign.php new file mode 100644 index 00000000..72593c5d --- /dev/null +++ b/DeskHub-Webseite/tools/plugin-sign.php @@ -0,0 +1,71 @@ + + * Erzeugt ein RSA-Schlüsselpaar (3072 Bit): herausgeber-privat.pem (GEHEIM, nie weitergeben) und herausgeber-oeffentlich.pem. + * Den öffentlichen Schlüssel tragen Admins in DeskHub unter Plugins → „Herausgeber-Schlüssel“ ein. + * + * php tools/plugin-sign.php sign + * Gibt SHA-256 und Signatur (Base64) eines Plugin-Pakets aus. Das Paket muss eine plugin.json mit "slug" und "version" enthalten. + * + * php tools/plugin-sign.php sign-theme + * Dasselbe für Themes (Name und Version werden mitsigniert, da Themes keine plugin.json haben). + * + * Signiert wird: "deskhub-plugin\n\n\n" mit RSA und SHA-256. Das ist exakt das, was DeskHub prüft. + */ +if (PHP_SAPI !== 'cli') { http_response_code(404); exit; } + +function fail(string $m): never { fwrite(STDERR, $m . PHP_EOL); exit(1); } + +/** Auf Windows fehlt oft eine openssl.cnf; eine minimale Datei genügt zum Erzeugen von Schlüsseln. */ +function openssl_options(): array +{ + $cnf = tempnam(sys_get_temp_dir(), 'cnf'); + file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n"); + return ['config' => $cnf]; +} + +$cmd = $argv[1] ?? ''; +if ($cmd === 'keygen') { + $dir = $argv[2] ?? fail('Aufruf: php tools/plugin-sign.php keygen '); + if (!is_dir($dir) && !mkdir($dir, 0700, true)) fail('Ordner konnte nicht angelegt werden.'); + $opts = openssl_options(); + $key = openssl_pkey_new($opts + ['private_key_bits' => 3072, 'private_key_type' => OPENSSL_KEYTYPE_RSA]) ?: fail('Schlüssel konnte nicht erzeugt werden: ' . openssl_error_string()); + openssl_pkey_export($key, $private, null, $opts); + $public = openssl_pkey_get_details($key)['key']; + file_put_contents($dir . '/herausgeber-privat.pem', $private); + @chmod($dir . '/herausgeber-privat.pem', 0600); + file_put_contents($dir . '/herausgeber-oeffentlich.pem', $public); + echo "Fertig.\n Privat (GEHEIM, sicher aufbewahren): {$dir}/herausgeber-privat.pem\n Öffentlich (an Admins weitergeben): {$dir}/herausgeber-oeffentlich.pem\n"; + exit(0); +} + +if ($cmd === 'sign' || $cmd === 'sign-theme') { + $zip = $argv[2] ?? fail('ZIP-Datei fehlt.'); + $keyFile = $argv[3] ?? fail('Privater Schlüssel fehlt.'); + if (!is_file($zip)) fail('ZIP-Datei nicht gefunden.'); + $private = openssl_pkey_get_private((string) @file_get_contents($keyFile)) ?: fail('Der private Schlüssel konnte nicht gelesen werden.'); + if ($cmd === 'sign') { + $z = new ZipArchive(); + $z->open($zip) === true || fail('ZIP konnte nicht geöffnet werden.'); + $json = null; + for ($i = 0; $i < $z->numFiles; $i++) { + $n = str_replace('\\', '/', (string) $z->getNameIndex($i)); + if ($n === 'plugin.json' || preg_match('#^[^/]+/plugin\.json$#', $n)) { $json = json_decode((string) $z->getFromIndex($i), true); break; } + } + $z->close(); + if (!is_array($json) || empty($json['slug']) || empty($json['version'])) fail('plugin.json mit "slug" und "version" nicht gefunden.'); + $name = (string) $json['slug']; + $version = (string) $json['version']; + } else { + $name = 'theme-' . ($argv[4] ?? fail('Name (slug) fehlt.')); + $version = $argv[5] ?? fail('Version fehlt.'); + } + $sha = hash_file('sha256', $zip); + openssl_sign("deskhub-plugin\n{$name}\n{$version}\n{$sha}", $signature, $private, OPENSSL_ALGO_SHA256) || fail('Signieren fehlgeschlagen.'); + echo "name: {$name}\nversion: {$version}\nsha256: {$sha}\nsignature: " . base64_encode($signature) . "\n"; + exit(0); +} + +fail("Aufruf:\n php tools/plugin-sign.php keygen \n php tools/plugin-sign.php sign \n php tools/plugin-sign.php sign-theme "); diff --git a/DeskHub-Webseite/tools/release-sign.php b/DeskHub-Webseite/tools/release-sign.php new file mode 100644 index 00000000..3558eb01 --- /dev/null +++ b/DeskHub-Webseite/tools/release-sign.php @@ -0,0 +1,26 @@ + + * + * Schreibt .sig neben das Paket. Beide Dateien (ZIP und .sig) werden als Anhänge des Releases + * hochgeladen, mit genau diesen Namen: deskhub-.zip und deskhub-.zip.sig. + * Verwendet wird derselbe private Schlüssel wie für Plugins und Themes (der öffentliche Teil steckt in + * includes/catalog_defaults.php). Signiert wird: "deskhub-update\n\n" mit RSA und SHA-256. + */ +if (PHP_SAPI !== 'cli') { http_response_code(404); exit; } + +function fail(string $m): never { fwrite(STDERR, $m . PHP_EOL); exit(1); } + +$zip = $argv[1] ?? fail('Aufruf: php tools/release-sign.php '); +$keyFile = $argv[2] ?? fail('Privater Schlüssel fehlt.'); +$version = ltrim(trim($argv[3] ?? fail('Version fehlt (z. B. 1.0.7).')), 'vV'); +if (!is_file($zip)) fail('ZIP-Datei nicht gefunden.'); +if (!preg_match('/^\d+\.\d+\.\d+([\-+][0-9A-Za-z.\-]+)?$/', $version)) fail('Die Version muss wie 1.0.7 aussehen.'); +if (basename($zip) !== "deskhub-{$version}.zip") fwrite(STDERR, "Hinweis: Der Dateiname sollte deskhub-{$version}.zip lauten, sonst findet DeskHub das Paket nicht.\n"); +$private = openssl_pkey_get_private((string) @file_get_contents($keyFile)) ?: fail('Der private Schlüssel konnte nicht gelesen werden.'); +$sha = hash_file('sha256', $zip); +if (!openssl_sign("deskhub-update\n{$version}\n{$sha}", $signature, $private, OPENSSL_ALGO_SHA256)) fail('Signieren fehlgeschlagen: ' . openssl_error_string()); +file_put_contents($zip . '.sig', base64_encode($signature)); +echo "Fertig.\n Paket: {$zip}\n SHA-256: {$sha}\n Signatur: {$zip}.sig\nBeide Dateien als Anhänge ans Release {$version} hängen.\n"; diff --git a/DeskHub-Webseite/uploads/.htaccess b/DeskHub-Webseite/uploads/.htaccess new file mode 100644 index 00000000..33a3715a --- /dev/null +++ b/DeskHub-Webseite/uploads/.htaccess @@ -0,0 +1,19 @@ +# Verhindert die Ausführung von hochgeladenen Dateien (z.B. falls jemand eine +# .php-Datei mit falscher/umgangener Endungsprüfung hochladen würde). + + Require all denied + + + php_flag engine off + + + php_flag engine off + + + php_flag engine off + + + Header set X-Content-Type-Options "nosniff" + Header set Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; sandbox" + +Options -Indexes diff --git a/DeskHub-Webseite/version.php b/DeskHub-Webseite/version.php new file mode 100644 index 00000000..3d839796 --- /dev/null +++ b/DeskHub-Webseite/version.php @@ -0,0 +1,9 @@ + ['token' => '…'] eingetragen werden (nur Leserecht vergeben). +define('DESKHUB_GITEA_TOKEN', '');