Upload via GUI (258 Dateien)
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
$user = current_user();
|
||||
$pdo = get_db();
|
||||
$departments = departments_for_select($pdo);
|
||||
$priorities = $pdo->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';
|
||||
?>
|
||||
|
||||
<div class="ticket-create-page">
|
||||
<div class="topbar ticket-create-header">
|
||||
<div>
|
||||
<h1>Neues Ticket erstellen</h1>
|
||||
<p class="muted" style="margin:4px 0 0">Beschreiben Sie Ihr Anliegen so genau wie möglich – das hilft uns, schneller zu helfen.</p>
|
||||
</div>
|
||||
<a href="/dashboard.php" class="btn secondary" style="white-space:nowrap">Meine Tickets</a>
|
||||
</div>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="card ticket-create-card">
|
||||
<form method="post" enctype="multipart/form-data" class="ticket-create-form">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Honeypot -->
|
||||
<div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true">
|
||||
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<?php if (!$user): ?>
|
||||
<!-- Schritt 1: Kontaktdaten -->
|
||||
<div class="ticket-form-section">
|
||||
<p class="ticket-section-label"><span class="ticket-section-number">1</span>Ihre Kontaktdaten</p>
|
||||
<div class="field-row">
|
||||
<div>
|
||||
<label for="name">Name <span style="color:var(--danger)">*</span></label>
|
||||
<input type="text" id="name" name="name" value="<?= e($_POST['name'] ?? '') ?>" placeholder="Max Mustermann" required autofocus>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email">E-Mail-Adresse <span style="color:var(--danger)">*</span></label>
|
||||
<input type="email" id="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" placeholder="max@beispiel.de" required>
|
||||
</div>
|
||||
</div>
|
||||
<p class="ticket-form-hint">
|
||||
<a href="/register.php">Kostenloses Konto erstellen</a> um alle Ihre Tickets im Überblick zu behalten.
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Schritt 2: Anliegen -->
|
||||
<div class="ticket-form-section">
|
||||
<p class="ticket-section-label"><span class="ticket-section-number">2</span>Ihr Anliegen</p>
|
||||
|
||||
<?php if ($useHelpTopics): ?>
|
||||
<label for="help_topic_id">Kategorie <span style="color:var(--danger)">*</span></label>
|
||||
<select id="help_topic_id" name="help_topic_id" required onchange="updateTopicFieldVisibility()" style="margin-bottom:14px">
|
||||
<option value="">– bitte wählen –</option>
|
||||
<?php foreach ($helpTopics as $t): ?>
|
||||
<option value="<?= $t['id'] ?>" <?= (($_POST['help_topic_id'] ?? '') == $t['id']) ? 'selected' : '' ?>><?= e($t['path']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php else: ?>
|
||||
<div class="field-row" style="margin-bottom:14px">
|
||||
<div>
|
||||
<label for="department_id">Abteilung <span style="color:var(--danger)">*</span></label>
|
||||
<select id="department_id" name="department_id" required>
|
||||
<option value="">– bitte wählen –</option>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= $d['id'] ?>" <?= (($_POST['department_id'] ?? '') == $d['id']) ? 'selected' : '' ?>><?= e($d['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="priority_id">Priorität <span style="color:var(--danger)">*</span></label>
|
||||
<select id="priority_id" name="priority_id" required>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= (($_POST['priority_id'] ?? '') == $p['id'] || (!isset($_POST['priority_id']) && $p['name']==='Normal')) ? 'selected' : '' ?>><?= e($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<label for="subject">Betreff <span style="color:var(--danger)">*</span></label>
|
||||
<input type="text" id="subject" name="subject" value="<?= e($_POST['subject'] ?? '') ?>" placeholder="Kurze Zusammenfassung Ihres Problems" required style="margin-bottom:14px">
|
||||
|
||||
<label for="message">Beschreibung <span style="color:var(--danger)">*</span></label>
|
||||
<textarea id="message" name="message" required placeholder="Beschreiben Sie Ihr Anliegen so detailliert wie möglich…" style="min-height:140px"><?= e($_POST['message'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php if ($customFields): ?>
|
||||
<div class="ticket-form-section">
|
||||
<p class="ticket-section-label"><span class="ticket-section-number">3</span>Zusätzliche Angaben</p>
|
||||
<?php foreach ($customFields as $f):
|
||||
$fieldName = 'custom_' . $f['id'];
|
||||
$posted = $_POST[$fieldName] ?? '';
|
||||
?>
|
||||
<div class="custom-field-group" data-topic="<?= $f['help_topic_id'] !== null ? (int) $f['help_topic_id'] : '' ?>">
|
||||
<label for="<?= $fieldName ?>"><?= e($f['label']) ?><?= $f['is_required'] ? ' <span style="color:var(--danger)">*</span>' : '' ?></label>
|
||||
<?php if ($f['field_type'] === 'textarea'): ?>
|
||||
<textarea id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>><?= e($posted) ?></textarea>
|
||||
<?php elseif ($f['field_type'] === 'select'): ?>
|
||||
<select id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
||||
<option value="">– bitte wählen –</option>
|
||||
<?php foreach (preg_split('/\r?\n/', trim((string) $f['options'])) as $opt): $opt = trim($opt); if ($opt === '') continue; ?>
|
||||
<option value="<?= e($opt) ?>" <?= $posted === $opt ? 'selected' : '' ?>><?= e($opt) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php elseif ($f['field_type'] === 'checkbox'): ?>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:4px">
|
||||
<input type="checkbox" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="1" style="width:auto" <?= !empty($posted) ? 'checked' : '' ?>>
|
||||
<span><?= e($f['label']) ?></span>
|
||||
</label>
|
||||
<?php elseif ($f['field_type'] === 'number'): ?>
|
||||
<input type="number" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
||||
<?php elseif ($f['field_type'] === 'date'): ?>
|
||||
<input type="date" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
||||
<?php else: ?>
|
||||
<input type="text" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Anhang -->
|
||||
<div class="ticket-form-section ticket-attachment-section">
|
||||
<p class="ticket-section-label"><span class="ticket-section-number"><?= $customFields ? '4' : '3' ?></span>Anhang <span class="ticket-section-optional">(optional)</span></p>
|
||||
<label for="attachment" style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border:1.5px dashed var(--border);border-radius:var(--radius);cursor:pointer;font-size:.85rem;color:var(--muted);font-weight:400;transition:border-color .15s" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span id="att-label">Datei auswählen</span>
|
||||
</label>
|
||||
<input type="file" id="attachment" name="attachment" style="display:none" onchange="document.getElementById('att-label').textContent=this.files[0]?this.files[0].name:'Datei auswählen'">
|
||||
</div>
|
||||
|
||||
<div class="ticket-form-actions">
|
||||
<p class="ticket-form-hint">Pflichtfelder sind mit <span class="ticket-required">*</span> gekennzeichnet.</p>
|
||||
<button type="submit" class="btn ticket-submit">Ticket erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($useHelpTopics): ?>
|
||||
<script>
|
||||
function updateTopicFieldVisibility() {
|
||||
var select = document.getElementById('help_topic_id');
|
||||
var selectedTopic = select ? select.value : '';
|
||||
document.querySelectorAll('.custom-field-group').forEach(function (group) {
|
||||
var groupTopic = group.getAttribute('data-topic');
|
||||
var matches = groupTopic === '' || groupTopic === selectedTopic;
|
||||
group.style.display = matches ? '' : 'none';
|
||||
group.querySelectorAll('input, select, textarea').forEach(function (el) {
|
||||
if (!matches) {
|
||||
if (el.required) { el.dataset.wasRequired = '1'; el.required = false; }
|
||||
} else if (el.dataset.wasRequired === '1') {
|
||||
el.required = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', updateTopicFieldVisibility);
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$pdo = get_db();
|
||||
$number = $_GET['n'] ?? '';
|
||||
$known = max(0, (int) ($_GET['count'] ?? 0));
|
||||
|
||||
$stmt = $pdo->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]);
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* ticket_print.php
|
||||
* Druckansicht für Kunden – ohne Navigation.
|
||||
*/
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
$pdo = get_db();
|
||||
$number = $_GET['n'] ?? '';
|
||||
|
||||
$stmt = $pdo->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']);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Ticket #<?= e($ticket['ticket_number']) ?></title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 11pt; color: #1e293b; background: #fff; padding: 20mm; }
|
||||
.print-header { display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 12px; border-bottom: 2px solid #0f6f6a; margin-bottom: 16px; }
|
||||
.print-header .logo { font-size: 18pt; font-weight: 800; color: #0f6f6a; }
|
||||
.print-header .meta { text-align: right; font-size: 9pt; color: #64748b; }
|
||||
.ticket-title { font-size: 15pt; font-weight: 700; margin-bottom: 12px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 24px; margin-bottom: 20px; padding: 10px 14px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
|
||||
.info-row { display: flex; gap: 8px; font-size: 9.5pt; }
|
||||
.info-label { font-weight: 600; color: #64748b; min-width: 90px; }
|
||||
.section-title { font-size: 10pt; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #64748b; margin: 16px 0 10px; border-bottom: 1px solid #e2e8f0; padding-bottom: 4px; }
|
||||
.message { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 10px; break-inside: avoid; }
|
||||
.message-header { display: flex; justify-content: space-between; padding: 7px 12px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; font-size: 9pt; font-weight: 600; }
|
||||
.message-header.staff { background: #e6f4f3; }
|
||||
.message-body { padding: 10px 12px; font-size: 10pt; line-height: 1.6; }
|
||||
.attachments { padding: 5px 12px 7px; border-top: 1px solid #e2e8f0; font-size: 9pt; color: #64748b; }
|
||||
.print-footer { margin-top: 20px; padding-top: 8px; border-top: 1px solid #e2e8f0; font-size: 8.5pt; color: #94a3b8; display: flex; justify-content: space-between; }
|
||||
@media print { .no-print { display: none !important; } @page { margin: 15mm; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="no-print" style="position:fixed;top:16px;right:16px;display:flex;gap:8px">
|
||||
<button onclick="window.print()" style="padding:8px 16px;background:#0f6f6a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:.85rem">🖨 Drucken</button>
|
||||
<a href="/ticket_view.php?n=<?= urlencode($number) ?>" style="padding:8px 14px;border:1px solid #e2e8f0;border-radius:6px;text-decoration:none;color:#64748b;font-size:.85rem;background:#fff">← Zurück</a>
|
||||
</div>
|
||||
|
||||
<div class="print-header">
|
||||
<div class="logo"><?= e($siteName) ?></div>
|
||||
<div class="meta">Gedruckt: <?= date('d.m.Y H:i') ?></div>
|
||||
</div>
|
||||
|
||||
<div class="ticket-title"><?= e($ticket['subject']) ?></div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-row"><span class="info-label">Ticket-Nr.:</span><span>#<?= e($ticket['ticket_number']) ?></span></div>
|
||||
<div class="info-row"><span class="info-label">Status:</span><span><?= e($statusLabel) ?></span></div>
|
||||
<div class="info-row"><span class="info-label">Abteilung:</span><span><?= e($ticket['department_name']) ?></span></div>
|
||||
<div class="info-row"><span class="info-label">Erstellt:</span><span><?= date('d.m.Y H:i', strtotime($ticket['created_at'])) ?></span></div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Nachrichtenverlauf</div>
|
||||
|
||||
<?php foreach ($messages as $msg):
|
||||
$isStaff = $msg['author_type'] === 'staff';
|
||||
$author = $isStaff ? ($msg['staff_name'] ?? 'Support') : ($msg['user_name'] ?? $ticket['requester_name']);
|
||||
$body = $msg['message'] ?? '';
|
||||
$isHtml = preg_match('/<[a-zA-Z][^>]*>/i', $body);
|
||||
$attStmt->execute([$msg['id']]);
|
||||
$atts = $attStmt->fetchAll();
|
||||
?>
|
||||
<div class="message">
|
||||
<div class="message-header <?= $isStaff ? 'staff' : '' ?>">
|
||||
<span><?= e($author) ?><?= $isStaff ? ' (Support)' : '' ?></span>
|
||||
<span style="color:#64748b"><?= date('d.m.Y H:i', strtotime($msg['created_at'])) ?></span>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<?php if ($isHtml): ?>
|
||||
<?= sanitize_staff_html($body) ?>
|
||||
<?php else: ?>
|
||||
<?php foreach (preg_split('/\n{2,}/', trim($body)) as $p): $p = trim($p); if ($p): ?>
|
||||
<p style="margin:0 0 8px"><?= nl2br(e($p)) ?></p>
|
||||
<?php endif; endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($atts): ?>
|
||||
<div class="attachments">📎 <?= implode(', ', array_map(fn($a) => e($a['original_name']), $atts)) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="print-footer">
|
||||
<span><?= e($siteName) ?> – Ticket #<?= e($ticket['ticket_number']) ?></span>
|
||||
<span><?= date('d.m.Y') ?></span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
$pdo = get_db();
|
||||
$number = $_GET['n'] ?? '';
|
||||
|
||||
$stmt = $pdo->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']);
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1><?= e($ticket['subject']) ?></h1>
|
||||
<span class="ticket-no">#<?= e($ticket['ticket_number']) ?></span>
|
||||
<span class="badge" style="background:<?= $statusColor ?>"><?= e($statusLabel) ?></span>
|
||||
<span class="badge" style="background:<?= e($ticket['priority_color']) ?>"><?= e($ticket['priority_name']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="muted"><?= e($ticket['department_name']) ?> · erstellt am <?= format_dt($ticket['created_at']) ?></p>
|
||||
|
||||
<?php if (!$user): ?>
|
||||
<div class="card">
|
||||
<strong>Ticket dauerhaft in Ihrem Konto speichern?</strong>
|
||||
<p class="muted">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.</p>
|
||||
<a href="/login.php">Anmelden</a> · <a href="/register.php">Konto erstellen</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($customFieldValues): ?>
|
||||
<div class="card" style="font-size:.88rem">
|
||||
<?php foreach ($customFieldValues as $fv): ?>
|
||||
<div style="padding:4px 0"><strong><?= e($fv['label']) ?>:</strong>
|
||||
<?= $fv['field_type'] === 'checkbox' ? ($fv['value'] ? 'Ja' : 'Nein') : e($fv['value']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php foreach ($thread as $msg):
|
||||
$attStmt->execute([$msg['id']]);
|
||||
$attachments = $attStmt->fetchAll();
|
||||
$isStaff = $msg['author_type'] === 'staff';
|
||||
?>
|
||||
<div class="thread-msg <?= $isStaff ? 'from-staff' : '' ?>">
|
||||
<div class="meta">
|
||||
<span><strong><?= e($isStaff ? ($msg['staff_name'] ?? 'Support-Team') : ($msg['user_name'] ?? $ticket['requester_name'])) ?></strong> <?= $isStaff ? '(Support)' : '' ?></span>
|
||||
<span><?= format_dt($msg['created_at']) ?></span>
|
||||
</div>
|
||||
<div class="body"><?= $isStaff ? $msg['message'] : nl2br(e($msg['message'])) ?></div>
|
||||
<?php if ($attachments): ?>
|
||||
<div class="body" style="padding-top:0">
|
||||
<?php foreach ($attachments as $a): ?>
|
||||
<a href="/download.php?id=<?= $a['id'] ?>">📎 <?= e($a['original_name']) ?></a><br>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="display:flex; justify-content:flex-end; margin-bottom:12px">
|
||||
<a href="/ticket_print.php?n=<?= urlencode($ticket['ticket_number']) ?>" target="_blank" style="background:none; border:1px solid var(--border); border-radius:var(--radius); padding:7px 14px; font-size:.82rem; cursor:pointer; color:var(--muted); display:flex; align-items:center; gap:6px; text-decoration:none">🖨️ Ticket drucken</a>
|
||||
</div>
|
||||
|
||||
<?php if ($ticket['status'] !== 'closed'): ?>
|
||||
<div class="card" id="reply-form">
|
||||
<h3>Antworten</h3>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<?= csrf_field() ?>
|
||||
<textarea name="message" placeholder="Ihre Nachricht..." required></textarea>
|
||||
<label for="attachments">Anhänge (optional, mehrere möglich)</label>
|
||||
<input type="file" id="attachments" name="attachments[]" multiple>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Antwort senden</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="muted">Dieses Ticket ist geschlossen. Bei weiteren Fragen erstellen Sie bitte ein <a href="/ticket_new.php">neues Ticket</a>.</p>
|
||||
|
||||
<?php
|
||||
$ratingsEnabled = get_setting('ticket_ratings_enabled', '1') === '1';
|
||||
$existingRating = null;
|
||||
if ($ratingsEnabled) {
|
||||
$rs = get_db()->prepare('SELECT * FROM ticket_ratings WHERE ticket_id=?');
|
||||
$rs->execute([$ticket['id']]);
|
||||
$existingRating = $rs->fetch();
|
||||
}
|
||||
if ($ratingsEnabled):
|
||||
?>
|
||||
<div class="card" style="margin-top:16px; text-align:center; padding:28px">
|
||||
<?php if ($existingRating): ?>
|
||||
<div style="font-size:2rem; margin-bottom:8px"><?= str_repeat('⭐', $existingRating['rating']) ?><?= str_repeat('☆', 5 - $existingRating['rating']) ?></div>
|
||||
<div style="font-weight:600; color:var(--ink)">Danke für Ihre Bewertung!</div>
|
||||
<?php if ($existingRating['comment']): ?>
|
||||
<p class="muted" style="font-size:.85rem; margin-top:6px"><?= e($existingRating['comment']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div style="font-size:1rem; font-weight:600; margin-bottom:16px; color:var(--ink)">Wie zufrieden waren Sie mit der Bearbeitung?</div>
|
||||
<form method="post" id="rating-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="rate_ticket">
|
||||
<input type="hidden" name="rating" id="rating-value" value="0">
|
||||
<div class="star-rating" style="display:flex; justify-content:center; gap:8px; margin-bottom:16px">
|
||||
<?php for ($i = 1; $i <= 5; $i++): ?>
|
||||
<button type="button" onclick="setRating(<?= $i ?>)" id="star-<?= $i ?>"
|
||||
style="background:none; border:none; font-size:2.2rem; cursor:pointer; transition:transform .1s; line-height:1">☆</button>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<textarea name="rating_comment" placeholder="Kommentar (optional)" rows="2"
|
||||
style="width:100%; max-width:400px; margin-bottom:12px; font-size:.85rem"></textarea><br>
|
||||
<button type="submit" class="btn" id="rating-submit" disabled>Bewertung abschicken</button>
|
||||
</form>
|
||||
<script>
|
||||
function setRating(val) {
|
||||
document.getElementById('rating-value').value = val;
|
||||
document.getElementById('rating-submit').disabled = false;
|
||||
for (var i = 1; i <= 5; i++) {
|
||||
document.getElementById('star-' + i).textContent = i <= val ? '⭐' : '☆';
|
||||
document.getElementById('star-' + i).style.transform = i === val ? 'scale(1.2)' : 'scale(1)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var lastCount = <?= count($thread) ?>;
|
||||
var ticketNumber = <?= json_encode($ticket['ticket_number']) ?>;
|
||||
function checkUpdates() {
|
||||
fetch('/ticket_poll.php?n=' + encodeURIComponent(ticketNumber) + '&count=' + lastCount)
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){
|
||||
if (data.new_count > lastCount) {
|
||||
// Neue Nachrichten vorhanden – Seite sanft neu laden
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
setInterval(checkUpdates, 15000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
.site-header, .site-header *, footer.public-footer, #reply-form, .topbar nav, .btn, .btn-row { display: none !important; }
|
||||
.thread-msg { break-inside: avoid; border: 1px solid #ccc !important; margin-bottom: 12px !important; }
|
||||
body { background: white !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
/**
|
||||
* Signier-Werkzeug für Plugin-/Theme-Herausgeber (nur Kommandozeile, nicht Teil des Webbereichs).
|
||||
*
|
||||
* php tools/plugin-sign.php keygen <ordner>
|
||||
* 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 <paket.zip> <privater-schluessel.pem>
|
||||
* 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 <theme.zip> <privater-schluessel.pem> <name> <version>
|
||||
* Dasselbe für Themes (Name und Version werden mitsigniert, da Themes keine plugin.json haben).
|
||||
*
|
||||
* Signiert wird: "deskhub-plugin\n<name>\n<version>\n<sha256 der ZIP>" 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 <ordner>');
|
||||
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 <ordner>\n php tools/plugin-sign.php sign <paket.zip> <privater-schluessel.pem>\n php tools/plugin-sign.php sign-theme <theme.zip> <privater-schluessel.pem> <name> <version>");
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* Signiert ein DeskHub-Release für das System-Update (nur Kommandozeile, nicht Teil des Webbereichs).
|
||||
*
|
||||
* php tools/release-sign.php <deskhub-1.0.7.zip> <herausgeber-privat.pem> <version>
|
||||
*
|
||||
* Schreibt <deskhub-1.0.7.zip>.sig neben das Paket. Beide Dateien (ZIP und .sig) werden als Anhänge des Releases
|
||||
* hochgeladen, mit genau diesen Namen: deskhub-<version>.zip und deskhub-<version>.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<version>\n<sha256 der ZIP>" 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 <paket.zip> <privater-schluessel.pem> <version>');
|
||||
$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";
|
||||
@@ -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).
|
||||
<FilesMatch "\.(php|php3|php4|php5|php7|php8|phtml|phar|pl|py|cgi|asp|aspx|jsp|sh|bash|exe|htaccess|htpasswd|env|html?|xhtml|shtml|svgz?|xml|js|mjs)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
<IfModule mod_php7.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
<IfModule mod_php8.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
Header set Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||
</IfModule>
|
||||
Options -Indexes
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
if (defined('DESKHUB_VERSION')) return;
|
||||
|
||||
define('DESKHUB_VERSION', '1.0.6');
|
||||
define('DESKHUB_RELEASE_URL', 'https://git.viper.ipv64.net/M_Viper/deskhub/releases');
|
||||
|
||||
// Kein eingebautes Zugangstoken: Das Update liest die öffentlichen Releases. Ist das Repository privat, kann ein Token
|
||||
// in config/config.php unter 'update' => ['token' => '…'] eingetragen werden (nur Leserecht vergeben).
|
||||
define('DESKHUB_GITEA_TOKEN', '');
|
||||
Reference in New Issue
Block a user