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';
+?>
+
+
+
+
+
+
= e($err) ?>
+
+
+
+
+
+
+
+
+
+
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']) ?>
+
+
+
+
+
+
+
+
+= e($ticket['subject']) ?>
+
+
+
Ticket-Nr.: #= e($ticket['ticket_number']) ?>
+
Status: = e($statusLabel) ?>
+
Abteilung: = e($ticket['department_name']) ?>
+
Erstellt: = date('d.m.Y H:i', strtotime($ticket['created_at'])) ?>
+
+
+Nachrichtenverlauf
+
+]*>/i', $body);
+ $attStmt->execute([$msg['id']]);
+ $atts = $attStmt->fetchAll();
+?>
+
+
+
+
+ = sanitize_staff_html($body) ?>
+
+
+
= nl2br(e($p)) ?>
+
+
+
+
+
📎 = implode(', ', array_map(fn($a) => 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']);
+?>
+
+
+
+
= e($ticket['subject']) ?>
+ #= e($ticket['ticket_number']) ?>
+ = e($statusLabel) ?>
+ = e($ticket['priority_name']) ?>
+
+
+
+= e($ticket['department_name']) ?> · erstellt am = format_dt($ticket['created_at']) ?>
+
+
+
+
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
+
+
+
+
+
+
+
= e($fv['label']) ?>:
+ = $fv['field_type'] === 'checkbox' ? ($fv['value'] ? 'Ja' : 'Nein') : e($fv['value']) ?>
+
+
+
+
+
+ = e($err) ?>
+
+
+execute([$msg['id']]);
+ $attachments = $attStmt->fetchAll();
+ $isStaff = $msg['author_type'] === 'staff';
+?>
+
+
+ = e($isStaff ? ($msg['staff_name'] ?? 'Support-Team') : ($msg['user_name'] ?? $ticket['requester_name'])) ?> = $isStaff ? '(Support)' : '' ?>
+ = format_dt($msg['created_at']) ?>
+
+
= $isStaff ? $msg['message'] : nl2br(e($msg['message'])) ?>
+
+
+
+
+
+
+
+
+
+
+
+ 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):
+ ?>
+
+
+
= str_repeat('⭐', $existingRating['rating']) ?>= str_repeat('☆', 5 - $existingRating['rating']) ?>
+
Danke für Ihre Bewertung!
+
+
= e($existingRating['comment']) ?>
+
+
+
Wie zufrieden waren Sie mit der Bearbeitung?
+
+ = csrf_field() ?>
+
+
+
+
+ ☆
+
+
+
+ Bewertung abschicken
+
+
+
+
+
+
+
+
+
+
+
+
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', '');