';
}
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.');
}
}
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.');
}
// Token nach Verbrauch rotieren
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
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),
];
}
/**
* 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)";
}
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);
}
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'];
}
return array_values(array_unique($ids));
}
/** 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(),
]);
$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 request_from_trusted_proxy(): bool
{
$list = (array) (config()['site']['trusted_proxies'] ?? []);
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;
$trusted = (array) (config()['site']['trusted_proxies'] ?? []);
$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]+)`/', '$1', $escaped);
// Fett **text**
$escaped = preg_replace('/\*\*([^\*\n]+)\*\*/', '$1', $escaped);
// Kursiv *text*
$escaped = preg_replace('/\*([^\*\n]+)\*/', '$1', $escaped);
// Links [text](https://...) - nur http(s) erlaubt
$escaped = preg_replace_callback('/\[([^\]\n]+)\]\((https?:\/\/[^\s\)]+)\)/', function ($m) {
return '' . $m[1] . '';
}, $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 .= '
' . $line . '
'; } } if ($inList) $html .= ''; 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; } $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 = ? 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 = ? 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']; } /** 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']; } function handle_ticket_upload(array $file): array { $cfg = config()['uploads']; $allowed = get_allowed_extensions(); $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) $allParts = explode('.', strtolower($file['name'])); // 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']; foreach ($allParts as $part) { if (in_array($part, $dangerousExts, true)) { return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.']; } } 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' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.']; } } // 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.']; } if (!move_uploaded_file($file['tmp_name'], $dest)) { return ['error' => 'Die Datei konnte nicht gespeichert werden.']; } return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']]; } /** 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. */ function sanitize_staff_html(string $html): string { 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('/(?{}]|/\*|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(['<', '>'], ['<', '>'], $part); continue; } if (!preg_match('/^<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^<>]*)?)(\/?)>$/s', $part, $m)) { $result .= str_replace(['<', '>'], ['<', '>'], $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 .= ''; 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 if (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);|cid:)#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 { $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, '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 '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; } /** * 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