Files
2026-11-05 21:21:44 +00:00

2428 lines
115 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Allgemeine Hilfsfunktionen
*/
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
}
function redirect(string $path): void
{
// Nur relative Pfade erlauben – verhindert open redirects
if (!str_starts_with($path, '/') && !str_starts_with($path, 'http')) {
$path = '/' . $path;
}
// Externe URLs nur auf gleiche Domain erlauben
if (str_starts_with($path, 'http')) {
$host = parse_url($path, PHP_URL_HOST);
if ($host !== ($_SERVER['HTTP_HOST'] ?? '')) {
$path = '/'; // Auf Home umleiten statt externer URL
}
}
header('Location: ' . $path);
exit;
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' . e(csrf_token()) . '">';
}
function check_honeypot(): void
{
// Wenn das versteckte Feld ausgefüllt wurde, ist es ein Bot
if (!empty($_POST['hp_website'])) {
http_response_code(400);
die('Bad Request.');
}
}
/** Stimmt der Formular-Token mit dem der Sitzung überein? (ohne Abbruch, für Anmeldeseiten mit freundlicher Meldung) */
function csrf_valid(): bool
{
$token = $_POST['csrf_token'] ?? '';
return $token !== '' && hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
function verify_csrf(): void
{
$token = $_POST['csrf_token'] ?? '';
if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
http_response_code(403);
die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.');
}
// Der Token gilt für die ganze Sitzung und wird NICHT nach jeder Benutzung erneuert: Sonst würden alle anderen offenen Tabs desselben
// Browsers beim nächsten Absenden "CSRF-Token ungültig" melden (typisch bei mehreren Tickets in Tabs). Er wird beim Anmelden neu erzeugt.
}
function flash(string $type, string $message): void
{
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
}
function get_flashes(): array
{
$flashes = $_SESSION['flash'] ?? [];
unset($_SESSION['flash']);
return $flashes;
}
/**
* Merkt sich zusätzlich zur normalen (HTML-escapten) Flash-Meldung, welches Ticket gerade
* durch einen anderen Mitarbeiter gesperrt war. flash() allein kann keinen Button
* enthalten (die Nachricht wird per e() ausgegeben) – Admins bekommen darüber direkt
* unter der Fehlermeldung eine "Sperre jetzt aufheben"-Aktion angezeigt (siehe
* staff/_header.php), damit sie nicht bis zum automatischen Timeout warten müssen.
* Wird wie flash() einmalig ausgelesen und danach verworfen.
*/
function flash_locked_ticket(string $ticketNumber, string $lockedByName): void
{
$_SESSION['locked_ticket_override'] = ['number' => $ticketNumber, 'name' => $lockedByName];
}
function get_locked_ticket_override(): ?array
{
$data = $_SESSION['locked_ticket_override'] ?? null;
unset($_SESSION['locked_ticket_override']);
return $data;
}
/** Erzeugt eine eindeutige, gut lesbare Ticketnummer, z.B. 251023-0001 */
function generate_ticket_number(PDO $pdo): string
{
$prefix = date('ymd');
do {
$number = $prefix . '-' . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE ticket_number = ?');
$stmt->execute([$number]);
$exists = (int) $stmt->fetchColumn() > 0;
} while ($exists);
return $number;
}
function claim_ticket_for_customer(PDO $pdo, int $ticketId, array $user): bool
{
$userId = (int) ($user['id'] ?? 0);
$email = trim((string) ($user['email'] ?? ''));
if ($ticketId < 1 || $userId < 1 || $email === '') {
return false;
}
$stmt = $pdo->prepare(
'UPDATE tickets SET user_id = ?
WHERE id = ? AND (user_id IS NULL OR user_id = ?) AND LOWER(requester_email) = LOWER(?)'
);
$stmt->execute([$userId, $ticketId, $userId, $email]);
return $stmt->rowCount() === 1;
}
/**
* Liefert ein dauerhaftes, zufälliges Zugriffstoken für einen Ticket-Link.
* Das Klartext-Token wird verschlüsselt gespeichert und nur sein Hash zur Prüfung verwendet.
*/
function ticket_access_token(int $ticketId): string
{
$pdo = get_db();
$stmt = $pdo->prepare(
'SELECT token_hash, token_enc FROM ticket_access_tokens
WHERE ticket_id = ? AND revoked_at IS NULL ORDER BY id DESC LIMIT 1'
);
$stmt->execute([$ticketId]);
$stored = $stmt->fetch();
if ($stored) {
$token = decrypt_secret($stored['token_enc']);
if (preg_match('/^[a-f0-9]{96}$/', $token) && hash_equals($stored['token_hash'], hash('sha256', $token))) {
return $token;
}
}
$token = bin2hex(random_bytes(48));
$pdo->prepare(
'INSERT INTO ticket_access_tokens (ticket_id, token_hash, token_enc, created_at)
VALUES (?,?,?,?)'
)->execute([$ticketId, hash('sha256', $token), encrypt_secret($token), db_now()]);
return $token;
}
function verify_ticket_access_token(int $ticketId, string $token): bool
{
if (!preg_match('/^[a-f0-9]{96}$/', $token)) {
return false;
}
$stmt = get_db()->prepare(
'SELECT token_hash FROM ticket_access_tokens
WHERE ticket_id = ? AND token_hash = ? AND revoked_at IS NULL LIMIT 1'
);
$stmt->execute([$ticketId, hash('sha256', $token)]);
$storedHash = $stmt->fetchColumn();
return is_string($storedHash) && hash_equals($storedHash, hash('sha256', $token));
}
function ticket_access_url(array $ticket): string
{
$ticketId = (int) ($ticket['id'] ?? 0);
if ($ticketId < 1) {
$stmt = get_db()->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
$stmt->execute([$ticket['ticket_number'] ?? '']);
$ticketId = (int) $stmt->fetchColumn();
}
if ($ticketId < 1) {
throw new RuntimeException('Ticket für den Kunden-Zugriffslink nicht gefunden.');
}
return base_url() . '/ticket_view.php?n=' . urlencode($ticket['ticket_number'])
. '&token=' . urlencode(ticket_access_token($ticketId));
}
/**
* Berechnet SLA-Fälligkeiten anhand einer Prioritäts-Zeile (response_hours/resolution_hours).
*
* Stunden werden auf max. 5 Jahre gekappt (SLA_MAX_HOURS): ein versehentlich riesiger oder
* negativer Wert bei einer Priorität (z.B. Millionen statt normaler Stundenzahl, oder ein
* Tippfehler mit zu vielen Nullen) würde sonst ein Datum weit außerhalb dessen ergeben, was
* die DATETIME-Spalte speichern kann (MySQL wirft dann eine harte SQL-Exception und die
* komplette Ticket-/Chat-Erstellung schlägt fehl – live an genau dieser Stelle beobachtet).
*/
function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentId = null, ?int $helpTopicId = null): array
{
$slaMaxHours = 43800; // 5 Jahre – weit über jedem sinnvollen SLA-Wert, aber sicher innerhalb DATETIME-Grenzen
$from = $from ?? db_now();
// Ein zugeordneter SLA-Plan (Hilfethema vor Abteilung) ersetzt die Zeiten der Priorität
if ($plan = sla_plan_for(get_db(), $departmentId, $helpTopicId)) {
$priority['response_hours'] = $plan['response_hours'];
$priority['resolution_hours'] = $plan['resolution_hours'];
}
$responseHours = max(0, min($slaMaxHours, (int) $priority['response_hours']));
$resolutionHours = max(0, min($slaMaxHours, (int) $priority['resolution_hours']));
if (get_setting('business_hours_enabled', '0') === '1') {
return [
'first_response_due_at' => add_business_hours($from, $responseHours),
'resolution_due_at' => add_business_hours($from, $resolutionHours),
];
}
return [
'first_response_due_at' => add_hours($from, $responseHours),
'resolution_due_at' => add_hours($from, $resolutionHours),
];
}
function ticket_sla_is_paused_status(string $status): bool
{
return in_array($status, ['pending', 'on_hold'], true);
}
function ticket_sla_history_add(PDO $pdo, int $ticketId, string $event, ?string $deadlineType, string $details, ?int $staffId = null, ?string $at = null): void
{
try {
$pdo->prepare('INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,staff_id,created_at) VALUES (?,?,?,?,?,?)')
->execute([$ticketId, $event, $deadlineType, $details, $staffId, $at ?? db_now()]);
} catch (Throwable $e) {
error_log('DeskHub SLA-Verlauf konnte nicht gespeichert werden: ' . $e->getMessage());
}
}
/** Seconds that count against an SLA during the interval, honoring business hours and holidays. */
function ticket_sla_working_seconds_between(string $from, string $to): int
{
$start = strtotime($from); $end = strtotime($to);
if ($start === false || $end === false || $end <= $start) return 0;
if (get_setting('business_hours_enabled', '0') !== '1') return $end - $start;
$pdo = get_db(); $hours = [];
try {
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable $e) { error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage()); return $end - $start; }
$zone = new DateTimeZone(date_default_timezone_get());
$day = (new DateTimeImmutable('@' . $start))->setTimezone($zone)->setTime(0, 0);
$lastDay = (new DateTimeImmutable('@' . $end))->setTimezone($zone)->setTime(0, 0);
$seconds = 0; $iterations = 0;
while ($day <= $lastDay && $iterations++ < 36600) {
$w = (int) $day->format('w'); $cfg = $hours[$w] ?? null; $date = $day->format('Y-m-d');
if ($cfg && !empty($cfg['is_open']) && !isset($holidays[$date])) {
$open = (new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone))->getTimestamp();
$close = (new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone))->getTimestamp();
$seconds += max(0, min($end, $close) - max($start, $open));
}
$day = $day->modify('+1 day');
}
return $seconds;
}
function ticket_sla_add_business_seconds(string $from, int $seconds): string
{
if ($seconds <= 0) return $from;
$pdo = get_db(); $hours = [];
try {
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable $e) {
error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage());
return (new DateTimeImmutable($from))->modify('+' . $seconds . ' seconds')->format('Y-m-d H:i:s');
}
$zone = new DateTimeZone(date_default_timezone_get());
$cursor = new DateTimeImmutable($from, $zone); $iterations = 0;
while ($seconds > 0 && $iterations++ < 36600) {
$date = $cursor->format('Y-m-d'); $cfg = $hours[(int) $cursor->format('w')] ?? null;
if (!$cfg || empty($cfg['is_open']) || isset($holidays[$date])) {
$cursor = $cursor->modify('+1 day')->setTime(0, 0); continue;
}
$open = new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone);
$close = new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone);
if ($cursor < $open) $cursor = $open;
if ($cursor >= $close) { $cursor = $cursor->modify('+1 day')->setTime(0, 0); continue; }
$available = $close->getTimestamp() - $cursor->getTimestamp();
if ($seconds <= $available) { $cursor = $cursor->modify('+' . $seconds . ' seconds'); $seconds = 0; }
else { $seconds -= $available; $cursor = $cursor->modify('+1 day')->setTime(0, 0); }
}
return $cursor->format('Y-m-d H:i:s');
}
function ticket_sla_shift_deadline(string $deadline, int $pauseSeconds): string
{
if ($pauseSeconds <= 0) return $deadline;
if (get_setting('business_hours_enabled', '0') === '1') {
return ticket_sla_add_business_seconds($deadline, $pauseSeconds);
}
return (new DateTimeImmutable($deadline))->modify('+' . $pauseSeconds . ' seconds')->format('Y-m-d H:i:s');
}
function ticket_sla_completed_pause_seconds(PDO $pdo, int $ticketId): int
{
try {
$stmt = $pdo->prepare("SELECT event_type,created_at FROM ticket_sla_history WHERE ticket_id = ? AND event_type IN ('paused','resumed') ORDER BY created_at,id");
$stmt->execute([$ticketId]);
$started = null; $total = 0;
foreach ($stmt->fetchAll() as $event) {
if ($event['event_type'] === 'paused') $started = (string) $event['created_at'];
elseif ($started !== null) { $total += ticket_sla_working_seconds_between($started, (string) $event['created_at']); $started = null; }
}
return $total;
} catch (Throwable $e) { error_log('DeskHub SLA-Pausenzeiten nicht lesbar: ' . $e->getMessage()); return 0; }
}
/** Startet die SLA eines wieder geöffneten Tickets neu: neue Lösungsfrist ab jetzt, Überfällig-Markierung weg. */
function ticket_sla_restart(PDO $pdo, int $ticketId, ?int $staffId = null): void
{
$q = $pdo->prepare('SELECT t.priority_id, t.department_id, t.help_topic_id FROM tickets t WHERE t.id = ?');
$q->execute([$ticketId]);
$t = $q->fetch();
if (!$t) return;
$p = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$p->execute([(int) $t['priority_id']]);
$priority = $p->fetch();
if (!$priority) { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); return; }
$now = db_now();
$dates = calc_sla_dates($priority, $now, (int) $t['department_id'] ?: null, (int) $t['help_topic_id'] ?: null);
$pdo->prepare('UPDATE tickets SET resolution_due_at = ?, is_overdue = 0, sla_paused_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$dates['resolution_due_at'], $ticketId]);
ticket_sla_history_add($pdo, $ticketId, 'restarted', 'resolution', 'SLA neu gestartet: Ticket wurde wieder geöffnet', $staffId, $now);
}
/** Call after the ticket status has been persisted. pending/on_hold pause both SLA clocks. */
function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
if ($oldStatus === $newStatus) return;
// Wieder geöffnet (zum Beispiel weil der Kunde auf ein gelöstes Ticket antwortet): Die alte Lösungsfrist ist längst abgelaufen und würde
// sofort „SLA überschritten“ auslösen. Die SLA beginnt deshalb neu, mit den Zeiten der Priorität bzw. des SLA-Plans ab jetzt.
$terminal = ['resolved', 'closed'];
if (in_array($oldStatus, $terminal, true) && !in_array($newStatus, $terminal, true)) {
ticket_sla_restart($pdo, $ticketId, $staffId);
}
// Erledigt (gelöst oder geschlossen) ist dasselbe: Die SLA zählt nicht weiter, „überfällig“ und eine Wiedervorlage entfallen
if (!in_array($oldStatus, $terminal, true) && in_array($newStatus, $terminal, true)) {
try { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { error_log('SLA erledigt: ' . $e->getMessage()); }
try { $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { /* Spalte fehlt vor dem Datenbank-Update */ }
}
$wasPaused = ticket_sla_is_paused_status($oldStatus); $willPause = ticket_sla_is_paused_status($newStatus);
if (!$wasPaused && $willPause) {
$now = db_now();
$pdo->prepare('UPDATE tickets SET sla_paused_at = ? WHERE id = ?')->execute([$now, $ticketId]);
ticket_sla_history_add($pdo, $ticketId, 'paused', null, 'Pausiert: ' . status_label($newStatus)[0], $staffId, $now);
$q = $pdo->prepare('SELECT first_response_due_at,resolution_due_at,first_responded_at,resolved_at FROM tickets WHERE id = ?');
$q->execute([$ticketId]); $deadlines = $q->fetch() ?: [];
if (empty($deadlines['first_responded_at']) && !empty($deadlines['first_response_due_at']) && $deadlines['first_response_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $deadlines['first_response_due_at']);
}
if (empty($deadlines['resolved_at']) && !empty($deadlines['resolution_due_at']) && $deadlines['resolution_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $deadlines['resolution_due_at']);
$pdo->prepare('UPDATE tickets SET is_overdue = 1, updated_at = updated_at WHERE id = ?')->execute([$ticketId]);
}
return;
}
if ($wasPaused && !$willPause) {
$stmt = $pdo->prepare('SELECT sla_paused_at,first_response_due_at,resolution_due_at,first_responded_at FROM tickets WHERE id = ?');
$stmt->execute([$ticketId]); $row = $stmt->fetch();
if (!$row) return;
$now = db_now(); $started = (string) ($row['sla_paused_at'] ?? '');
$seconds = $started !== '' ? ticket_sla_working_seconds_between($started, $now) : 0;
$respondedBeforePause = !empty($row['first_responded_at']) && ($started === '' || (string) $row['first_responded_at'] < $started);
$updates = []; $args = [];
if ($seconds > 0) {
foreach (['first_response_due_at' => 'first_response', 'resolution_due_at' => 'resolution'] as $field => $deadlineType) {
$breach = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id=? AND event_type='overdue' AND deadline_type=?");
$breach->execute([$ticketId, $deadlineType]);
if (!empty($row[$field]) && !($deadlineType === 'first_response' && $respondedBeforePause) && !(int) $breach->fetchColumn()) {
$updates[] = "$field = ?"; $args[] = ticket_sla_shift_deadline((string) $row[$field], $seconds);
}
}
}
$updates[] = 'sla_paused_at = NULL'; $args[] = $ticketId;
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($args);
$minutes = (int) floor($seconds / 60);
ticket_sla_history_add($pdo, $ticketId, 'resumed', null, 'Fortgesetzt nach ' . $minutes . ' Minuten Wartezeit', $staffId, $now);
}
}
function ticket_sla_status_transition(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
try {
ticket_sla_status_transition_impl($pdo, $ticketId, $oldStatus, $newStatus, $staffId);
} catch (Throwable $e) {
error_log('DeskHub SLA-Pause konnte für Ticket #' . $ticketId . ' nicht aktualisiert werden: ' . $e->getMessage());
}
}
function ticket_sla_transition_rows(PDO $pdo, array $rows, string $newStatus, ?int $staffId = null): void
{
foreach ($rows as $row) ticket_sla_status_transition($pdo, (int) $row['id'], (string) $row['status'], $newStatus, $staffId);
}
function ticket_sla_history_for_ticket(PDO $pdo, array $ticket): array
{
try {
$ticketId = (int) $ticket['id'];
$currentlyPaused = !empty($ticket['sla_paused_at']) || ticket_sla_is_paused_status((string) ($ticket['status'] ?? ''));
if (!$currentlyPaused && !in_array((string) ($ticket['status'] ?? ''), ['resolved', 'closed'], true)) {
$now = db_now();
if (empty($ticket['first_responded_at']) && !empty($ticket['first_response_due_at']) && $ticket['first_response_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $ticket['first_response_due_at']);
}
if (empty($ticket['resolved_at']) && !empty($ticket['resolution_due_at']) && $ticket['resolution_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $ticket['resolution_due_at']);
}
}
foreach ([['first_response_due_at', 'first_response', 'Erstantwort'], ['resolution_due_at', 'resolution', 'Lösung']] as [$field, $type, $label]) {
if (empty($ticket[$field])) continue;
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ?");
$check->execute([$ticketId, $type]);
if (!(int) $check->fetchColumn()) {
ticket_sla_history_add($pdo, $ticketId, 'started', $type, $label . '-Frist gestartet; fällig am ' . $ticket[$field], null, (string) $ticket['created_at']);
} else {
$pdo->prepare("UPDATE ticket_sla_history SET details = ? WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ? AND details LIKE 'SLA gestartet; Fristfeld:%'")
->execute([$label . '-Frist gestartet; fällig am ' . $ticket[$field], $ticketId, $type]);
}
}
$stmt = $pdo->prepare('SELECT h.*, s.name AS staff_name FROM ticket_sla_history h LEFT JOIN staff s ON s.id = h.staff_id WHERE h.ticket_id = ? ORDER BY h.created_at DESC, h.id DESC LIMIT 100');
$stmt->execute([$ticketId]);
return $stmt->fetchAll();
} catch (Throwable $e) {
error_log('DeskHub SLA-Verlauf konnte nicht geladen werden: ' . $e->getMessage());
return [];
}
}
function ticket_sla_log_overdue(PDO $pdo, int $ticketId, string $deadlineType, string $dueAt): void
{
try {
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'overdue' AND deadline_type = ?");
$check->execute([$ticketId, $deadlineType]);
if (!(int) $check->fetchColumn()) ticket_sla_history_add($pdo, $ticketId, 'overdue', $deadlineType, 'SLA-Frist überschritten', null, $dueAt);
} catch (Throwable $e) { error_log('DeskHub SLA-Überschreitung konnte nicht protokolliert werden: ' . $e->getMessage()); }
}
/**
* Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
* null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
*/
function sla_plan_for(PDO $pdo, ?int $departmentId, ?int $helpTopicId): ?array
{
$ids = [];
if ($helpTopicId) {
$s = $pdo->prepare('SELECT sla_plan_id FROM help_topics WHERE id = ?');
$s->execute([$helpTopicId]);
$ids[] = $s->fetchColumn();
}
if ($departmentId) {
$s = $pdo->prepare('SELECT sla_plan_id FROM departments WHERE id = ?');
$s->execute([$departmentId]);
$ids[] = $s->fetchColumn();
}
$stmt = $pdo->prepare('SELECT * FROM sla_plans WHERE id = ? AND is_active = 1');
foreach ($ids as $id) {
if (!$id) continue;
$stmt->execute([(int) $id]);
if ($plan = $stmt->fetch()) return $plan;
}
return null;
}
function slugify(string $text): string
{
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
$text = trim($text, '-');
$text = iconv('UTF-8', 'ASCII//TRANSLIT', $text) ?: $text;
$text = strtolower($text);
$text = preg_replace('~[^-a-z0-9]+~', '', $text);
return $text !== '' ? $text : 'n-a';
}
/** Statuslabel + Badge-Farbe für die Oberfläche */
function status_label(string $status): array
{
$map = [
'open' => ['Offen', '#2563eb'],
'pending' => ['Wartet auf Kunde', '#a855f7'],
'answered' => ['Beantwortet', '#0d9488'],
'on_hold' => ['Pausiert', '#6b7280'],
'resolved' => ['Gelöst', '#16a34a'],
'closed' => ['Geschlossen', '#374151'],
];
return $map[$status] ?? [$status, '#888888'];
}
function format_dt(?string $dt): string
{
if (!$dt) return '–';
return date('d.m.Y H:i', strtotime($dt));
}
function time_until(?string $dt): string
{
if (!$dt) return '–';
$diff = strtotime($dt) - time();
$abs = abs($diff);
$hours = floor($abs / 3600);
$mins = floor(($abs % 3600) / 60);
$label = $hours > 0 ? "{$hours} Std {$mins} Min" : "{$mins} Min";
return $diff >= 0 ? "in {$label}" : "vor {$label} (überfällig)";
}
/**
* Die zur Wahl stehenden Signaturen für eine Antwort in einem Ticket (Web, Windows- und Android-App):
* eigene und die der Abteilung (bereinigtes HTML), Abteilungsname und die Voreinstellung ('own', 'dept' oder 'none').
*
* @return array{own: string, dept: string, deptName: string, default: string}
*/
function reply_signature_options(PDO $pdo, array $staff, int $departmentId): array
{
$own = trim((string) ($staff['signature'] ?? ''));
$stmt = $pdo->prepare('SELECT name, signature FROM departments WHERE id = ?');
$stmt->execute([$departmentId]);
$row = $stmt->fetch() ?: ['name' => '', 'signature' => ''];
$dept = trim((string) $row['signature']);
return [
'own' => $own !== '' ? sanitize_staff_html($own) : '',
'dept' => $dept !== '' ? sanitize_staff_html($dept) : '',
'deptName' => (string) $row['name'],
'default' => $own !== '' ? 'own' : ($dept !== '' ? 'dept' : 'none'),
];
}
/**
* Bestimmt die Signatur, die an eine öffentliche Antwort angehängt wird.
* $choice: 'own' = Signatur des Mitarbeiters, 'dept' = Signatur der Abteilung des Tickets, 'none' = keine,
* sonst (oder leer) das bisherige Verhalten: eigene Signatur, ersatzweise die der Abteilung.
* Gibt bereinigtes HTML zurück.
*/
function resolve_reply_signature(PDO $pdo, array $staff, int $departmentId, ?string $choice = null): string
{
$own = trim((string) ($staff['signature'] ?? ''));
$deptStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
$deptStmt->execute([$departmentId]);
$dept = trim((string) $deptStmt->fetchColumn());
$signature = match ($choice) {
'own' => $own,
'dept' => $dept,
'none' => '',
default => $own !== '' ? $own : $dept,
};
return $signature === '' ? '' : sanitize_staff_html($signature);
}
/** Unterstützte Favicon-Formate: Endung => MIME-Typ. */
function favicon_types(): array
{
return ['ico' => 'image/x-icon', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml'];
}
/** Prüft, ob die Datei wirklich ein Bild des angegebenen Formats ist (nicht nur die Endung). Gibt null oder eine Fehlermeldung zurück. */
function favicon_validate_file(string $path, string $ext): ?string
{
$head = (string) @file_get_contents($path, false, null, 0, 16);
switch ($ext) {
case 'ico':
return str_starts_with($head, "\x00\x00\x01\x00") || str_starts_with($head, "\x00\x00\x02\x00") ? null : 'Das ist keine gültige ICO-Datei.';
case 'png': return str_starts_with($head, "\x89PNG") ? null : 'Das ist keine gültige PNG-Datei.';
case 'jpg':
case 'jpeg': return str_starts_with($head, "\xFF\xD8\xFF") ? null : 'Das ist keine gültige JPG-Datei.';
case 'gif': return str_starts_with($head, 'GIF8') ? null : 'Das ist keine gültige GIF-Datei.';
case 'webp': return str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP' ? null : 'Das ist keine gültige WebP-Datei.';
case 'svg':
$svg = (string) @file_get_contents($path, false, null, 0, 1048576);
if (!preg_match('/<svg\b/i', $svg)) return 'Das ist keine gültige SVG-Datei.';
// Als Favicon läuft eine SVG im Bild-Kontext ohne Skripte; trotzdem keine Datei mit Skripten oder Ereignis-Handlern annehmen
if (preg_match('/<script\b|<foreignObject\b|\son[a-z]+\s*=|javascript:/i', $svg)) return 'Die SVG enthält Skripte oder aktive Inhalte und wird nicht angenommen.';
return null;
}
return 'Dieses Format wird nicht unterstützt.';
}
/**
* Alle <link>-Tags für das Browser-Symbol (Tab, Lesezeichen, Startbildschirm). Ein eigenes Favicon (Einstellungen → Design) ersetzt alle
* Standard-Symbole, damit der Browser nicht ein anderes auswählt. Ohne Favicon gelten die Standard-Symbole und, falls vorhanden, das Logo.
*/
function site_icon_links(): string
{
$favicon = (string) get_setting('favicon_path', '');
$types = favicon_types();
if ($favicon !== '') {
$ext = strtolower(pathinfo((string) parse_url($favicon, PHP_URL_PATH), PATHINFO_EXTENSION));
$type = $types[$ext] ?? 'image/png';
$href = e($favicon);
$out = '<link rel="icon" href="' . $href . '" type="' . $type . '">' . "\n" . '<link rel="shortcut icon" href="' . $href . '">' . "\n";
$out .= in_array($ext, ['png', 'jpg', 'jpeg'], true) ? '<link rel="apple-touch-icon" href="' . $href . '">' : '<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">';
return $out . "\n";
}
$out = '<link rel="icon" href="/favicon.ico" sizes="any">' . "\n" . '<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">' . "\n"
. '<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">' . "\n";
$logo = (string) get_setting('logo_path', '');
if ($logo !== '') $out .= '<link rel="icon" href="' . e($logo) . '">' . "\n";
return $out;
}
function base_url(): string
{
return rtrim(get_setting('site_url', config()['site']['url'] ?? 'http://localhost'), '/');
}
/**
* Baut ein sicheres SQL-Filterfragment für eine Abteilungs-Zugriffsbeschränkung.
* $deptIds === null -> kein Filter (Admin, voller Zugriff): ['', []]
* $deptIds === [] -> kein Zugriff auf irgendeine Abteilung: ['AND 1=0', []]
* $deptIds === [1,2] -> ['AND col IN (?,?)', [1,2]]
*/
function dept_filter_sql(?array $deptIds, string $column): array
{
if ($deptIds === null) {
return ['', []];
}
if (count($deptIds) === 0) {
return [' AND 1=0', []];
}
$placeholders = implode(',', array_fill(0, count($deptIds), '?'));
return [" AND {$column} IN ({$placeholders})", $deptIds];
}
/** Liste der Abteilungs-IDs, auf die ein Mitarbeiter Zugriff hat (Admins: null = alle) */
/** Darf dieser Mitarbeiter Tickets der Abteilung sehen? Admins alle, alle anderen nur ihre Abteilungen. */
function staff_can_access_department(array $staff, int $departmentId): bool
{
$ids = staff_department_ids($staff);
return $ids === null || in_array($departmentId, $ids, true);
}
/** Ist der Mitarbeiter gerade abwesend (Urlaubsmodus)? Im Urlaub gibt es keine Benachrichtigungen (Mail, Push, Android). */
function staff_is_on_vacation(int $staffId): bool
{
try {
$q = get_db()->prepare('SELECT is_on_vacation FROM staff WHERE id = ?');
$q->execute([$staffId]);
return (int) $q->fetchColumn() === 1;
} catch (Throwable $e) {
return false;
}
}
function staff_department_ids(array $staff): ?array
{
if ($staff['role'] === 'admin') {
return null; // null = kein Filter, voller Zugriff
}
$ids = [];
if (!empty($staff['department_id'])) {
$ids[] = (int) $staff['department_id'];
}
$stmt = get_db()->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?');
$stmt->execute([$staff['id']]);
foreach ($stmt->fetchAll() as $row) {
$ids[] = (int) $row['department_id'];
}
foreach (staff_substitution_department_ids($staff) as $d) $ids[] = $d;
return array_values(array_unique($ids));
}
/**
* Abteilungen, auf die ein Mitarbeiter nur als Vertretung Zugriff hat: Ist ein Kollege im Urlaub (Profil → Abwesenheit oder Plugin
* „Urlaub und Vertretung“) und hat diesen Mitarbeiter als Vertretung eingetragen, gelten dessen Abteilungen (Haupt- und zusätzliche) mit,
* solange die Abwesenheit dauert. Ketten (A vertritt B, B ist selbst im Urlaub und von C vertreten) folgen bis zu drei Stufen.
* Ist der Abwesende Administrator (sieht alle Abteilungen), kommen nur die Abteilungen der Tickets dazu, die der Vertretung gerade
* zugewiesen sind. Ohne die Spalten (Datenbank noch nicht aktualisiert) ergibt sich eine leere Liste.
* @return list<int>
*/
function staff_substitution_department_ids(array $staff): array
{
if (($staff['role'] ?? '') === 'admin' || empty($staff['id'])) return [];
$pdo = get_db();
$out = [];
try {
$frontier = [(int) $staff['id']];
$seen = $frontier;
for ($level = 0; $level < 3 && $frontier; $level++) {
$in = implode(',', array_fill(0, count($frontier), '?'));
$q = $pdo->prepare("SELECT id, role, department_id FROM staff WHERE is_active = 1 AND is_on_vacation = 1 AND vacation_substitute_id IN ($in)");
$q->execute($frontier);
$next = [];
$adminAbsent = false;
foreach ($q->fetchAll() as $abs) {
$aid = (int) $abs['id'];
if (in_array($aid, $seen, true)) continue;
$seen[] = $aid;
$next[] = $aid;
if ($abs['role'] === 'admin') { $adminAbsent = true; continue; }
if (!empty($abs['department_id'])) $out[] = (int) $abs['department_id'];
$d = $pdo->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?');
$d->execute([$aid]);
foreach ($d->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
}
if ($adminAbsent) {
$t = $pdo->prepare("SELECT DISTINCT department_id FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed')");
$t->execute([(int) $staff['id']]);
foreach ($t->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
}
$frontier = $next;
}
} catch (Throwable $e) {
return [];
}
return array_values(array_unique(array_filter($out)));
}
/**
* Beim Öffnen eines noch niemandem zugewiesenen Tickets wird es dem öffnenden Mitarbeiter zugewiesen (Einstellung "Beim Öffnen
* automatisch zuweisen", Standard an). Nicht bei geschlossenen, gelösten, zusammengeführten oder als Spam markierten Tickets und nicht,
* wenn der Mitarbeiter in dieser Abteilung nicht antworten darf. Aktualisiert $ticket (assigned_staff_id, staff_name) und gibt zurück,
* ob zugewiesen wurde.
*/
function ticket_assign_on_open(PDO $pdo, array &$ticket, array $staff): bool
{
if (get_setting('auto_assign_on_open', '1') !== '1') return false;
if (!empty($ticket['assigned_staff_id']) || !empty($ticket['merged_into']) || !empty($ticket['is_spam'])) return false;
if (in_array((string) ($ticket['status'] ?? ''), ['closed', 'resolved'], true)) return false;
if (!has_permission($staff, 'reply_ticket', (int) ($ticket['department_id'] ?? 0))) return false;
$stmt = $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ? AND assigned_staff_id IS NULL');
$stmt->execute([(int) $staff['id'], (int) $ticket['id']]);
if ($stmt->rowCount() !== 1) return false;
$ticket['assigned_staff_id'] = (int) $staff['id'];
$ticket['staff_name'] = $staff['name'] ?? null;
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Beim Öffnen automatisch zugewiesen');
return true;
}
/** Arten der automatischen Zuweisung neuer Tickets einer Abteilung. */
function department_auto_assign_modes(): array
{
return ['none' => 'Nicht automatisch zuweisen', 'staff' => 'Immer an einen bestimmten Mitarbeiter', 'round_robin' => 'Reihum an die Mitarbeiter der Abteilung'];
}
/**
* Wem ein neues Ticket dieser Abteilung laut Einstellung der Abteilung automatisch zugewiesen wird (Mitarbeiter-ID) oder null.
* - "staff": immer derselbe Mitarbeiter; ist er im Urlaub, geht es an seine Vertretung, ohne Vertretung bleibt das Ticket unzugewiesen
* - "round_robin": der Reihe nach an die Mitarbeiter der Abteilung (Hauptabteilung oder zusätzlich zugewiesen), die nicht im Urlaub und
* gerade erreichbar sind (nicht offline, in der Schicht); ist niemand erreichbar, bekommt der nächste aus der Reihe es trotzdem
* Fehlende Spalten (Datenbank noch nicht aktualisiert) und unbekannte Abteilungen ergeben null.
*/
function department_auto_assignee(PDO $pdo, int $departmentId): ?int
{
try {
$s = $pdo->prepare('SELECT auto_assign_mode, auto_assign_staff_id, auto_assign_last_id FROM departments WHERE id = ?');
$s->execute([$departmentId]);
$d = $s->fetch();
} catch (Throwable $e) {
return null;
}
if (!$d) return null;
$mode = (string) ($d['auto_assign_mode'] ?? 'none');
if ($mode === 'staff') {
$id = (int) ($d['auto_assign_staff_id'] ?? 0);
if ($id <= 0) return null;
$st = $pdo->prepare('SELECT id, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
$st->execute([$id]);
$row = $st->fetch();
if (!$row || !(int) $row['is_active']) return null;
if (!(int) $row['is_on_vacation']) return $id;
$sub = (int) ($row['vacation_substitute_id'] ?? 0);
if ($sub > 0) {
$st->execute([$sub]);
$subRow = $st->fetch();
if ($subRow && (int) $subRow['is_active'] && !(int) $subRow['is_on_vacation']) return $sub;
}
return null;
}
if ($mode === 'round_robin') {
$c = $pdo->prepare(
'SELECT id FROM staff WHERE is_active = 1 AND is_on_vacation = 0
AND (department_id = ? OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ?)) ORDER BY id'
);
$c->execute([$departmentId, $departmentId]);
$candidates = array_map('intval', $c->fetchAll(PDO::FETCH_COLUMN));
if (!$candidates) return null;
$available = array_values(array_filter($candidates, fn(int $id) => is_staff_available($pdo, $id)));
$pool = $available ?: $candidates;
$last = (int) ($d['auto_assign_last_id'] ?? 0);
$next = null;
foreach ($pool as $id) { if ($id > $last) { $next = $id; break; } }
$next ??= $pool[0];
$pdo->prepare('UPDATE departments SET auto_assign_last_id = ? WHERE id = ?')->execute([$next, $departmentId]);
return $next;
}
return null;
}
/** Löscht ein Ticket dauerhaft, protokolliert es vorher für Berichte */
function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void
{
$stmt = $pdo->prepare(
'INSERT INTO deleted_tickets_log (ticket_number, department_id, priority_id, help_topic_id, assigned_staff_id, deleted_by, deleted_at)
VALUES (?,?,?,?,?,?,?)'
);
$stmt->execute([
$ticket['ticket_number'],
$ticket['department_id'] ?? null,
$ticket['priority_id'] ?? null,
$ticket['help_topic_id'] ?? null,
$ticket['assigned_staff_id'] ?? null,
$deletedByStaffId,
db_now(),
]);
if (function_exists('ticket_links_purge')) ticket_links_purge($pdo, (int) $ticket['id']);
$pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]);
}
/** Prüft, ob eine IP in einer Liste aus Einzeladressen und CIDR-Bereichen (IPv4/IPv6) steht. */
function ip_in_list(string $ip, array $list): bool
{
$bin = @inet_pton($ip);
if ($bin === false) return false;
foreach ($list as $entry) {
$entry = trim((string) $entry);
if ($entry === '') continue;
[$net, $bits] = array_pad(explode('/', $entry, 2), 2, null);
$nbin = @inet_pton($net);
if ($nbin === false || strlen($nbin) !== strlen($bin)) continue;
$bits = $bits === null ? strlen($bin) * 8 : max(0, min(strlen($bin) * 8, (int) $bits));
$bytes = intdiv($bits, 8);
if ($bytes > 0 && substr($bin, 0, $bytes) !== substr($nbin, 0, $bytes)) continue;
$rest = $bits % 8;
if ($rest > 0) {
$mask = (0xFF << (8 - $rest)) & 0xFF;
if ((ord($bin[$bytes]) & $mask) !== (ord($nbin[$bytes]) & $mask)) continue;
}
return true;
}
return false;
}
/** Kommt die Anfrage direkt von einem als vertrauenswürdig eingetragenen Reverse-Proxy (config site → trusted_proxies)? */
function cloudflare_proxy_ranges(): array
{
static $ranges = null;
if ($ranges !== null) return $ranges;
$ranges = [];
try {
$active = get_db()->prepare('SELECT is_active FROM plugins WHERE slug = ?');
$active->execute(['cloudflare-protection']);
if ((int) $active->fetchColumn() === 1) {
$dynamic = json_decode((string) get_setting('plugin.cloudflare-protection.ranges', ''), true);
if (is_array($dynamic)) $ranges = array_values(array_filter($dynamic, 'is_string'));
}
} catch (Throwable $e) {
// No dynamic Cloudflare networks are trusted if the plugin is not installed.
}
return $ranges;
}
function trusted_proxy_ranges(): array
{
static $ranges = null;
if ($ranges === null) {
$ranges = array_values(array_unique(array_merge(
array_map('strval', (array) (config()['site']['trusted_proxies'] ?? [])),
cloudflare_proxy_ranges()
)));
}
return $ranges;
}
function request_from_trusted_proxy(): bool
{
$list = trusted_proxy_ranges();
return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list);
}
/** Wurde die Anfrage per HTTPS gestellt (direkt oder über einen vertrauenswürdigen Proxy, der X-Forwarded-Proto setzt)? */
function request_is_https(): bool
{
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') return true;
return request_from_trusted_proxy() && strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0])) === 'https';
}
/**
* Ermittelt die Client-IP für Rate-Limiting. Ohne eingetragene Proxys zählt nur REMOTE_ADDR (ein Besucher kann
* X-Forwarded-For selbst setzen und die Sperre sonst umgehen). Steht der Proxy in "trusted_proxies", gilt die
* erste Adresse von rechts in X-Forwarded-For, die nicht selbst ein vertrauenswürdiger Proxy ist.
*/
function client_ip(): string
{
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!request_from_trusted_proxy()) return $remote;
$cloudflareRanges = cloudflare_proxy_ranges();
$cloudflareIp = trim((string) ($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''));
if ($cloudflareRanges !== [] && ip_in_list($remote, $cloudflareRanges) && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
return $cloudflareIp;
}
$trusted = trusted_proxy_ranges();
$chain = array_reverse(array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))));
foreach ($chain as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip;
}
return $remote;
}
/** Schreibt einen Eintrag ins Admin-Aktionsprotokoll (Einstellungen, Stammdaten – getrennt vom Ticket-Aktivitätsprotokoll) */
function audit_log(?int $staffId, string $action): void
{
// Spalte ist VARCHAR(255) NOT NULL – bei MySQL im Strict-Modus wirft ein zu langer
// Wert sonst eine harte Exception statt einfach zu kürzen.
get_db()->prepare('INSERT INTO admin_audit_log (staff_id, action, created_at) VALUES (?,?,?)')
->execute([$staffId, mb_substr($action, 0, 255), db_now()]);
}
/**
* Einfache Rate-Begrenzung: prüft, ob unter $bucket/$identifier in den letzten
* $windowMinutes Minuten bereits $maxAttempts Einträge protokolliert wurden.
* Bei Erlaubnis wird der Versuch sofort selbst protokolliert (Aufruf = Verbrauch).
*/
function rate_limit_check(string $bucket, string $identifier, int $maxAttempts, int $windowMinutes): bool
{
// Alte Einträge regelmäßig löschen (ca. 1% der Aufrufe)
if (random_int(1, 100) === 1) {
get_db()->prepare('DELETE FROM rate_limit_log WHERE created_at < ?')
->execute([date('Y-m-d H:i:s', time() - 86400)]);
}
$pdo = get_db();
$since = date('Y-m-d H:i:s', time() - $windowMinutes * 60);
$stmt = $pdo->prepare('SELECT COUNT(*) FROM rate_limit_log WHERE bucket = ? AND identifier = ? AND created_at >= ?');
$stmt->execute([$bucket, $identifier, $since]);
$count = (int) $stmt->fetchColumn();
if ($count >= $maxAttempts) {
return false;
}
$pdo->prepare('INSERT INTO rate_limit_log (bucket, identifier, created_at) VALUES (?,?,?)')
->execute([$bucket, $identifier, db_now()]);
return true;
}
/** Erzeugt ein Passwort-Reset-Token, speichert nur dessen Hash, gibt das Klartext-Token zurück (für den Link) */
function create_password_reset(string $accountType, string $email): string
{
$pdo = get_db();
// 48 Bytes = 96 hex chars – ausreichend Entropie gegen Brute-Force
$token = bin2hex(random_bytes(48));
// Alte, ungenutzte Tokens für diese E-Mail ungültig machen
$pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND email = ? AND used_at IS NULL')
->execute([db_now(), $accountType, $email]);
$pdo->prepare('INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES (?,?,?,?,?)')
->execute([$accountType, $email, hash('sha256', $token), date('Y-m-d H:i:s', time() + 3600), db_now()]);
return $token;
}
/**
* Prüft ob ein Passwort die Mindestanforderungen erfüllt.
* Gibt null zurück wenn OK, sonst eine Fehlermeldung.
*/
function validate_password_strength(string $password): ?string
{
if (strlen($password) < 10) {
return 'Das Passwort muss mindestens 10 Zeichen lang sein.';
}
if (!preg_match('/[A-Z]/', $password)) {
return 'Das Passwort muss mindestens einen Großbuchstaben enthalten.';
}
if (!preg_match('/[a-z]/', $password)) {
return 'Das Passwort muss mindestens einen Kleinbuchstaben enthalten.';
}
if (!preg_match('/[0-9]/', $password)) {
return 'Das Passwort muss mindestens eine Ziffer enthalten.';
}
if (!preg_match('/[^A-Za-z0-9]/', $password)) {
return 'Das Passwort muss mindestens ein Sonderzeichen enthalten (!@#$%&* usw.).';
}
// Triviale Passwörter blockieren
$trivial = ['password','passwort','12345678','123456789','1234567890','qwertzui','admin123','welcome1'];
if (in_array(strtolower($password), $trivial, true)) {
return 'Dieses Passwort ist zu häufig verwendet. Bitte wählen Sie ein sichereres Passwort.';
}
return null;
}
/**
* Verschlüsselt einen sensiblen Wert (z.B. SMTP-Passwort) mit AES-256-GCM.
* Schlüssel kommt aus config app_key oder wird aus DB-Credentials abgeleitet.
*/
function encrypt_secret(string $plaintext): string
{
$key = _get_app_key();
$iv = random_bytes(12);
$tag = '';
$encrypted = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
return base64_encode($iv . $tag . $encrypted);
}
/** Entschlüsselt einen mit encrypt_secret() verschlüsselten Wert. */
function decrypt_secret(string $ciphertext): string
{
if ($ciphertext === '') return '';
// Legacy: wenn nicht base64-verschlüsselt, Klartext zurückgeben (Migration)
$raw = base64_decode($ciphertext, true);
if ($raw === false || strlen($raw) < 28) return $ciphertext;
$key = _get_app_key();
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$encrypted = substr($raw, 28);
$decrypted = openssl_decrypt($encrypted, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
return $decrypted !== false ? $decrypted : $ciphertext; // Fallback auf Klartext
}
/** Interner Schlüssel für Verschlüsselung – 32 Bytes. */
/**
* Interner Schlüssel für AES-256-GCM-Verschlüsselung – 32 Bytes.
*
* Priorität:
* 1. settings-Tabelle (key = 'app_key') → überlebt Updates ohne config.php-Änderung
* 2. config.php → app_key → gesetzt vom Installer oder manuell
* 3. Fallback aus DB-Credentials → schwach, aber nie null (Warnung wird angezeigt)
*/
function _get_app_key(): string
{
// 1. DB-Setting hat höchste Priorität (überlebt Updates)
try {
$stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
$stmt->execute(['app_key']);
$dbKey = $stmt->fetchColumn();
if ($dbKey !== false && $dbKey !== '') {
return substr(hash('sha256', $dbKey, true), 0, 32);
}
} catch (\Throwable $e) {
// DB noch nicht verfügbar (z.B. während Installation) → weiter zum Fallback
}
// 2. config.php
$cfg = config();
if (!empty($cfg['app_key'])) {
return substr(hash('sha256', $cfg['app_key'], true), 0, 32);
}
// 3. Schwacher Fallback (Warnung wird im Admin-Header angezeigt)
$seed = ($cfg['db']['pass'] ?? '') . ($cfg['db']['name'] ?? '') . ($cfg['db']['host'] ?? 'localhost');
return substr(hash('sha256', $seed . 'deskhub-secret', true), 0, 32);
}
/** Setzt einen Rate-Limit-Zähler zurück (z.B. nach erfolgreichem Login) */
function rate_limit_reset(string $action, string $key): void
{
try {
get_db()->prepare('DELETE FROM rate_limit_log WHERE bucket = ? AND identifier = ?')
->execute([$action, $key]);
} catch (Throwable $e) { /* ignorieren */ }
}
/** Prüft ein Passwort-Reset-Token und gibt die zugehörige E-Mail-Adresse zurück, oder null wenn ungültig/abgelaufen/verbraucht */
function verify_password_reset(string $accountType, string $token): ?string
{
$pdo = get_db();
$stmt = $pdo->prepare(
"SELECT * FROM password_resets WHERE account_type = ? AND token_hash = ? AND used_at IS NULL AND expires_at >= ?
ORDER BY id DESC LIMIT 1"
);
$stmt->execute([$accountType, hash('sha256', $token), db_now()]);
$row = $stmt->fetch();
return $row ? $row['email'] : null;
}
/** Markiert ein Reset-Token als verbraucht (nach erfolgreichem Zurücksetzen) */
function consume_password_reset(string $accountType, string $token): void
{
$pdo = get_db();
$pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND token_hash = ?')
->execute([db_now(), $accountType, hash('sha256', $token)]);
}
/**
* Wandelt eine kleine, sichere Teilmenge von Markdown in HTML um (für Ticket-Antworten).
* Escaped IMMER zuerst den kompletten Text, wendet danach nur feste Muster an –
* dadurch ist eingegebenes HTML niemals aktiv, unabhängig vom Absender.
*/
function markdown_to_html(string $text): string
{
$text = trim($text);
if ($text === '') return '';
$escaped = e($text);
// Code-Abschnitte `code`
$escaped = preg_replace('/`([^`\n]+)`/', '<code>$1</code>', $escaped);
// Fett **text**
$escaped = preg_replace('/\*\*([^\*\n]+)\*\*/', '<strong>$1</strong>', $escaped);
// Kursiv *text*
$escaped = preg_replace('/\*([^\*\n]+)\*/', '<em>$1</em>', $escaped);
// Links [text](https://...) - nur http(s) erlaubt
$escaped = preg_replace_callback('/\[([^\]\n]+)\]\((https?:\/\/[^\s\)]+)\)/', function ($m) {
return '<a href="' . e($m[2]) . '" target="_blank" rel="noopener noreferrer">' . $m[1] . '</a>';
}, $escaped);
// Zeilen in Absätze/Listen umwandeln
$lines = preg_split('/\r?\n/', $escaped);
$html = '';
$inList = false;
foreach ($lines as $line) {
if (preg_match('/^-\s+(.*)/', $line, $m)) {
if (!$inList) { $html .= '<ul>'; $inList = true; }
$html .= '<li>' . $m[1] . '</li>';
} else {
if ($inList) { $html .= '</ul>'; $inList = false; }
$html .= trim($line) === '' ? '<br>' : '<p>' . $line . '</p>';
}
}
if ($inList) $html .= '</ul>';
return $html;
}
/** Schreibt einen Eintrag ins Aktivitätsprotokoll eines Tickets */
function log_activity(PDO $pdo, int $ticketId, ?int $staffId, string $action): void
{
$stmt = $pdo->prepare('INSERT INTO activity_log (ticket_id, staff_id, action, created_at) VALUES (?,?,?,?)');
$stmt->execute([$ticketId, $staffId, $action, db_now()]);
}
/**
* Sperre für Live-Chat-Tickets (source='chat'): verhindert, dass zwei Mitarbeiter
* unbemerkt gleichzeitig im selben Chat antworten – egal ob über das Web-Backend
* (staff/live_chat.php) oder die Mobile-App (api/staff/v1/ticket_message.php), beide
* teilen sich diese eine Logik, damit sie nicht auseinanderlaufen. Wer den Chat öffnet
* bzw. beantwortet, "besitzt" ihn für ein paar Minuten; danach läuft die Sperre
* automatisch ab (kein Hängenbleiben, wenn z.B. die App einfach geschlossen wird).
* Andere Mitarbeiter dürfen den Chat trotzdem weiter LESEN – nur das Schreiben wird
* ihnen verwehrt, siehe Aufrufer.
*/
const LIVE_CHAT_LOCK_TIMEOUT_MINUTES = 5;
/** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn
* frei, selbst gesperrt, oder die Sperre abgelaufen ist. */
function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string
{
$lockFresh = !empty($ticket['locked_at'])
&& (time() - strtotime($ticket['locked_at'])) < LIVE_CHAT_LOCK_TIMEOUT_MINUTES * 60;
if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) {
return null;
}
// Administratoren werden nie ausgesperrt: sie dürfen auch einen fremd "besessenen" Chat lesen und beantworten (und übernehmen die Sperre)
$r = $pdo->prepare('SELECT role FROM staff WHERE id = ?');
$r->execute([$staffId]);
if ($r->fetchColumn() === 'admin') return null;
$stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?');
$stmt->execute([$ticket['locked_by']]);
return $stmt->fetchColumn() ?: 'ein anderer Mitarbeiter';
}
/** Übernimmt/erneuert die Sperre für $staffId (nur aufrufen, wenn nicht bereits
* frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */
function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
{
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?')
->execute([$staffId, db_now(), $ticketId]);
}
/**
* Generelle Ticket-Sperre für ALLE Tickets (nicht Live-Chat-spezifisch, siehe oben) –
* verhindert, dass zwei Mitarbeiter unbemerkt gleichzeitig am selben Ticket arbeiten.
* Nutzt dieselben locked_by/locked_at-Spalten, aber eine "harte" Semantik: anders als
* beim Live-Chat wird hier nicht nur das Schreiben, sondern auch das Öffnen selbst
* verweigert (siehe Aufrufer in staff/ticket_view.php und api/staff/v1/ticket.php).
* Für Live-Chat-Tickets (source='chat') gilt weiterhin die separate, mildere
* live_chat_locked_by_other()-Logik – NIEMALS beide auf dasselbe Ticket anwenden, sonst
* würden sich die Timeouts widersprechen.
* (Ursprünglich 10 Minuten, auf Nutzerwunsch verkürzt: wer ein Ticket nur kurz öffnet und
* dann wegnavigiert ohne zu antworten/freizugeben, blockiert es sonst unnötig lange für
* andere. Admins können eine fremde Sperre zusätzlich jederzeit sofort aufheben, siehe
* flash_locked_ticket()/get_locked_ticket_override().)
*/
const TICKET_LOCK_TIMEOUT_MINUTES = 5;
/** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn
* frei, selbst gesperrt, oder die Sperre abgelaufen ist. */
function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string
{
$lockFresh = !empty($ticket['locked_at'])
&& (time() - strtotime($ticket['locked_at'])) < TICKET_LOCK_TIMEOUT_MINUTES * 60;
if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) {
return null;
}
$stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?');
$stmt->execute([$ticket['locked_by']]);
return $stmt->fetchColumn() ?: 'einem anderen Mitarbeiter';
}
/** Übernimmt/erneuert die generelle Ticket-Sperre (nur aufrufen, wenn nicht bereits
* frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */
function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
{
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?')
->execute([$staffId, db_now(), $ticketId]);
}
/** Liest eine Einstellung aus der settings-Tabelle (z.B. Logo-Pfad) */
/** Geteilter In-Request-Cache für get_setting / set_setting (per Referenz). */
function &_settings_cache(): array
{
static $cache = [];
return $cache;
}
/** Liest eine Einstellung aus der DB (mit In-Request-Cache). */
function get_setting(string $key, ?string $default = null): ?string
{
$cache = &_settings_cache();
if (array_key_exists($key, $cache)) {
return $cache[$key];
}
$stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
$stmt->execute([$key]);
$value = $stmt->fetchColumn();
return $cache[$key] = ($value !== false ? $value : $default);
}
/**
* Speichert eine Einstellung (atomares Upsert, treiber-unabhängig)
* und aktualisiert sofort den In-Request-Cache.
*/
function set_setting(string $key, ?string $value): void
{
$pdo = get_db();
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$pdo->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?,?)')
->execute([$key, $value]);
} else {
$pdo->prepare(
'INSERT INTO settings (setting_key, setting_value) VALUES (?,?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
)->execute([$key, $value]);
}
// Cache sofort aktualisieren – nachfolgende get_setting()-Aufrufe im selben
// Request liefern den neuen Wert ohne erneuten DB-Trip.
$cache = &_settings_cache();
$cache[$key] = $value;
}
/** Formatiert einen Zeitstempel als relative "vor X Min./Std./Tagen"-Angabe (für Cron-/Postfach-Status-Anzeigen). */
function cron_last_run_label(?string $dt): string
{
if (!$dt) return 'Noch nie';
$mins = (int) round((time() - strtotime($dt)) / 60);
if ($mins < 1) return 'Gerade eben';
if ($mins < 60) return "vor {$mins} Min.";
$h = (int) round($mins / 60);
if ($h < 48) return "vor {$h} Std.";
return 'vor ' . (int) round($h / 24) . ' Tagen';
}
/**
* Sorgt dafür, dass die E-Mail-Adresse einer Abteilung automatisch auch als
* Eintrag in der Routing-Tabelle (email_addresses) existiert, damit eingehende
* Mails an diese Adresse ohne separaten Schritt in der richtigen Abteilung
* landen (siehe staff/departments.php, staff/email_addresses.php).
* Bereits vorhandene Routing-Einträge für DIESELBE Adresse werden nie
* überschrieben – auch nicht, wenn sie auf eine andere Abteilung zeigen.
*/
function sync_department_email_route(PDO $pdo, int $departmentId, string $email): void
{
$email = trim(strtolower($email));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return;
$stmt = $pdo->prepare('SELECT id FROM email_addresses WHERE LOWER(email) = ?');
$stmt->execute([$email]);
if ($stmt->fetchColumn()) {
return; // Adresse ist bereits geroutet (an diese oder eine andere Abteilung) – nicht anfassen
}
$pdo->prepare('UPDATE email_addresses SET is_default = 0 WHERE department_id = ?')->execute([$departmentId]);
$pdo->prepare('INSERT INTO email_addresses (email, label, department_id, priority_id, is_default, created_at) VALUES (?,?,?,NULL,1,?)')
->execute([$email, 'Automatisch (Abteilungs-E-Mail)', $departmentId, db_now()]);
}
/**
* Abteilungen für Auswahllisten, in denen etwas NEU zugeordnet wird (Weiterleiten, Routing,
* Regeln, Hilfethemen …): nur aktive Abteilungen plus die bereits gewählten ($keepIds), damit
* bestehende Zuordnungen beim Bearbeiten nicht verloren gehen.
*/
function departments_for_select(PDO $pdo, array $keepIds = []): array
{
$keep = array_values(array_filter(array_map('intval', $keepIds)));
$sql = 'SELECT * FROM departments WHERE is_active = 1';
if ($keep) $sql .= ' OR id IN (' . implode(',', $keep) . ')';
return $pdo->query($sql . ' ORDER BY name')->fetchAll();
}
/** Liefert die ID einer aktiven Abteilung: $id selbst, sonst die Standardabteilung, sonst irgendeine aktive. */
function active_department_id(PDO $pdo, ?int $id): ?int
{
if ($id) {
$stmt = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1');
$stmt->execute([$id]);
if ((int) $stmt->fetchColumn()) return $id;
}
$fallback = $pdo->query('SELECT id FROM departments WHERE is_active = 1 ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn();
return $fallback ? (int) $fallback : $id;
}
/**
* Hilfethemen als flache, hierarchisch sortierte Liste (Eltern vor Kindern).
* Jede Zeile enthält zusätzlich 'path' ("Eltern / Kind") und 'depth'.
* Kunden sehen nur aktive, öffentliche Themen ($publicOnly), Mitarbeiter alle aktiven.
*/
function help_topic_options(PDO $pdo, bool $publicOnly = false, bool $activeOnly = true): array
{
$rows = $pdo->query(
'SELECT h.*, COALESCE(d.is_active, 1) AS dept_active FROM help_topics h LEFT JOIN departments d ON d.id = h.department_id ORDER BY h.sort_order, h.name'
)->fetchAll();
$byParent = [];
$byId = [];
foreach ($rows as $r) {
$byId[(int) $r['id']] = $r;
$pid = !empty($r['parent_id']) ? (int) $r['parent_id'] : 0;
$byParent[$pid][] = $r;
}
$out = [];
$walk = function (int $parent, string $prefix, int $depth, array $seen) use (&$walk, &$out, $byParent, $publicOnly, $activeOnly) {
foreach ($byParent[$parent] ?? [] as $r) {
if (isset($seen[(int) $r['id']])) continue; // Schutz vor Zyklen
$path = $prefix === '' ? $r['name'] : $prefix . ' / ' . $r['name'];
$visible = (!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1));
if ($visible) {
$r['path'] = $path;
$r['depth'] = $depth;
$out[] = $r;
}
$walk((int) $r['id'], $path, $depth + 1, $seen + [(int) $r['id'] => true]);
}
};
$walk(0, '', 0, []);
// Themen, deren Elternthema nicht existiert, trotzdem anzeigen
$shown = array_column($out, 'id');
foreach ($rows as $r) {
if (!empty($r['parent_id']) && !isset($byId[(int) $r['parent_id']]) && !in_array($r['id'], $shown, true)) {
if ((!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1))) {
$r['path'] = $r['name']; $r['depth'] = 0; $out[] = $r;
}
}
}
return $out;
}
/** Gibt die erlaubten Upload-Dateiendungen zurück (aus DB-Setting oder config-Fallback). */
function get_allowed_extensions(): array
{
$setting = get_setting('upload_allowed_extensions', '');
if ($setting) {
$exts = array_filter(array_map('trim', explode(',', strtolower($setting))));
if ($exts) return array_values($exts);
}
return config()['uploads']['allowed_extensions'];
}
/** Zusätzliche Dateiendungen, die nur für Anhänge eingehender E-Mails erlaubt sind (Einstellungen → Uploads); Standard: gängige Büro- und Bildformate. */
const MAIL_ATTACHMENT_EXTRA_DEFAULT = 'doc, xls, ppt, pptx, odt, ods, odp, csv, rtf, msg, heic, webp';
/** @return string[] */
function mail_attachment_extra_extensions(): array
{
$list = array_filter(array_map('trim', explode(',', strtolower(get_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT)))));
return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $list)));
}
/** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */
function get_max_upload_size(): int
{
$setting = get_setting('upload_max_size_mb', '');
if ($setting && (int)$setting > 0) {
return (int)$setting * 1024 * 1024;
}
return config()['uploads']['max_size_bytes'];
}
/**
* @param bool $dryRun nur prüfen (Name, Endung, Größe, Inhalt), nichts speichern
* @param array $extraAllowed zusätzlich erlaubte Endungen (nur für Dateien, die DeskHub selbst aus einer Mail übernimmt: Bilder, .eml)
* @param bool $httpUpload true bei einem Formular-Upload (move_uploaded_file); false für eine Datei, die DeskHub selbst angelegt hat
* (zum Beispiel ein Anhang aus einer abgerufenen E-Mail). Die Prüfungen sind in beiden Fällen dieselben.
*/
function handle_ticket_upload(array $file, bool $httpUpload = true, array $extraAllowed = [], bool $dryRun = false): array
{
$cfg = config()['uploads'];
$allowed = array_values(array_unique(array_merge(get_allowed_extensions(), $extraAllowed)));
$maxSize = get_max_upload_size();
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'Der Datei-Upload ist fehlgeschlagen.'];
}
if ($file['size'] > $maxSize) {
return ['error' => 'Die Datei ist zu groß (max. ' . round($maxSize / 1024 / 1024) . ' MB).'];
}
// Null-Byte im Dateinamen verhindern (PHP < 8 war anfällig)
if (strpos($file['name'], "\0") !== false) {
return ['error' => 'Ungültiger Dateiname.'];
}
// .htaccess und ähnliche Systemdateien explizit blockieren
$basename = strtolower(basename($file['name']));
if (in_array($basename, ['.htaccess', '.htpasswd', 'web.config', '.env', 'php.ini'], true)) {
return ['error' => 'Dieser Dateiname ist nicht erlaubt.'];
}
// Endung prüfen (nur letzte Endung – verhindert doppelte wie shell.php.jpg)
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
// Doppelte Endungen blockieren (z.B. evil.php.jpg)
// Auch wenn ein Admin die Liste der erlaubten Endungen erweitert: Typen, die ein Browser auf unserer Domain
// als Seite/Skript ausführen würde (Uploads sind direkt abrufbar), und ausführbare Dateien bleiben gesperrt.
$dangerousExts = ['php','php3','php4','php5','php7','php8','phtml','phar','asp','aspx','jsp','jspx','sh','py','pl','cgi','exe',
'html','htm','xhtml','shtml','svg','svgz','xml','xsl','xslt','js','mjs','htc','swf','hta','jar','vbs','bat','cmd','ps1','msi','dll','com','scr','cer','crt'];
// Gespeichert wird unter einem Zufallsnamen mit nur der letzten Endung. Namen wie „www.example.com.pdf“ oder „scan.js.2024.jpg“
// sind daher harmlos und werden nicht abgelehnt; geprüft wird die tatsächliche Endung.
if (in_array($ext, $dangerousExts, true)) {
return ['error' => 'Dieser Dateityp (.' . $ext . ') ist aus Sicherheitsgründen nicht erlaubt: ' . $file['name']];
}
if (!in_array($ext, $allowed, true)) {
return ['error' => 'Dieser Dateityp ist nicht erlaubt (' . e($ext) . '). Erlaubt: ' . implode(', ', $allowed)];
}
// MIME-Typ per finfo prüfen (verhindert verschleierte Dateitypen)
$dangerousMimes = [
'text/x-php','application/x-php','application/php','text/php',
'application/x-httpd-php','application/x-httpd-php-source',
'text/x-script.php','application/x-sh','text/x-shellscript',
];
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (in_array($mimeType, $dangerousMimes, true)) {
return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.'];
}
}
if ($dryRun) return ['original_name' => $file['name'], 'stored_name' => '', 'filesize' => $file['size']];
// Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz
$storedName = bin2hex(random_bytes(16)) . '.' . $ext;
$uploadsDir = realpath(rtrim($cfg['path'], '/'));
$dest = $uploadsDir . '/' . $storedName;
if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) {
return ['error' => 'Ungültiger Upload-Pfad.'];
}
$moved = $httpUpload ? move_uploaded_file($file['tmp_name'], $dest) : @copy($file['tmp_name'], $dest);
if (!$moved) {
return ['error' => 'Die Datei konnte nicht gespeichert werden.'];
}
return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']];
}
/**
* Speichert einen Anhang aus einer E-Mail (Rohdaten) als Anhang einer Nachricht. Es gelten dieselben Regeln wie beim Upload
* (erlaubte Endungen, Größe, gesperrte Typen).
*
* @return array{ok:bool,error?:string,name:string}
*/
function store_ticket_attachment_data(PDO $pdo, int $threadId, string $name, string $data, array $opts = []): array
{
$name = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', '', basename(str_replace(chr(92), '/', $name))));
if ($name === '') $name = 'Anhang';
$name = mb_substr($name, 0, 255);
if ($data === '') return ['ok' => false, 'error' => 'Der Anhang ist leer.', 'name' => $name];
$tmp = tempnam(sys_get_temp_dir(), 'dhmail');
if ($tmp === false || file_put_contents($tmp, $data) === false) return ['ok' => false, 'error' => 'Der Anhang konnte nicht zwischengespeichert werden.', 'name' => $name];
$res = handle_ticket_upload(['name' => $name, 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, (array) ($opts['extra_allowed'] ?? []));
@unlink($tmp);
if (isset($res['error'])) return ['ok' => false, 'error' => (string) $res['error'], 'name' => $name];
if (!empty($opts['content_id']) && ticket_mail_html_ready($pdo)) {
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at, content_id, is_inline) VALUES (?,?,?,?,?,?,?)')
->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now(), mb_substr((string) $opts['content_id'], 0, 255), empty($opts['inline']) ? 0 : 1]);
} else {
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')
->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now()]);
}
return ['ok' => true, 'name' => $name];
}
/** Gibt es die Spalten für die HTML-Fassung von Mails (Datenbank-Update php_0070 eingespielt)? Bis dahin verhält sich alles wie bisher. */
function ticket_mail_html_ready(PDO $pdo): bool
{
static $cache = [];
$key = spl_object_id($pdo);
if (isset($cache[$key])) return $cache[$key];
try {
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(ticket_threads)' : 'SHOW COLUMNS FROM ticket_threads')->fetchAll();
foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'message_html') return $cache[$key] = true;
} catch (Throwable $e) {
// Tabelle fehlt: nichts zu tun
}
return $cache[$key] = false;
}
/** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input).
* Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */
function handle_ticket_uploads_multi(array $filesInput): array
{
$results = [];
// $_FILES['attachments'] hat Struktur: ['name'=>[...], 'tmp_name'=>[...], ...]
$count = is_array($filesInput['name']) ? count($filesInput['name']) : 0;
for ($i = 0; $i < $count; $i++) {
if (empty($filesInput['name'][$i])) continue;
$single = [
'name' => $filesInput['name'][$i],
'type' => $filesInput['type'][$i],
'tmp_name' => $filesInput['tmp_name'][$i],
'error' => $filesInput['error'][$i],
'size' => $filesInput['size'][$i],
];
$results[] = handle_ticket_upload($single);
}
return $results;
}
/** Gruppiert ein Aggregat-Ergebnis (Spalten "grp"/"c") als [Schlüssel => Anzahl]; "none" für NULL-Gruppe */
function fetch_grouped_counts(PDO $pdo, string $sql, array $params): array
{
$out = [];
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
foreach ($stmt->fetchAll() as $row) {
$key = $row['grp'] === null ? 'none' : (string) $row['grp'];
$out[$key] = (int) $row['c'];
}
return $out;
}
function report_avg_response_hours_expression(string $driver): string
{
return $driver === 'sqlite'
? '(julianday(t.first_responded_at) - julianday(t.created_at)) * 24'
: 'TIMESTAMPDIFF(SECOND, t.created_at, t.first_responded_at) / 3600.0';
}
/** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension */
/** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension.
* $deptIds: null = kein Filter (Admin, alle Abteilungen), Array = nur diese Abteilungen (abteilungsbeschränkte Mitarbeiter). */
function build_report_table(PDO $pdo, string $dimColumn, string $from, string $to, ?array $deptIds = null): array
{
$tCol = "t.{$dimColumn}";
[$deptFilterT, $deptParamsT] = dept_filter_sql($deptIds, 't.department_id');
[$deptFilterPlain, $deptParamsPlain] = dept_filter_sql($deptIds, 'department_id');
$geoeffnet = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
$geschlossen = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.closed_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
$zugewiesen = fetch_grouped_counts($pdo,
"SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id
WHERE al.action LIKE 'Mitarbeiter zugewiesen:%' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
$ueberfaellig = fetch_grouped_counts($pdo,
"SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id
WHERE al.action LIKE 'SLA %' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
$wiederGeoeffnet = fetch_grouped_counts($pdo,
"SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id
WHERE al.action = 'Ticket wieder geöffnet' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
$geloescht = fetch_grouped_counts($pdo,
"SELECT {$dimColumn} AS grp, COUNT(*) c FROM deleted_tickets_log WHERE deleted_at BETWEEN ? AND ?{$deptFilterPlain} GROUP BY {$dimColumn}", [$from, $to, ...$deptParamsPlain]);
$serviceTimes = [];
$stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.resolved_at FROM tickets t WHERE t.resolved_at BETWEEN ? AND ?{$deptFilterT}");
$stmt->execute([$from, $to, ...$deptParamsT]);
foreach ($stmt->fetchAll() as $r) {
$key = $r['grp'] === null ? 'none' : (string) $r['grp'];
$serviceTimes[$key][] = (strtotime($r['resolved_at']) - strtotime($r['created_at'])) / 3600;
}
$responseTimes = [];
$stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.first_responded_at FROM tickets t WHERE t.first_responded_at BETWEEN ? AND ?{$deptFilterT}");
$stmt->execute([$from, $to, ...$deptParamsT]);
foreach ($stmt->fetchAll() as $r) {
$key = $r['grp'] === null ? 'none' : (string) $r['grp'];
$responseTimes[$key][] = (strtotime($r['first_responded_at']) - strtotime($r['created_at'])) / 3600;
}
$allKeys = array_unique(array_merge(
array_keys($geoeffnet), array_keys($geschlossen), array_keys($zugewiesen),
array_keys($ueberfaellig), array_keys($wiederGeoeffnet), array_keys($geloescht),
array_keys($serviceTimes), array_keys($responseTimes)
));
$rows = [];
foreach ($allKeys as $key) {
$rows[$key] = [
'geoeffnet' => $geoeffnet[$key] ?? 0,
'zugewiesen' => $zugewiesen[$key] ?? 0,
'ueberfaellig' => $ueberfaellig[$key] ?? 0,
'geschlossen' => $geschlossen[$key] ?? 0,
'wieder_geoeffnet' => $wiederGeoeffnet[$key] ?? 0,
'geloescht' => $geloescht[$key] ?? 0,
'servicezeit' => isset($serviceTimes[$key]) ? round(array_sum($serviceTimes[$key]) / count($serviceTimes[$key]), 1) : null,
'reaktionszeit' => isset($responseTimes[$key]) ? round(array_sum($responseTimes[$key]) / count($responseTimes[$key]), 1) : null,
];
}
return $rows;
}
/**
* Allowlist-Sanitizer für Rich-Text-HTML (Antworten, Wissensdatenbank, Vorlagen) und für HTML-E-Mails
* fremder Absender, die im Mitarbeiterbereich angezeigt werden. Nutzt bewusst keine DOM-Erweiterung
* (siehe README: keine zusätzlichen PHP-Erweiterungen nötig).
*
* Vorgehen: Der Text wird in Tags und Text zerlegt. Jedes Tag wird nur mit festem Namen und festen,
* neu geschriebenen Attributen ausgegeben; alles andere (auch ein nicht geschlossenes "<" im Text)
* wird als Text maskiert. Dadurch kann kein Rest-Markup entstehen, das sich mit nachfolgendem HTML
* der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities),
* weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert.
*/
/**
* Ob eine Nachricht HTML enthält (echte HTML-Tags wie <p>, <div>, <br>, <b> …) und deshalb als HTML dargestellt werden muss.
* Eine Adresse in spitzen Klammern ("Olaf <olaf@gmx.de>") ist kein HTML: Wird sie dafür gehalten, verschwinden die Zeilenumbrüche
* des Textes (HTML kennt keine).
*/
function message_is_html(string $text): bool
{
return (bool) preg_match('/<\/?(?:a|abbr|b|big|blockquote|body|br|caption|center|code|col|colgroup|div|em|figcaption|figure|font|h[1-6]|head|hr|html|i|img|li|meta|ol|p|pre|s|small|span|strike|strong|style|sub|sup|table|tbody|td|tfoot|th|thead|title|tr|u|ul|o:p)(?=[\s\/>])[^>]*>/i', $text);
}
/**
* @param array{remote_images?:string,cid_map?:array<string,string>} $opts
* remote_images: 'placeholder' = Bilder von fremden Adressen (https) werden nicht geladen, sondern durch einen Platzhalter ersetzt
* (Datenschutz: ein Bild von außen verrät dem Absender, wann und von wo die Mail geöffnet wurde).
* cid_map: Content-ID (klein, ohne <>) => Adresse des Bildes, das aus der Mail übernommen wurde (cid:-Bilder im Text)
*/
function sanitize_staff_html(string $html, array $opts = []): string
{
$placeholderRemote = ($opts['remote_images'] ?? '') === 'placeholder';
$cidMap = (array) ($opts['cid_map'] ?? []);
if (trim($html) === '') return '';
// Gefährliche Elemente komplett inkl. Inhalt entfernen
$blocked = 'script|style|iframe|frame|frameset|object|embed|applet|link|meta|base|form|input|textarea|select|option|button|svg|math|template|noscript|title|head|xml';
$html = preg_replace('/<(' . $blocked . ')\b[^>]*>.*?<\/\1\s*>/is', '', $html);
$html = preg_replace('/<\/?(' . $blocked . ')\b[^>]*>/is', '', $html);
// Kommentare, Doctype, bedingte Kommentare und Verarbeitungsanweisungen entfernen
$html = preg_replace('/<!--.*?-->/s', '', $html);
$html = preg_replace('/<[!?][^>]*>/s', '', $html);
$allowed = [
'b','strong','i','em','u','s','ul','ol','li','br','p','div','span','a',
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
'h1','h2','h3','h4','h5','h6','hr','blockquote','pre','code','img',
'figure','figcaption','small','sup','sub',
];
$styleTags = ['img','td','th','tr','table','div','span','p','blockquote'];
$extraAttrs = [
'img' => ['alt','width','height'],
'td' => ['colspan','rowspan','align','valign','width'],
'th' => ['colspan','rowspan','align','valign','width'],
'table' => ['border','cellpadding','cellspacing','width'],
];
// Attributwert lesen (Anführungszeichen Pflicht, Name nicht Teil eines anderen Namens), Entities auflösen
$attr = static function (string $raw, string $name): ?string {
if (!preg_match('/(?<![\w:-])' . $name . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/is', $raw, $m)) return null;
$v = html_entity_decode($m[1] !== '' ? $m[1] : ($m[2] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/[\x00-\x1F\x7F]+/', '', $v));
};
// CSS auf eine Liste harmloser Eigenschaften beschränken (kein url(), kein position, keine Skripte)
$cleanStyle = static function (string $css): string {
$okProps = ['color','background-color','background','font','font-size','font-weight','font-style','font-family','text-align',
'text-decoration','line-height','margin','margin-top','margin-bottom','margin-left','margin-right','padding','padding-top',
'padding-bottom','padding-left','padding-right','border','border-top','border-bottom','border-left','border-right',
'border-color','border-style','border-width','border-collapse','border-spacing','width','height','max-width','min-width',
'vertical-align','white-space','list-style-type','text-indent','letter-spacing','word-break'];
$out = [];
foreach (explode(';', $css) as $decl) {
if (!str_contains($decl, ':')) continue;
[$prop, $val] = array_map('trim', explode(':', $decl, 2));
$prop = strtolower($prop);
if (!in_array($prop, $okProps, true) || $val === '') continue;
if (preg_match('#[\\\\@<>{}]|/\*|expression|javascript|behavior|-moz-binding|url\s*\(|image\s*\(|var\s*\(|attr\s*\(#i', $val)) continue;
$plain = preg_replace('/\b(?:rgb|rgba|hsl|hsla)\([0-9.,%\s\/]*\)/i', '', $val);
if (str_contains((string) $plain, '(') || strlen($val) > 120) continue;
$out[] = $prop . ': ' . $val;
}
return implode('; ', $out);
};
$e = static fn(string $v): string => htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$parts = preg_split('/(<[^<>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
$result = '';
foreach ($parts as $i => $part) {
if ($part === '') continue;
if ($i % 2 === 0) {
// Text: nie ein "<" oder ">" durchlassen, das der Browser als Markup lesen könnte
$result .= str_replace(['<', '>'], ['&lt;', '&gt;'], $part);
continue;
}
if (!preg_match('/^<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^<>]*)?)(\/?)>$/s', $part, $m)) {
$result .= str_replace(['<', '>'], ['&lt;', '&gt;'], $part); // kein Tag (z. B. "a < b und c > d"): als Text behalten
continue;
}
$tag = strtolower($m[2]);
if (!in_array($tag, $allowed, true)) continue;
if ($m[1] === '/') { $result .= "</{$tag}>"; continue; }
$raw = $m[3];
$out = '';
if ($tag === 'a') {
$href = $attr($raw, 'href');
if ($href !== null && preg_match('#^(https?:|mailto:|cid:)#i', $href)) $out .= ' href="' . $e($href) . '"';
$result .= '<a' . $out . ' target="_blank" rel="noopener noreferrer">';
continue;
}
if ($tag === 'img') {
$src = $attr($raw, 'src');
if ($src !== null) {
// Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen
$blank = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=';
if (preg_match('#^cid:#i', $src)) {
$cid = strtolower(trim(substr($src, 4), " \t\r\n<>"));
if (isset($cidMap[$cid])) $out .= ' src="' . $e((string) $cidMap[$cid]) . '"';
elseif ($placeholderRemote) $out .= ' src="' . $blank . '" title="Bild aus der E-Mail nicht verfügbar"';
else $out .= ' src="' . $e($src) . '"';
} elseif ($placeholderRemote && preg_match('#^https://#i', $src)) {
$out .= ' src="' . $blank . '" title="Externes Bild aus Datenschutzgründen nicht geladen" data-blocked-remote="1"';
} elseif (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);)#i', $src)) {
$out .= ' src="' . $e($src) . '"';
} else {
$out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"';
}
}
}
foreach ($extraAttrs[$tag] ?? [] as $name) {
$v = $attr($raw, $name);
if ($v !== null) $out .= ' ' . $name . '="' . $e($v) . '"';
}
if (in_array($tag, $styleTags, true)) {
$st = $attr($raw, 'style');
if ($st !== null) {
$st = $cleanStyle($st);
if ($st !== '') $out .= ' style="' . $e($st) . '"';
}
}
$result .= ($m[4] === '/') ? "<{$tag}{$out}/>" : "<{$tag}{$out}>";
}
return $result;
}
/**
* Ersetzt Variablen in einem Canned-Response-Text.
*
* Verfügbare Variablen:
* {{kundename}} Name des Ticketersellers
* {{vorname}} Erster Teil des Namens
* {{email}} E-Mail des Kunden
* {{ticketnummer}} Ticket-Nummer (z.B. #532085)
* {{betreff}} Betreff des Tickets
* {{abteilung}} Name der Abteilung
* {{prioritaet}} Priorität
* {{status}} Aktueller Status
* {{mitarbeiter}} Name des zugewiesenen Mitarbeiters
* {{datum}} Heutiges Datum (TT.MM.JJJJ)
* {{uhrzeit}} Aktuelle Uhrzeit (HH:MM)
* {{signatur}} Signatur des eingeloggten Mitarbeiters
*/
function resolve_canned_vars(string $text, array $ticket, array $staff): string
{
$name = $ticket['requester_name'] ?? '';
$vorname = explode(' ', trim($name))[0] ?? $name;
$vars = [
'{{kundename}}' => $name,
'{{vorname}}' => $vorname,
'{{email}}' => $ticket['requester_email'] ?? '',
'{{ticketnummer}}' => '#' . ($ticket['ticket_number'] ?? ''),
'{{betreff}}' => $ticket['subject'] ?? '',
'{{abteilung}}' => $ticket['department_name'] ?? '',
'{{prioritaet}}' => $ticket['priority_name'] ?? '',
'{{status}}' => $ticket['status'] ?? '',
'{{mitarbeiter}}' => $ticket['staff_name'] ?? $staff['name'] ?? '',
'{{datum}}' => date('d.m.Y'),
'{{uhrzeit}}' => date('H:i'),
'{{signatur}}' => $staff['signature'] ?? '',
];
return str_replace(array_keys($vars), array_values($vars), $text);
}
/**
* Prüft ob ein Mitarbeiter aktuell verfügbar ist (nicht im Urlaub,
* Status nicht 'offline', und falls Schichten definiert: innerhalb der Schicht).
*/
function is_staff_available(PDO $pdo, int $staffId): bool
{
// Urlaub
$s = $pdo->prepare('SELECT is_on_vacation FROM staff WHERE id = ?');
$s->execute([$staffId]);
$row = $s->fetch();
if (!$row || $row['is_on_vacation']) return false;
// Echtzeit-Status
try {
staff_run_auto_offline($pdo);
$s = $pdo->prepare('SELECT status FROM staff_availability WHERE staff_id = ?');
$s->execute([$staffId]);
$avail = $s->fetchColumn();
if ($avail === 'offline') return false;
} catch (Throwable $e) { /* Tabelle noch nicht angelegt */ }
// Schichtplan – nur prüfen wenn Schichten definiert sind
try {
$s = $pdo->prepare('SELECT COUNT(*) FROM staff_shifts WHERE staff_id = ? AND is_active = 1');
$s->execute([$staffId]);
if ((int)$s->fetchColumn() > 0) {
$dow = (int)date('N'); // 1=Mo … 7=So
$time = date('H:i:s');
$s = $pdo->prepare(
'SELECT COUNT(*) FROM staff_shifts
WHERE staff_id = ? AND weekday = ? AND is_active = 1
AND start_time <= ? AND end_time > ?'
);
$s->execute([$staffId, $dow, $time, $time]);
if ((int)$s->fetchColumn() === 0) return false; // außerhalb Schicht
}
} catch (Throwable $e) { /* ignorieren */ }
return true;
}
/** Prüft, ob eine E-Mail-Adresse (per exaktem Treffer oder Domain) auf der Sperrliste steht */
function is_banned(string $email): bool
{
$email = trim(strtolower($email));
if ($email === '') return false;
$domain = substr(strrchr($email, '@'), 1) ?: '';
$stmt = get_db()->prepare(
"SELECT 1 FROM banlist WHERE
(match_type = 'email' AND LOWER(value) = ?)
OR (match_type = 'domain' AND LOWER(value) = ?)
LIMIT 1"
);
$stmt->execute([$email, $domain]);
return (bool) $stmt->fetchColumn();
}
/**
* Wertet alle aktiven Ticket-Regeln (sort_order) gegen die Daten eines neuen Tickets aus
* und liefert die anzuwendenden Aktionen zurück. $ticketData braucht die Schlüssel:
* subject, message, requester_email, requester_name, source.
* Sobald eine Regel mit der Aktion "reject" zutrifft, werden keine weiteren Regeln mehr geprüft.
*/
function evaluate_ticket_rules(PDO $pdo, array $ticketData): array
{
$result = [
'reject' => false,
'set_department' => null,
'set_priority' => null,
'set_help_topic' => null,
'assign_staff' => null,
'add_tags' => [],
'auto_reply' => null,
'close' => false,
'matched_rules' => [],
];
$rules = $pdo->query('SELECT * FROM ticket_rules WHERE is_active = 1 ORDER BY sort_order, id')->fetchAll();
foreach ($rules as $rule) {
$condStmt = $pdo->prepare('SELECT * FROM ticket_rule_conditions WHERE rule_id = ?');
$condStmt->execute([$rule['id']]);
$conditions = $condStmt->fetchAll();
if (!$conditions) continue;
$matchAll = $rule['match_type'] === 'all';
$matched = $matchAll; // bei "all" optimistisch starten, bei "any" pessimistisch
foreach ($conditions as $cond) {
$fieldValue = mb_strtolower((string) ($ticketData[$cond['field']] ?? ''));
$condValue = mb_strtolower($cond['value']);
$hit = match ($cond['operator']) {
'contains' => $condValue !== '' && str_contains($fieldValue, $condValue),
'not_contains' => $condValue === '' || !str_contains($fieldValue, $condValue),
'equals' => $fieldValue === $condValue,
'starts_with' => str_starts_with($fieldValue, $condValue),
'ends_with' => str_ends_with($fieldValue, $condValue),
default => false,
};
if ($matchAll && !$hit) { $matched = false; break; }
if (!$matchAll && $hit) { $matched = true; break; }
}
if (!$matched) continue;
$result['matched_rules'][] = $rule['name'];
$actStmt = $pdo->prepare('SELECT * FROM ticket_rule_actions WHERE rule_id = ?');
$actStmt->execute([$rule['id']]);
foreach ($actStmt->fetchAll() as $act) {
switch ($act['action_type']) {
case 'reject': $result['reject'] = true; break;
case 'close': $result['close'] = true; break;
case 'set_department': $result['set_department'] = (int) $act['action_value']; break;
case 'set_priority': $result['set_priority'] = (int) $act['action_value']; break;
case 'set_help_topic': $result['set_help_topic'] = (int) $act['action_value']; break;
case 'assign_staff': $result['assign_staff'] = (int) $act['action_value']; break;
case 'add_tag': $result['add_tags'][] = $act['action_value']; break;
case 'auto_reply': $result['auto_reply'] = (int) $act['action_value']; break;
}
}
if ($result['reject']) break; // weitere Regeln sind irrelevant, Ticket wird ohnehin verworfen
}
return $result;
}
/**
* Schließt ein neu angelegtes Ticket, wenn eine Regel die Aktion "Ticket schließen" enthält. Gibt true zurück, wenn geschlossen wurde;
* dann sollten keine Benachrichtigungen an Mitarbeiter oder Kunden mehr verschickt werden.
*/
function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
{
if (empty($ruleResult['close'])) return false;
$now = db_now();
$statusStmt = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
$statusStmt->execute([$ticketId]);
$oldStatus = (string) ($statusStmt->fetchColumn() ?: 'open');
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, resolution_code = 'automated', resolution_note = 'Automatisch durch Ticket-Regel geschlossen', updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
ticket_sla_status_transition($pdo, $ticketId, $oldStatus, 'closed');
log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)');
return true;
}
/**
* Verschickt automatisch einen hinterlegten Textbaustein als Antwort, wenn eine
* Ticket-Regel eine "Auto-Antwort senden"-Aktion ausgelöst hat (siehe
* evaluate_ticket_rules(), Ergebnis-Schlüssel 'auto_reply' = Textbaustein-ID).
* Direkt nach der Ticket-Erstellung aufrufen – NUR an Stellen, an denen das Ticket
* automatisiert (nicht durch einen Mitarbeiter) entsteht: ticket_new.php,
* live_chat.php, cron/fetch_emails.php. Bei manuell von Mitarbeitern angelegten
* Tickets (staff/ticket_create.php) bewusst NICHT aufrufen, da dort schon ein
* Mensch draufgeschaut hat.
*/
function apply_auto_reply(PDO $pdo, int $ticketId, ?int $cannedResponseId): void
{
if (!$cannedResponseId) return;
$cannedStmt = $pdo->prepare('SELECT body FROM canned_responses WHERE id = ?');
$cannedStmt->execute([$cannedResponseId]);
$body = $cannedStmt->fetchColumn();
if ($body === false) return; // Textbaustein wurde inzwischen gelöscht
$ticketStmt = $pdo->prepare(
'SELECT t.*, d.name AS department_name, p.name AS priority_name
FROM tickets t
JOIN departments d ON d.id = t.department_id
JOIN priorities p ON p.id = t.priority_id
WHERE t.id = ?'
);
$ticketStmt->execute([$ticketId]);
$ticket = $ticketStmt->fetch();
if (!$ticket) return;
// author_type='staff' mit staff_id=NULL statt 'system': so rendert die Antwort
// überall (Kundenansicht, Live-Chat-Widget, Mobile App) korrekt als Support-Bubble
// statt versehentlich wie eine Kunden-Nachricht auszusehen – der bestehende Code
// fragt an vielen Stellen nur author_type==='staff' ab, nicht 'system'.
$message = resolve_canned_vars($body, $ticket, []);
$now = db_now();
$pdo->prepare(
"INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,'staff',NULL,?,0,?)"
)->execute([$ticketId, $message, $now]);
$updateFirstResponse = empty($ticket['first_responded_at']);
$sql = "UPDATE tickets SET status = 'answered', updated_at = ?" . ($updateFirstResponse ? ', first_responded_at = ?' : '') . ' WHERE id = ?';
$params = [$now];
if ($updateFirstResponse) $params[] = $now;
$params[] = $ticketId;
$pdo->prepare($sql)->execute($params);
log_activity($pdo, $ticketId, null, 'Automatische Antwort gesendet (Ticket-Regel)');
try {
notify_customer_new_reply($ticket, $message);
} catch (Throwable $e) {
error_log('apply_auto_reply: ' . $e->getMessage());
}
}
/** Verknüpft (und legt bei Bedarf an) einen Tag mit einem Ticket – gleiche Logik wie staff/ticket_view.php */
function apply_tag_to_ticket(PDO $pdo, int $ticketId, string $tagName): void
{
$tagName = trim($tagName);
if ($tagName === '') return;
$stmt = $pdo->prepare('SELECT id FROM tags WHERE name = ?');
$stmt->execute([$tagName]);
$tagId = $stmt->fetchColumn();
if (!$tagId) {
$palette = ['#0f6f6a', '#2563eb', '#d97706', '#a855f7', '#16a34a', '#dc2626'];
$color = $palette[array_rand($palette)];
$pdo->prepare('INSERT INTO tags (name, color) VALUES (?,?)')->execute([$tagName, $color]);
$tagId = (int) $pdo->lastInsertId();
}
$linkStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_tags WHERE ticket_id = ? AND tag_id = ?');
$linkStmt->execute([$ticketId, $tagId]);
if ((int) $linkStmt->fetchColumn() === 0) {
$pdo->prepare('INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?,?)')->execute([$ticketId, $tagId]);
}
}
/** Anzeigenamen der 16 deutschen Bundesländer, Kürzel → Name (für das Feiertags-Dropdown
* in staff/business_hours.php und als Whitelist gültiger Codes für german_public_holidays()). */
const GERMAN_STATE_NAMES = [
'BW' => 'Baden-Württemberg',
'BY' => 'Bayern',
'BE' => 'Berlin',
'BB' => 'Brandenburg',
'HB' => 'Bremen',
'HH' => 'Hamburg',
'HE' => 'Hessen',
'MV' => 'Mecklenburg-Vorpommern',
'NI' => 'Niedersachsen',
'NW' => 'Nordrhein-Westfalen',
'RP' => 'Rheinland-Pfalz',
'SL' => 'Saarland',
'SN' => 'Sachsen',
'ST' => 'Sachsen-Anhalt',
'SH' => 'Schleswig-Holstein',
'TH' => 'Thüringen',
];
/**
* Liefert den Buß- und Bettag (nur Sachsen) für ein Jahr: der Mittwoch vor dem 23.
* November – ausgehend vom 22.11. rückwärts zum nächsten Mittwoch gesucht, damit auch der
* Sonderfall "23.11. ist selbst ein Mittwoch" korrekt eine Woche früher landet (z.B. 2022:
* 23.11. war ein Mittwoch → Buß- und Bettag war der 16.11., nicht der 23.11. selbst).
*/
function german_buss_und_bettag(int $year): string
{
$d = new DateTime("{$year}-11-22");
while ((int) $d->format('N') !== 3) { // 3 = Mittwoch (ISO-8601: 1=Montag..7=Sonntag)
$d->modify('-1 day');
}
return $d->format('Y-m-d');
}
/**
* Berechnet den Termin des Ostersonntags (gregorianischer Kalender) nach dem
* Gauß/Meeus-Algorithmus – bewusst NICHT PHP's eingebaute easter_date(): die liefert in
* diesem Environment nachweislich ein Datum zu früh (geprüft gegen mehrere bekannte
* Ostertermine, z.B. 2023: PHP gibt den 8.4. statt korrekt 9.4. zurück – konsequent einen
* Tag daneben, über alle getesteten Jahre). Diese Eigenimplementierung ist eine
* eigenständige, gut dokumentierte Standardformel ohne Abhängigkeit von der
* PHP-Calendar-Extension oder deren Plattform-Eigenheiten.
*/
function german_easter_sunday(int $year): DateTime
{
$a = $year % 19;
$b = intdiv($year, 100);
$c = $year % 100;
$d = intdiv($b, 4);
$e = $b % 4;
$f = intdiv($b + 8, 25);
$g = intdiv($b - $f + 1, 3);
$h = (19 * $a + $b - $d - $g + 15) % 30;
$i = intdiv($c, 4);
$k = $c % 4;
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
$m = intdiv($a + 11 * $h + 22 * $l, 451);
$month = intdiv($h + $l - 7 * $m + 114, 31);
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
return new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day));
}
/**
* Berechnet die gesetzlichen Feiertage eines deutschen Bundeslandes für ein Kalenderjahr –
* inkl. beweglicher, von Ostern abhängiger Feiertage (siehe german_easter_sunday() oben).
* Wird für den "Feiertage automatisch übernehmen"-Import in staff/business_hours.php
* genutzt, damit nicht jeder Feiertag einzeln von Hand eingetragen werden muss – einfach
* Bundesland + Jahr wählen.
*
* Quelle der Regeln: bundesweit einheitliche Feiertage + je Bundesland zusätzliche, nach
* aktuellem Stand der Feiertagsgesetze der Länder. Mariä Himmelfahrt (Bayern) gilt offiziell
* nur in Gemeinden mit überwiegend katholischer Bevölkerung – wird hier trotzdem als
* Bayern-Feiertag mitgeführt (üblicher Standard in Feiertagslisten), im Zweifel manuell
* wieder entfernen, falls der Standort davon nicht betroffen ist.
*
* @return array<int, array{date: string, name: string}> sortiert nach Datum
*/
function german_public_holidays(string $stateCode, int $year): array
{
if (!isset(GERMAN_STATE_NAMES[$stateCode])) {
throw new InvalidArgumentException("Unbekanntes Bundesland-Kürzel: {$stateCode}");
}
$easter = german_easter_sunday($year);
$offset = function (int $days) use ($easter): string {
$d = clone $easter;
$d->modify("{$days} days");
return $d->format('Y-m-d');
};
$fixed = fn (string $monthDay): string => "{$year}-{$monthDay}";
// Bundesweit einheitliche Feiertage
$holidays = [
$fixed('01-01') => 'Neujahr',
$offset(-2) => 'Karfreitag',
$offset(1) => 'Ostermontag',
$fixed('05-01') => 'Tag der Arbeit',
$offset(39) => 'Christi Himmelfahrt',
$offset(50) => 'Pfingstmontag',
$fixed('10-03') => 'Tag der Deutschen Einheit',
$fixed('12-25') => '1. Weihnachtstag',
$fixed('12-26') => '2. Weihnachtstag',
];
$dreiKoenige = [$fixed('01-06') => 'Heilige Drei Könige'];
$fronleichnam = [$offset(60) => 'Fronleichnam'];
$mariaHimmelfahrt = [$fixed('08-15') => 'Mariä Himmelfahrt'];
$allerheiligen = [$fixed('11-01') => 'Allerheiligen'];
$reformationstag = [$fixed('10-31') => 'Reformationstag'];
$frauentag = [$fixed('03-08') => 'Internationaler Frauentag'];
$weltkindertag = [$fixed('09-20') => 'Weltkindertag'];
$bussUndBettag = [german_buss_und_bettag($year) => 'Buß- und Bettag'];
$byState = [
'BW' => $dreiKoenige + $fronleichnam + $allerheiligen,
'BY' => $dreiKoenige + $fronleichnam + $mariaHimmelfahrt + $allerheiligen,
'BE' => $frauentag,
'BB' => $reformationstag,
'HB' => $reformationstag,
'HH' => $reformationstag,
'HE' => $fronleichnam,
'MV' => $reformationstag + $weltkindertag,
'NI' => $reformationstag,
'NW' => $fronleichnam + $allerheiligen,
'RP' => $fronleichnam + $allerheiligen,
'SL' => $fronleichnam + $mariaHimmelfahrt + $allerheiligen,
'SN' => $reformationstag + $bussUndBettag,
'ST' => $dreiKoenige + $reformationstag,
'SH' => $reformationstag,
'TH' => $weltkindertag + $reformationstag,
];
$holidays = array_merge($holidays, $byState[$stateCode] ?? []);
ksort($holidays);
$result = [];
foreach ($holidays as $date => $name) {
$result[] = ['date' => $date, 'name' => $name];
}
return $result;
}
/**
* Addiert eine Anzahl Arbeitsstunden zu einem Zeitpunkt, unter Berücksichtigung der
* konfigurierten Geschäftszeiten (Tabelle business_hours) und Feiertage (business_holidays).
* Liegt der Startzeitpunkt außerhalb der Geschäftszeiten, zählt die Frist erst ab dem
* nächsten Öffnungszeitpunkt.
*/
function add_business_hours(string $from, int $hours): string
{
$pdo = get_db();
$rows = $pdo->query('SELECT weekday, is_open, open_time, close_time FROM business_hours')->fetchAll();
$byWeekday = [];
foreach ($rows as $r) $byWeekday[(int) $r['weekday']] = $r;
$holidays = $pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN);
$holidaySet = array_flip($holidays);
$remainingMinutes = $hours * 60;
$cursor = new DateTime($from);
$maxIterations = 3650; // Sicherheitsgrenze (~10 Jahre Tage), falls z.B. alle Tage geschlossen sind
$iterations = 0;
while ($remainingMinutes > 0 && $iterations < $maxIterations) {
$iterations++;
$weekday = (int) $cursor->format('w');
$dateStr = $cursor->format('Y-m-d');
$dayConfig = $byWeekday[$weekday] ?? null;
if (!$dayConfig || !$dayConfig['is_open'] || isset($holidaySet[$dateStr])) {
$cursor->modify('+1 day');
$cursor->setTime(0, 0, 0);
continue;
}
$dayOpen = new DateTime($dateStr . ' ' . $dayConfig['open_time']);
$dayClose = new DateTime($dateStr . ' ' . $dayConfig['close_time']);
if ($cursor < $dayOpen) $cursor = clone $dayOpen;
if ($cursor >= $dayClose) {
$cursor->modify('+1 day');
$cursor->setTime(0, 0, 0);
continue;
}
// WICHTIG (echter Bug, live auf Produktion nachgewiesen): ganzzahlig per intdiv()
// statt "/ 60" – eine Fließkommazahl mit Nachkommastellen (z.B. 0.3333…) führt bei
// DateTime::modify("+X minutes") zu einem komplett falschen Sprung um tausende
// Jahre statt der paar Sekunden Rundungsdifferenz, die hier eigentlich gemeint war
// (bekannter PHP-DateTime-Fallstrick mit Dezimalwerten in relativen Formaten).
$availableMinutesToday = intdiv($dayClose->getTimestamp() - $cursor->getTimestamp(), 60);
if ($remainingMinutes <= $availableMinutesToday) {
$cursor->modify("+{$remainingMinutes} minutes");
$remainingMinutes = 0;
} else {
$remainingMinutes -= $availableMinutesToday;
$cursor = clone $dayClose;
$cursor->modify('+1 day');
$cursor->setTime(0, 0, 0);
}
}
return $cursor->format('Y-m-d H:i:s');
}
// ====================================================================
// Rollen & Berechtigungen
// ====================================================================
// Rechte-Katalog, Standardrechte und Vorlagen: siehe includes/permissions.php (permission_catalog(), default_permissions(), admin_section_permissions())
/**
* Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat.
* Admins haben immer alle Rechte. Bei Agenten greift ggf. eine benutzerdefinierte Rolle.
*/
function has_permission(array $staff, string $permission, ?int $departmentId = null): bool
{
if ($staff['role'] === 'admin') return true;
$roleId = !empty($staff['role_id']) ? (int) $staff['role_id'] : 0;
// Rolle pro Abteilung (erweiterter Zugriff): überschreibt die Hauptrolle nur für diese Abteilung.
// Ohne eigene Rolle an der Zugriffszeile gilt weiter die Hauptrolle.
if ($departmentId !== null && $departmentId !== (int) ($staff['department_id'] ?? 0)) {
static $deptRoleCache = [];
$key = $staff['id'] . ':' . $departmentId;
if (!array_key_exists($key, $deptRoleCache)) {
$stmt = get_db()->prepare('SELECT role_id FROM staff_department WHERE staff_id = ? AND department_id = ?');
$stmt->execute([$staff['id'], $departmentId]);
$deptRoleCache[$key] = $stmt->fetchColumn() ?: null;
}
if ($deptRoleCache[$key] !== null) $roleId = (int) $deptRoleCache[$key];
}
if ($roleId > 0) {
static $roleCache = [];
$rid = $roleId;
if (!array_key_exists($rid, $roleCache)) {
$stmt = get_db()->prepare('SELECT permissions, is_active FROM roles WHERE id = ?');
$stmt->execute([$rid]);
$row = $stmt->fetch();
// Deaktivierte oder gelöschte Rolle: es gelten wieder die Standard-Mitarbeiterrechte
$roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null;
}
$perms = $roleCache[$rid];
// Fehlt ein Recht in der gespeicherten Rolle: ein altes Recht gilt wie bisher als „nein“, ein neueres mit dem Standardwert des Katalogs
if ($perms !== null) return array_key_exists($permission, $perms) ? (bool) $perms[$permission] : (!in_array($permission, permission_legacy_keys(), true) && (bool) (default_permissions('agent')[$permission] ?? false));
}
$defaults = default_permissions('agent');
return (bool) ($defaults[$permission] ?? false);
}
// ── @Erwähnungen ────────────────────────────────────────────────
/** Parst @Name-Erwähnungen aus einer Nachricht und gibt Staff-IDs zurück */
function parse_mentions(PDO $pdo, string $message): array
{
preg_match_all('/@([^\s<,;]+)/u', strip_tags($message), $matches);
if (empty($matches[1])) return [];
$found = [];
foreach ($matches[1] as $name) {
$stmt = $pdo->prepare("SELECT id FROM staff WHERE is_active=1 AND (name=? OR name LIKE ?) LIMIT 1");
$stmt->execute([$name, $name . ' %']);
$id = $stmt->fetchColumn();
if ($id) $found[(int)$id] = true;
}
return array_keys($found);
}
/** Speichert Erwähnungen und benachrichtigt betroffene Mitarbeiter */
function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, string $message, array $ticket): void
{
$ids = parse_mentions($pdo, $message);
foreach ($ids as $mentionedId) {
if ($mentionedId === $byStaffId) continue;
$pdo->prepare('INSERT INTO staff_mentions (ticket_id,thread_id,mentioned_staff_id,mentioned_by_staff_id,created_at) VALUES (?,?,?,?,?)')
->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]);
fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App
// E-Mail an erwähnten Mitarbeiter
// Im Urlaub geht die Mail an die Vertretung
$mailTo = vacation_expand_ids($pdo, [$mentionedId]);
$stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?');
$stmt->execute([$mailTo ? $mailTo[0] : 0]);
$mentioned = $stmt->fetch();
if ($mentioned) {
$by = $pdo->prepare('SELECT name FROM staff WHERE id=?');
$by->execute([$byStaffId]);
$byName = $by->fetchColumn() ?: 'Ein Mitarbeiter';
$link = rtrim(config()['site']['url'], '/') . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']);
$inner = email_p("Hallo " . e($mentioned['name']) . ",")
. email_p("<strong>" . e($byName) . "</strong> hat Sie in Ticket <strong>#{$ticket['ticket_number']}: " . e($ticket['subject']) . "</strong> erwähnt.", '24px')
. email_button('Zum Ticket', e($link));
email_send_and_log(
$mentioned['email'],
"Sie wurden in Ticket #{$ticket['ticket_number']} erwähnt",
email_wrap($inner),
null,
'Erwähnung-Mail',
$ticket['ticket_number']
);
}
}
}
// ── Zeiterfassung ────────────────────────────────────────────────
/** Gibt die Summe der erfassten Minuten für ein Ticket zurück */
function ticket_total_minutes(PDO $pdo, int $ticketId): int
{
$stmt = $pdo->prepare('SELECT COALESCE(SUM(minutes),0) FROM ticket_time_entries WHERE ticket_id=?');
$stmt->execute([$ticketId]);
return (int) $stmt->fetchColumn();
}
/**
* Eigenes Ticket-Formular einer Abteilung. Gibt null zurück, wenn die Abteilung das Standardformular nutzt.
* Sonst die Texte/Optionen (mit Standardwerten ergänzt) und die Felder der Abteilung.
*
* @return array{title:string,intro:string,subject_show:bool,subject_label:string,message_label:string,message_hint:string,attachment_show:bool,submit_label:string,fields:array}|null
*/
function department_form(PDO $pdo, ?int $departmentId): ?array
{
if (!$departmentId) return null;
$stmt = $pdo->prepare('SELECT name, own_form, form_config FROM departments WHERE id = ?');
$stmt->execute([$departmentId]);
$d = $stmt->fetch();
if (!$d || empty($d['own_form'])) return null;
$cfg = json_decode((string) ($d['form_config'] ?? ''), true);
$cfg = is_array($cfg) ? $cfg : [];
$str = fn(string $k, string $def) => (isset($cfg[$k]) && is_string($cfg[$k]) && trim($cfg[$k]) !== '') ? trim($cfg[$k]) : $def;
$fields = $pdo->prepare('SELECT * FROM custom_fields WHERE department_id = ? AND is_active = 1 ORDER BY sort_order, label');
$fields->execute([$departmentId]);
$out = [
'title' => $str('title', ''),
'intro' => $str('intro', ''),
'subject_show' => !array_key_exists('subject_show', $cfg) || !empty($cfg['subject_show']),
'subject_label' => $str('subject_label', 'Betreff'),
'message_label' => $str('message_label', 'Beschreibung'),
'message_show' => !array_key_exists('message_show', $cfg) || !empty($cfg['message_show']),
'subject_width' => in_array($cfg['subject_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['subject_width'] : 'full',
'message_width' => in_array($cfg['message_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['message_width'] : 'full',
'message_hint' => $str('message_hint', 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…'),
'attachment_show' => !array_key_exists('attachment_show', $cfg) || !empty($cfg['attachment_show']),
'submit_label' => $str('submit_label', 'Ticket erstellen'),
'department_name' => (string) $d['name'],
'contact_title' => $str('contact_title', 'Ihre Kontaktdaten'),
'issue_title' => $str('issue_title', 'Ihr Anliegen'),
'details_title' => $str('details_title', 'Ihr Anliegen'),
'fields' => [],
];
$form = $out;
foreach ($fields->fetchAll() as $f) {
$sec = trim((string) ($f['form_section'] ?? ''));
if ($sec === '') $f['_section'] = 'extra';
elseif ($sec === '@topic') $f['_section'] = 'topic';
elseif ($sec === '@topic_top') $f['_section'] = 'topic_top';
elseif ($sec === '@contact_top') $f['_section'] = 'contact_top';
elseif ($sec === '@contact') $f['_section'] = 'contact';
elseif ($sec === '@details') $f['_section'] = 'issue';
elseif ($sec === '@details_top') $f['_section'] = 'issue_top';
elseif ($sec === '@details_mid') $f['_section'] = 'issue_mid';
elseif (mb_strtolower($sec) === mb_strtolower($form['contact_title'])) $f['_section'] = 'contact';
elseif (in_array(mb_strtolower($sec), [mb_strtolower($form['issue_title']), mb_strtolower($form['details_title'])], true)) $f['_section'] = 'issue';
else $f['_section'] = $sec;
$f['_section_title'] = in_array($f['_section'], ['topic', 'topic_top'], true) ? $form['issue_title'] : ($f['_section'] === 'extra' ? 'Zusätzliche Angaben' : (in_array($f['_section'], ['contact', 'contact_top'], true) ? $form['contact_title'] : (in_array($f['_section'], ['issue', 'issue_top', 'issue_mid'], true) ? $form['details_title'] : $sec)));
$form['fields'][] = $f;
}
return $form;
}
/**
* Hinweistext eines Formularfeldes als sicheres HTML: Text wird maskiert, Links schreibt man als [Text](https://adresse)
* (nur http, https oder Adressen auf dieser Seite, die mit / beginnen). Zeilenumbrüche bleiben erhalten.
*/
function form_help_html(?string $text, string $linkLabel = ''): string
{
// Steht nur eine Adresse im Text, wird die Bezeichnung des Feldes zum Link (statt der nackten Adresse)
if ($linkLabel !== '' && preg_match('#^\s*(https?://|/)[^\s)"\'<>]{1,500}\s*$#', (string) $text)) {
$text = '[' . $linkLabel . '](' . trim((string) $text) . ')';
}
$html = e((string) $text);
$html = preg_replace_callback('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', function ($m) {
return '<a href="' . $m[2] . '" target="_blank" rel="noopener noreferrer">' . $m[1] . '</a>';
}, $html);
// Adressen ohne [Text](…) werden ebenfalls anklickbar (nicht, wenn sie schon in einem Link stehen)
$html = preg_replace_callback('~(<a [^>]*>.*?</a>)|(https?://[^\s<>"\']+)~u', function ($m) {
if (!empty($m[1])) return $m[1];
$url = rtrim($m[2], '.,;:!?)');
$rest = substr($m[2], strlen($url));
return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>' . $rest;
}, $html);
return nl2br($html, false);
}
/** Setzt Hinweistext, Link-Adresse und verlinkten Textteil (Eingabefelder im Admin) zu gespeichertem Text mit [Wort](Adresse) zusammen. */
function form_help_compose(string $text, string $url, string $word, string $fallbackLabel = ''): string
{
$text = trim($text);
$url = trim($url);
$word = trim($word);
if (preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $text) && $url === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $text . ')';
if ($url === '' || !preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $url)) return mb_substr($text, 0, 1000);
if ($text === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $url . ')';
if ($word !== '' && ($pos = mb_stripos($text, $word)) !== false) {
$found = mb_substr($text, $pos, mb_strlen($word));
return mb_substr(mb_substr($text, 0, $pos) . '[' . $found . '](' . $url . ')' . mb_substr($text, $pos + mb_strlen($word)), 0, 1000);
}
return mb_substr($text . ' [' . ($word !== '' ? $word : 'Mehr erfahren') . '](' . $url . ')', 0, 1000);
}
/** Gegenstück zu form_help_compose(): zerlegt gespeicherten Text in [Text ohne Link-Syntax, Adresse, verlinkter Teil]. */
function form_help_split(?string $stored): array
{
$stored = (string) $stored;
if (preg_match('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', $stored, $m, PREG_OFFSET_CAPTURE)) {
$plain = substr($stored, 0, $m[0][1]) . $m[1][0] . substr($stored, $m[0][1] + strlen($m[0][0]));
return [$plain, $m[2][0], $m[1][0]];
}
return [$stored, '', ''];
}