diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php index 2d6e347f..32bbbfb3 100644 --- a/DeskHub-Webseite/includes/bootstrap.php +++ b/DeskHub-Webseite/includes/bootstrap.php @@ -23,6 +23,10 @@ require_once __DIR__ . '/fcm.php'; require_once __DIR__ . '/staff_activity.php'; require_once __DIR__ . '/notifications.php'; require_once __DIR__ . '/ticket_extras.php'; +require_once __DIR__ . '/i18n.php'; +require_once __DIR__ . '/csat.php'; +require_once __DIR__ . '/status_page.php'; +require_once __DIR__ . '/ticket_templates.php'; require_once __DIR__ . '/plugins.php'; require_once __DIR__ . '/app_downloads.php'; require_once __DIR__ . '/theme_catalog.php'; @@ -71,6 +75,9 @@ pseudo_cron_schedule_background(); // Aktive Plugins laden (Fehler in Plugins werden isoliert, siehe includes/plugins.php). plugins_boot(); +// Mehrsprachigkeit: bei einer anderen Sprache als Deutsch (oder im Sammelmodus) wird die fertige Seite übersetzt (includes/i18n.php). +i18n_start(); + // DB-kompatibler SQL-Ausdruck für Domain-Teil einer E-Mail function sql_email_domain(string $col): string { $driver = config()['db']['driver'] ?? 'sqlite'; diff --git a/DeskHub-Webseite/includes/crontab_installer.php b/DeskHub-Webseite/includes/crontab_installer.php index 6ea81627..2543ed65 100644 --- a/DeskHub-Webseite/includes/crontab_installer.php +++ b/DeskHub-Webseite/includes/crontab_installer.php @@ -64,7 +64,7 @@ function detect_php_cli_binary(): string return 'php'; // Fallback: hoffen, dass PATH im Cron-Kontext passt } -/** Baut den verwalteten DeskHub-Block mit den 6 Job-Zeilen (fest hinterlegte Kommandos, keine Nutzereingabe). */ +/** Baut den verwalteten DeskHub-Block mit den Job-Zeilen (fest hinterlegte Kommandos, keine Nutzereingabe). */ function crontab_marker_block(): string { $php = detect_php_cli_binary(); @@ -72,6 +72,7 @@ function crontab_marker_block(): string $lines = [ DESKHUB_CRON_MARKER_START, "*/5 * * * * $php $root/cron/fetch_emails.php >> $root/cron/fetch_emails.log 2>&1", + "* * * * * $php $root/cron/run_plugins.php >> $root/cron/run_plugins.log 2>&1", "*/15 * * * * $php $root/cron/check_sla.php >> $root/cron/check_sla.log 2>&1", "0 * * * * $php $root/cron/check_escalations.php >> $root/cron/check_escalations.log 2>&1", "0 6 * * * $php $root/cron/process_recurring.php >> $root/cron/process_recurring.log 2>&1", diff --git a/DeskHub-Webseite/includes/csat.php b/DeskHub-Webseite/includes/csat.php new file mode 100644 index 00000000..cb4e3e30 --- /dev/null +++ b/DeskHub-Webseite/includes/csat.php @@ -0,0 +1,166 @@ +prepare('SELECT * FROM ticket_csat WHERE ticket_id = ?'); + $s->execute([$ticketId]); + return $s->fetch() ?: null; +} + +function csat_find_by_token(PDO $pdo, string $token): ?array +{ + if (!preg_match('/^[a-f0-9]{40}$/', $token)) return null; + $s = $pdo->prepare('SELECT c.*, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE c.token = ?'); + $s->execute([$token]); + return $s->fetch() ?: null; +} + +/** + * Speichert die Bewertung. Jede Abfrage lässt sich nur einmal beantworten. + * @return array{ok:bool,error?:string} + */ +function csat_record(PDO $pdo, string $token, int $rating, string $comment): array +{ + $row = csat_find_by_token($pdo, $token); + if (!$row) return ['ok' => false, 'error' => 'Dieser Link ist ungültig oder abgelaufen.']; + if ($row['answered_at'] !== null) return ['ok' => false, 'error' => 'Vielen Dank, Sie haben dieses Ticket bereits bewertet.']; + if ($rating < 1 || $rating > 5) return ['ok' => false, 'error' => 'Bitte wählen Sie 1 bis 5 Sterne.']; + $comment = trim(mb_substr($comment, 0, 2000)); + $pdo->prepare('UPDATE ticket_csat SET rating = ?, comment = ?, answered_at = ? WHERE id = ? AND answered_at IS NULL') + ->execute([$rating, $comment !== '' ? $comment : null, db_now(), $row['id']]); + try { + log_activity($pdo, (int) $row['ticket_id'], null, 'Zufriedenheit bewertet: ' . $rating . ' von 5 Sternen'); + } catch (Throwable $e) { + } + return ['ok' => true]; +} + +/** Text der Mail an den Kunden. */ +function csat_mail_html(array $ticket, string $token): string +{ + $name = trim((string) ($ticket['requester_name'] ?? '')); + $stars = ''; + for ($i = 1; $i <= 5; $i++) { + $stars .= '★'; + } + return '

Wie zufrieden waren Sie?

' + . '

' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '') + . 'Ihr Ticket [#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . ' ist abgeschlossen. ' + . 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.

' + . '

' . $stars . '

' + . '

1 Stern = unzufrieden · 5 Sterne = sehr zufrieden

'; +} + +/** + * Verschickt die fälligen Abfragen. Fällig: Ticket geschlossen oder gelöst (seit mindestens der eingestellten Wartezeit, höchstens 14 Tage), + * kein Spam, kein Live-Chat, gültige Kundenadresse, noch keine Abfrage, und geschlossen nach dem Einschalten der Funktion. + */ +function csat_send_due(PDO $pdo, int $limit = 50): int +{ + if (!csat_enabled()) return 0; + $now = time(); + $until = date('Y-m-d H:i:s', $now - csat_delay_minutes() * 60); + $oldest = date('Y-m-d H:i:s', $now - 14 * 86400); + $since = (string) get_setting('csat_since', ''); + if ($since !== '' && $since > $oldest) $oldest = $since; + $stmt = $pdo->prepare( + "SELECT t.* FROM tickets t + WHERE t.status IN ('closed','resolved') AND t.is_spam = 0 AND t.source <> 'chat' + AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) <= ? AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) >= ? + AND t.requester_email IS NOT NULL AND t.requester_email <> '' + AND NOT EXISTS (SELECT 1 FROM ticket_csat c WHERE c.ticket_id = t.id) + ORDER BY t.id LIMIT " . max(1, $limit) + ); + $stmt->execute([$until, $oldest]); + $sent = 0; + foreach ($stmt->fetchAll() as $t) { + if (!filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) continue; + $token = bin2hex(random_bytes(20)); + try { + $pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, department_id, sent_at) VALUES (?,?,?,?,?)') + ->execute([$t['id'], $token, $t['assigned_staff_id'] ?: null, $t['department_id'] ?: null, db_now()]); + } catch (Throwable $e) { + continue; // parallel schon angelegt + } + $from = resolve_from_address($t['department_id'] ? (int) $t['department_id'] : null, $t['priority_id'] ? (int) $t['priority_id'] : null); + email_send_and_log((string) $t['requester_email'], 'Wie zufrieden waren Sie? [#' . $t['ticket_number'] . ']', email_wrap(csat_mail_html($t, $token)), $from, 'Zufriedenheitsabfrage', (string) $t['ticket_number']); + $sent++; + } + return $sent; +} + +/** + * Auswertung für einen Zeitraum. + * @param array|null $deptIds null = alle Abteilungen + * @return array{sent:int,answered:int,avg:?float,rate:?float,dist:array,byStaff:list,recent:list} + */ +function csat_stats(PDO $pdo, string $from, string $to, ?array $deptIds = null): array +{ + $where = 'c.sent_at >= ? AND c.sent_at <= ?'; + $args = [$from, $to]; + if ($deptIds !== null) { + if (!$deptIds) return ['sent' => 0, 'answered' => 0, 'avg' => null, 'rate' => null, 'dist' => [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0], 'byStaff' => [], 'recent' => []]; + $where .= ' AND c.department_id IN (' . implode(',', array_map('intval', $deptIds)) . ')'; + } + $q = $pdo->prepare("SELECT COUNT(*) AS sent, SUM(CASE WHEN c.rating IS NOT NULL THEN 1 ELSE 0 END) AS answered, AVG(c.rating) AS avg FROM ticket_csat c WHERE $where"); + $q->execute($args); + $r = $q->fetch() ?: []; + $sent = (int) ($r['sent'] ?? 0); + $answered = (int) ($r['answered'] ?? 0); + $dist = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0]; + $d = $pdo->prepare("SELECT c.rating AS r, COUNT(*) AS n FROM ticket_csat c WHERE $where AND c.rating IS NOT NULL GROUP BY c.rating"); + $d->execute($args); + foreach ($d->fetchAll() as $row) $dist[(int) $row['r']] = (int) $row['n']; + $b = $pdo->prepare("SELECT s.id, s.name, COUNT(*) AS n, AVG(c.rating) AS avg FROM ticket_csat c JOIN staff s ON s.id = c.staff_id WHERE $where AND c.rating IS NOT NULL GROUP BY s.id, s.name ORDER BY AVG(c.rating) DESC, COUNT(*) DESC"); + $b->execute($args); + $byStaff = array_map(fn($x) => ['id' => (int) $x['id'], 'name' => (string) $x['name'], 'count' => (int) $x['n'], 'avg' => round((float) $x['avg'], 2)], $b->fetchAll()); + $rc = $pdo->prepare("SELECT c.rating, c.comment, c.answered_at, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE $where AND c.rating IS NOT NULL ORDER BY c.answered_at DESC LIMIT 10"); + $rc->execute($args); + return [ + 'sent' => $sent, 'answered' => $answered, + 'avg' => $answered ? round((float) $r['avg'], 2) : null, + 'rate' => $sent ? round($answered * 100 / $sent, 1) : null, + 'dist' => $dist, 'byStaff' => $byStaff, + 'recent' => array_map(fn($x) => ['rating' => (int) $x['rating'], 'comment' => (string) ($x['comment'] ?? ''), 'answeredAt' => (string) $x['answered_at'], 'number' => (string) $x['ticket_number'], 'subject' => (string) $x['subject']], $rc->fetchAll()), + ]; +} + +/** „★★★★☆“ für Anzeigen. */ +function csat_stars(?int $rating): string +{ + $rating = max(0, min(5, (int) $rating)); + return str_repeat('★', $rating) . str_repeat('☆', 5 - $rating); +} diff --git a/DeskHub-Webseite/includes/footer_customer.php b/DeskHub-Webseite/includes/footer_customer.php index 446ba202..a4059388 100644 --- a/DeskHub-Webseite/includes/footer_customer.php +++ b/DeskHub-Webseite/includes/footer_customer.php @@ -550,3 +550,4 @@ if ($__assistantOn) require __DIR__ . '/assistant_widget.php'; })(); + diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php index 72c7ebba..14775012 100644 --- a/DeskHub-Webseite/includes/functions.php +++ b/DeskHub-Webseite/includes/functions.php @@ -47,6 +47,13 @@ function check_honeypot(): void } } +/** 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'] ?? ''; @@ -1070,15 +1077,14 @@ function handle_ticket_upload(array $file): array // 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.']; - } + // 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)]; @@ -1094,7 +1100,7 @@ function handle_ticket_upload(array $file): array $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.']; + return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.']; } } // Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz diff --git a/DeskHub-Webseite/includes/header_customer.php b/DeskHub-Webseite/includes/header_customer.php index ab63b05e..24a1448a 100644 --- a/DeskHub-Webseite/includes/header_customer.php +++ b/DeskHub-Webseite/includes/header_customer.php @@ -95,11 +95,15 @@ if ('serviceWorker' in navigator) { Wissensdatenbank + + Status + Abmelden Anmelden + diff --git a/DeskHub-Webseite/includes/health.php b/DeskHub-Webseite/includes/health.php index 7de19eec..41ee42eb 100644 --- a/DeskHub-Webseite/includes/health.php +++ b/DeskHub-Webseite/includes/health.php @@ -1,113 +1,204 @@ -query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN) ?: []; -} - -/** - * Hält das Ergebnis eines Abrufs für ein Postfach fest. $error = null bedeutet Erfolg. - * Verschickt eine Alarm-Mail an die Administratoren, wenn der Fehler anhält, und eine - * Entwarnung, wenn das Postfach wieder erreichbar ist. - */ -function health_record_mail_result(PDO $pdo, array $account, ?string $error): void -{ - $now = db_now(); - $label = $account['label'] ?: $account['username']; - if ($error === null) { - $wasAlerted = !empty($account['alert_sent_at']); - $pdo->prepare('UPDATE imap_accounts SET last_success_at = ?, last_error = NULL, fail_count = 0, alert_sent_at = NULL WHERE id = ?')->execute([$now, $account['id']]); - if ($wasAlerted) { - foreach (health_admin_emails($pdo) as $to) { - send_mail($to, "DeskHub: Postfach „{$label}“ ist wieder erreichbar", - '

Der Mail-Abruf für das Postfach ' . e($label) . ' funktioniert wieder.

'); - } - } - return; - } - - $fails = (int) ($account['fail_count'] ?? 0) + 1; - $pdo->prepare('UPDATE imap_accounts SET last_error = ?, fail_count = ? WHERE id = ?')->execute([mb_substr($error, 0, 500), $fails, $account['id']]); - $lastAlert = !empty($account['alert_sent_at']) ? strtotime($account['alert_sent_at']) : 0; - if ($fails >= HEALTH_FAIL_THRESHOLD && (time() - $lastAlert) >= HEALTH_ALERT_REPEAT) { - $since = $account['last_success_at'] ? date('d.m.Y H:i', strtotime($account['last_success_at'])) : 'noch nie'; - $sent = false; - foreach (health_admin_emails($pdo) as $to) { - $sent = send_mail($to, "DeskHub: Mail-Abruf für „{$label}“ fehlgeschlagen", - '

Der Mail-Abruf für das Postfach ' . e($label) . ' (' . e($account['host']) . ') schlägt seit ' - . $fails . ' Versuchen fehl. Eingehende Mails werden in dieser Zeit nicht als Tickets angelegt.

' - . '

Letzter Fehler: ' . e($error) . '
Letzter erfolgreicher Abruf: ' . e($since) . '

' - . '

Bitte Zugangsdaten und Server unter E-Mail-Adressen → Entferntes Postfach prüfen.

') || $sent; - } - // Auch ohne versendete Mail als „gemeldet“ markieren, damit nicht bei jedem Lauf neu versucht wird - $pdo->prepare('UPDATE imap_accounts SET alert_sent_at = ? WHERE id = ?')->execute([$now, $account['id']]); - } -} - -/** Merkt sich den Erfolg bzw. Misserfolg des letzten Mailversands (für die Statusanzeige). */ -function health_record_send_result(bool $ok, string $error = ''): void -{ - try { - if ($ok) { set_setting('mail_send_last_ok', db_now()); return; } - set_setting('mail_send_last_error', db_now() . '|' . mb_substr($error, 0, 300)); - } catch (Throwable $e) { - // Überwachung darf den Versand nie beeinträchtigen - } -} - -/** - * Liefert die aktuellen Probleme als Liste von ['level' => 'error'|'warning', 'code' => ..., 'message' => ...]. - * Leer = alles in Ordnung. Rein lesend, daher auch bei jedem Seitenaufruf aufrufbar. - */ -function system_health(PDO $pdo): array -{ - $issues = []; - // Das zuletzt geprüfte Backup lässt sich nicht wiederherstellen - $bk = json_decode((string) get_setting('backup_last_check', ''), true); - if (is_array($bk) && array_key_exists('ok', $bk) && !$bk['ok']) { - $issues[] = ['level' => 'error', 'code' => 'backup_broken', 'message' => 'Das Backup ' . ($bk['file'] ?? '') . ' ist nicht wiederherstellbar: ' . implode(' ', (array) ($bk['errors'] ?? [])) . ' Bitte unter Backup ein neues erstellen.']; - } - $accounts = $pdo->query('SELECT * FROM imap_accounts WHERE is_active = 1')->fetchAll(); - if ($accounts) { - if (!function_exists('imap_open')) { - $issues[] = ['level' => 'error', 'code' => 'imap_missing', 'message' => 'Die PHP-IMAP-Erweiterung fehlt – eingehende Mails können nicht abgerufen werden.']; - } - foreach ($accounts as $a) { - $label = $a['label'] ?: $a['username']; - $fails = (int) ($a['fail_count'] ?? 0); - if ($fails >= HEALTH_FAIL_THRESHOLD) { - $since = !empty($a['last_success_at']) ? ' Letzter Erfolg: ' . date('d.m.Y H:i', strtotime($a['last_success_at'])) . '.' : ''; - $issues[] = ['level' => 'error', 'code' => 'mailbox_down', 'message' => "Postfach „{$label}“ ist nicht erreichbar ({$fails} Fehlversuche in Folge).{$since} " . ($a['last_error'] ?? '')]; - } elseif ($fails > 0) { - $issues[] = ['level' => 'warning', 'code' => 'mailbox_flaky', 'message' => "Postfach „{$label}“: letzter Abruf fehlgeschlagen. " . ($a['last_error'] ?? '')]; - } - } - // Der Abruf selbst läuft nicht mehr (Cronjob/Seitenaufrufe bleiben aus) - $last = get_setting('cron_fetch_emails_last_run', ''); - if (get_setting('pseudo_cron_enabled', '1') === '1' || $last !== '') { - if ($last !== '' && (time() - strtotime($last)) > 1200) { - $issues[] = ['level' => 'warning', 'code' => 'fetch_stale', 'message' => 'Der Mail-Abruf wurde seit ' . cron_last_run_label($last) . ' nicht ausgeführt. Läuft der Cronjob (cron/fetch_emails.php)?']; - } - } - } - $lastErr = (string) get_setting('mail_send_last_error', ''); - if ($lastErr !== '') { - [$at, $msg] = array_pad(explode('|', $lastErr, 2), 2, ''); - $ok = (string) get_setting('mail_send_last_ok', ''); - if ($at !== '' && strtotime($at) > time() - 86400 && ($ok === '' || strtotime($ok) < strtotime($at))) { - $issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg]; - } - } - return $issues; -} +query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN) ?: []; +} + +/** + * Hält das Ergebnis eines Abrufs für ein Postfach fest. $error = null bedeutet Erfolg. + * Verschickt eine Alarm-Mail an die Administratoren, wenn der Fehler anhält, und eine + * Entwarnung, wenn das Postfach wieder erreichbar ist. + */ +function health_record_mail_result(PDO $pdo, array $account, ?string $error): void +{ + $now = db_now(); + $label = $account['label'] ?: $account['username']; + if ($error === null) { + $wasAlerted = !empty($account['alert_sent_at']); + $pdo->prepare('UPDATE imap_accounts SET last_success_at = ?, last_error = NULL, fail_count = 0, alert_sent_at = NULL WHERE id = ?')->execute([$now, $account['id']]); + if ($wasAlerted) { + foreach (health_admin_emails($pdo) as $to) { + send_mail($to, "DeskHub: Postfach „{$label}“ ist wieder erreichbar", + '

Der Mail-Abruf für das Postfach ' . e($label) . ' funktioniert wieder.

'); + } + } + return; + } + + $fails = (int) ($account['fail_count'] ?? 0) + 1; + $pdo->prepare('UPDATE imap_accounts SET last_error = ?, fail_count = ? WHERE id = ?')->execute([mb_substr($error, 0, 500), $fails, $account['id']]); + $lastAlert = !empty($account['alert_sent_at']) ? strtotime($account['alert_sent_at']) : 0; + if ($fails >= HEALTH_FAIL_THRESHOLD && (time() - $lastAlert) >= HEALTH_ALERT_REPEAT) { + $since = $account['last_success_at'] ? date('d.m.Y H:i', strtotime($account['last_success_at'])) : 'noch nie'; + $sent = false; + foreach (health_admin_emails($pdo) as $to) { + $sent = send_mail($to, "DeskHub: Mail-Abruf für „{$label}“ fehlgeschlagen", + '

Der Mail-Abruf für das Postfach ' . e($label) . ' (' . e($account['host']) . ') schlägt seit ' + . $fails . ' Versuchen fehl. Eingehende Mails werden in dieser Zeit nicht als Tickets angelegt.

' + . '

Letzter Fehler: ' . e($error) . '
Letzter erfolgreicher Abruf: ' . e($since) . '

' + . '

Bitte Zugangsdaten und Server unter E-Mail-Adressen → Entferntes Postfach prüfen.

') || $sent; + } + // Auch ohne versendete Mail als „gemeldet“ markieren, damit nicht bei jedem Lauf neu versucht wird + $pdo->prepare('UPDATE imap_accounts SET alert_sent_at = ? WHERE id = ?')->execute([$now, $account['id']]); + } +} + +/** Merkt sich den Erfolg bzw. Misserfolg des letzten Mailversands (für die Statusanzeige). */ +function health_record_send_result(bool $ok, string $error = ''): void +{ + try { + if ($ok) { set_setting('mail_send_last_ok', db_now()); return; } + set_setting('mail_send_last_error', db_now() . '|' . mb_substr($error, 0, 300)); + } catch (Throwable $e) { + // Überwachung darf den Versand nie beeinträchtigen + } +} + +/** + * Liefert die aktuellen Probleme als Liste von ['level' => 'error'|'warning', 'code' => ..., 'message' => ...]. + * Leer = alles in Ordnung. Rein lesend, daher auch bei jedem Seitenaufruf aufrufbar. + */ +function system_health(PDO $pdo): array +{ + $issues = []; + // Das zuletzt geprüfte Backup lässt sich nicht wiederherstellen + $bk = json_decode((string) get_setting('backup_last_check', ''), true); + if (is_array($bk) && array_key_exists('ok', $bk) && !$bk['ok']) { + $issues[] = ['level' => 'error', 'code' => 'backup_broken', 'message' => 'Das Backup ' . ($bk['file'] ?? '') . ' ist nicht wiederherstellbar: ' . implode(' ', (array) ($bk['errors'] ?? [])) . ' Bitte unter Backup ein neues erstellen.']; + } + $accounts = $pdo->query('SELECT * FROM imap_accounts WHERE is_active = 1')->fetchAll(); + if ($accounts) { + if (!function_exists('imap_open')) { + $issues[] = ['level' => 'error', 'code' => 'imap_missing', 'message' => 'Die PHP-IMAP-Erweiterung fehlt – eingehende Mails können nicht abgerufen werden.']; + } + foreach ($accounts as $a) { + $label = $a['label'] ?: $a['username']; + $fails = (int) ($a['fail_count'] ?? 0); + if ($fails >= HEALTH_FAIL_THRESHOLD) { + $since = !empty($a['last_success_at']) ? ' Letzter Erfolg: ' . date('d.m.Y H:i', strtotime($a['last_success_at'])) . '.' : ''; + $issues[] = ['level' => 'error', 'code' => 'mailbox_down', 'message' => "Postfach „{$label}“ ist nicht erreichbar ({$fails} Fehlversuche in Folge).{$since} " . ($a['last_error'] ?? '')]; + } elseif ($fails > 0) { + $issues[] = ['level' => 'warning', 'code' => 'mailbox_flaky', 'message' => "Postfach „{$label}“: letzter Abruf fehlgeschlagen. " . ($a['last_error'] ?? '')]; + } + } + // Der Abruf selbst läuft nicht mehr (Cronjob/Seitenaufrufe bleiben aus) + $last = get_setting('cron_fetch_emails_last_run', ''); + if (get_setting('pseudo_cron_enabled', '1') === '1' || $last !== '') { + if ($last !== '' && (time() - strtotime($last)) > 1200) { + $issues[] = ['level' => 'warning', 'code' => 'fetch_stale', 'message' => 'Der Mail-Abruf wurde seit ' . cron_last_run_label($last) . ' nicht ausgeführt. Läuft der Cronjob (cron/fetch_emails.php)?']; + } + } + } + $lastErr = (string) get_setting('mail_send_last_error', ''); + if ($lastErr !== '') { + [$at, $msg] = array_pad(explode('|', $lastErr, 2), 2, ''); + $ok = (string) get_setting('mail_send_last_ok', ''); + if ($at !== '' && strtotime($at) > time() - 86400 && ($ok === '' || strtotime($ok) < strtotime($at))) { + $issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg]; + } + } + return $issues; +} + +// ── Fehler-Mail bei schweren PHP-Fehlern (wie bei WordPress) ─────────────────── + +/** Fehlerarten, die eine Seite abbrechen. */ +const ERROR_ALERT_FATAL = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; +/** Dieselbe Meldung frühestens nach so vielen Sekunden erneut mailen. */ +const ERROR_ALERT_REPEAT = 3600; +/** Höchstens so viele Fehler-Mails pro Stunde insgesamt (schützt das Postfach bei einem Fehler auf jeder Seite). */ +const ERROR_ALERT_MAX_PER_HOUR = 10; + +/** Empfänger: eigene Adresse aus den Einstellungen, sonst Admin-Adresse, sonst alle aktiven Administratoren. */ +function error_alert_recipients(PDO $pdo): array +{ + $own = trim((string) get_setting('error_alert_email', '')); + if ($own !== '') return array_values(array_filter(array_map('trim', preg_split('/[,;\s]+/', $own) ?: []), fn($m) => filter_var($m, FILTER_VALIDATE_EMAIL))); + $admin = trim((string) get_setting('admin_email', '') ?: (string) (config()['site']['admin_email'] ?? '')); + if (filter_var($admin, FILTER_VALIDATE_EMAIL) && $admin !== 'support@example.com') return [$admin]; + return health_admin_emails($pdo); +} + +/** Betreff und Text der Mail. Nur Pfad der Adresse (keine Parameter, dort können Zugangscodes stehen), keine Formular- oder Cookie-Daten. */ +function error_alert_compose(array $err, array $context = []): array +{ + $root = str_replace('\\', '/', dirname(__DIR__)) . '/'; + $file = str_replace($root, '', str_replace('\\', '/', (string) ($err['file'] ?? ''))); + $msg = mb_substr(str_replace($root, '', str_replace('\\', '/', (string) ($err['message'] ?? ''))), 0, 1500); + $site = (string) get_setting('display_name', 'DeskHub'); + $path = (string) ($context['path'] ?? (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '(Hintergrundaufruf)')); + $headline = !empty($err['headline']) ? e((string) $err['headline']) : 'Auf ' . e($site) . ' ist ein schwerer Fehler aufgetreten. Die aufgerufene Seite konnte nicht angezeigt werden.'; + $html = '

' . $headline . '

' + . '

Fehler:
' . nl2br(e($msg)) . '

' + . '

Datei: ' . e($file) . ((int) ($err['line'] ?? 0) > 0 ? ', Zeile ' . (int) $err['line'] : '') . '
' + . 'Seite: ' . e($path) . '
' + . 'Zeit: ' . e(date('d.m.Y H:i:s')) . '
' + . 'Version: DeskHub ' . e((string) (defined('DESKHUB_VERSION') ? DESKHUB_VERSION : '?')) . ', PHP ' . e(PHP_VERSION) . '

' + . '

Dieselbe Meldung wird höchstens einmal pro Stunde gesendet. Ausschalten oder Empfänger ändern: Einstellungen → Allgemein.

'; + return [!empty($err['subject']) ? "{$site}: " . (string) $err['subject'] : "{$site}: Schwerer Fehler auf der Webseite", $html]; +} + +/** + * Meldet einen Fehler, der die Seite nicht abbricht, aber Aufmerksamkeit braucht (zum Beispiel ein Plugin, das Fehler wirft, oder ein + * fehlgeschlagener Hintergrundjob). Gleiche Ein-/Ausschalter, Empfänger und Drosselung wie bei schweren Fehlern. + */ +function error_alert_notice(string $subject, string $headline, string $where, string $message): void +{ + try { + error_alert_dispatch(get_db(), ['type' => E_USER_WARNING, 'message' => $message, 'file' => $where, 'line' => 0, 'subject' => $subject, 'headline' => $headline]); + } catch (Throwable $e) { + error_log('error_alert_notice: ' . $e->getMessage()); + } +} + +/** + * Prüft Ein/Aus und Drosselung und verschickt die Mail. $send ist nur für Tests austauschbar (Standard: send_mail). + * Liefert die Zahl der Empfänger, an die gesendet wurde. + */ +function error_alert_dispatch(PDO $pdo, array $err, ?callable $send = null): int +{ + if (get_setting('error_alert', '1') !== '1') return 0; + $fp = sha1(preg_replace('/\d+/', '#', (string) ($err['file'] ?? '') . ':' . substr((string) ($err['message'] ?? ''), 0, 200))); + $now = time(); + $log = json_decode((string) get_setting('error_alert_log', '[]'), true); + $log = array_values(array_filter(is_array($log) ? $log : [], fn($r) => is_array($r) && $now - (int) ($r[0] ?? 0) < 86400)); + $lastHour = array_filter($log, fn($r) => $now - (int) $r[0] < 3600); + if (count($lastHour) >= ERROR_ALERT_MAX_PER_HOUR) return 0; + foreach ($lastHour as $r) if (($r[1] ?? '') === $fp && $now - (int) $r[0] < ERROR_ALERT_REPEAT) return 0; + // erst merken, dann senden: Schlägt das Senden selbst fehl, folgt keine Endlosschleife + $log[] = [$now, $fp]; + set_setting('error_alert_log', json_encode($log)); + [$subject, $html] = error_alert_compose($err); + $sent = 0; + foreach (error_alert_recipients($pdo) as $to) { + if (($send ?? $GLOBALS['error_alert_send'] ?? 'send_mail')($to, $subject, $html)) $sent++; + } + return $sent; +} + +/** Wird am Ende jeder Anfrage aufgerufen: war es ein schwerer Fehler, geht eine Mail an den Administrator. */ +function error_alert_shutdown(): void +{ + static $running = false; + $err = error_get_last(); + if ($running || !$err || !in_array($err['type'], ERROR_ALERT_FATAL, true)) return; + $running = true; + try { + error_alert_dispatch(get_db(), $err); + } catch (Throwable $e) { + error_log('error_alert: ' . $e->getMessage()); + } +} +register_shutdown_function('error_alert_shutdown'); diff --git a/DeskHub-Webseite/includes/i18n.php b/DeskHub-Webseite/includes/i18n.php new file mode 100644 index 00000000..e7875593 --- /dev/null +++ b/DeskHub-Webseite/includes/i18n.php @@ -0,0 +1,334 @@ +'); +} + +/** + * Verfügbare Sprachen: Deutsch (eingebaut) und alle aktiven Sprachpakete. + * @return array Schlüssel = Sprachcode + */ +function i18n_languages(bool $refresh = false): array +{ + static $cache = null; + if ($cache !== null && !$refresh) return $cache; + $langs = ['de' => ['code' => 'de', 'name' => 'Deutsch', 'flag' => i18n_builtin_flag_de(), 'htmlLang' => 'de', 'slug' => '', 'strings' => '']]; + try { + if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return $cache = $langs; + $rows = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN); + } catch (Throwable $e) { + return $cache = $langs; + } + foreach ($rows as $slug) { + $slug = (string) $slug; + if (!plugin_valid_slug($slug)) continue; + $dir = plugins_dir() . '/' . $slug; + if (!is_file($dir . '/plugin.json')) continue; + $raw = json_decode((string) file_get_contents($dir . '/plugin.json'), true); + $l = is_array($raw) && is_array($raw['language'] ?? null) ? $raw['language'] : null; + if (!$l) continue; + [$manifest] = plugin_read_manifest($dir); + if (!$manifest) continue; + $code = strtolower((string) ($l['code'] ?? '')); + if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code)) continue; + $flag = ''; + $flagFile = (string) ($l['flag'] ?? ''); + if ($flagFile !== '' && plugin_package_path_ok($flagFile) && is_file($dir . '/' . $flagFile) && filesize($dir . '/' . $flagFile) <= I18N_FLAG_MAX_BYTES) { + $ext = strtolower(pathinfo($flagFile, PATHINFO_EXTENSION)); + $mime = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'][$ext] ?? ''; + if ($mime !== '') $flag = 'data:' . $mime . ';base64,' . base64_encode((string) file_get_contents($dir . '/' . $flagFile)); + } + $stringsFile = (string) ($l['strings'] ?? 'strings.json'); + if (!plugin_package_path_ok($stringsFile) || strtolower(pathinfo($stringsFile, PATHINFO_EXTENSION)) !== 'json') $stringsFile = ''; + $langs[$code] = [ + 'code' => $code, + 'name' => mb_substr(trim((string) ($l['name'] ?? $code)), 0, 40) ?: $code, + 'flag' => $flag !== '' ? $flag : ($code === 'de' ? i18n_builtin_flag_de() : ''), + 'htmlLang' => preg_match('/^[A-Za-z\-]{2,12}$/', (string) ($l['htmlLang'] ?? '')) ? (string) $l['htmlLang'] : $code, + 'slug' => $slug, + 'strings' => $stringsFile !== '' ? $dir . '/' . $stringsFile : '', + ]; + } + return $cache = $langs; +} + +/** Standardsprache der Seite (Einstellung), sonst Deutsch. */ +function i18n_default(): string +{ + $d = strtolower((string) get_setting('default_language', 'de')); + return isset(i18n_languages()[$d]) ? $d : 'de'; +} + +/** Sprache dieser Anfrage: Cookie, sonst Standardsprache. */ +function i18n_current(): string +{ + $c = strtolower((string) ($_COOKIE[I18N_COOKIE] ?? '')); + return $c !== '' && isset(i18n_languages()[$c]) ? $c : i18n_default(); +} + +/** + * Wörterbuch einer Sprache: exakte Texte und Muster mit "{}". + * @return array{exact:array,patterns:list,count:int} + */ +function i18n_dictionary(string $code): array +{ + static $cache = []; + if (isset($cache[$code])) return $cache[$code]; + $dict = ['exact' => [], 'patterns' => [], 'count' => 0]; + $file = i18n_languages()[$code]['strings'] ?? ''; + if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return $cache[$code] = $dict; + $data = json_decode((string) file_get_contents($file), true); + if (!is_array($data)) return $cache[$code] = $dict; + foreach ($data as $key => $value) { + $key = (string) $key; + if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue; + $norm = i18n_normalize($key); + if (str_contains($norm, '{}') || str_contains($norm, '{#}')) { + // {} = beliebiger Text, {#} = nur Ziffern + $pieces = preg_split('/(\{#\}|\{\})/', $norm, -1, PREG_SPLIT_DELIM_CAPTURE); + $regex = '/^'; + foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)'); + $regex .= '$/su'; + $dict['patterns'][] = [$regex, $value]; + } else { + $dict['exact'][$norm] = $value; + } + $dict['count']++; + } + return $cache[$code] = $dict; +} + +/** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */ +function i18n_normalize(string $text): string +{ + return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'))); +} + +/** Übersetzt einen (normalisierten) Text; null = keine Übersetzung bekannt. */ +function i18n_lookup(string $norm, array $dict): ?string +{ + if ($norm === '') return null; + if (isset($dict['exact'][$norm])) return $dict['exact'][$norm]; + foreach ($dict['patterns'] as [$regex, $value]) { + if (preg_match($regex, $norm, $m)) { + $value = preg_replace_callback('/\{(\d+)\}/', fn($x) => $m[(int) $x[1]] ?? '', $value); + $i = 0; + return preg_replace_callback('/\{\}/', function () use ($m, &$i) { $i++; return $m[$i] ?? ''; }, (string) $value); + } + } + return null; +} + +/** Texte, die nicht übersetzt werden müssen: Zahlen, Adressen, Zeiten, reine Zeichen. */ +function i18n_translatable(string $norm): bool +{ + if (mb_strlen($norm) < 2 || mb_strlen($norm) > 400) return false; + if (!preg_match('/\p{L}{2}/u', $norm)) return false; + if (preg_match('#^(https?://|/|[\w.+-]+@[\w-]+\.)#i', $norm)) return false; + return true; +} + +/** + * Übersetzt eine HTML-Seite. $seen sammelt (wenn übergeben) die deutschen Texte, für die es keine Übersetzung gab. + * @param array|null $seen + */ +function i18n_translate_html(string $html, string $code, ?array &$seen = null): string +{ + $dict = $code === 'de' ? ['exact' => [], 'patterns' => [], 'count' => 0] : i18n_dictionary($code); + $collect = $seen !== null; + if (!$collect && !$dict['count']) return $html; + + $note = function (string $norm) use (&$seen, $collect): void { + if ($collect && i18n_translatable($norm)) $seen[$norm] = true; + }; + $tr = function (string $norm) use ($dict, $note): ?string { + $t = i18n_lookup($norm, $dict); + if ($t === null) $note($norm); + return $t; + }; + + // JavaScript: Textliterale, die genau einem Schlüssel entsprechen + $jsLiterals = function (string $js, bool $collectCalls) use ($dict, $note): string { + return (string) preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($m) use ($dict, $note) { + $raw = str_replace(["\\'", '\\"', '\\\\', '\\n'], ["'", '"', '\\', ' '], $m[2]); + $norm = trim((string) preg_replace('/\s+/u', ' ', $raw)); + if ($norm === '') return $m[0]; + $t = i18n_lookup($norm, $dict); + return $t === null ? $m[0] : json_encode($t, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + }, $js); + }; + $jsCollect = function (string $js) use ($note, $dict): void { + if (preg_match_all('/(?:confirm|alert|prompt|toast)\(\s*(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', $js, $mm)) { + foreach ($mm[2] as $lit) { + $norm = trim((string) preg_replace('/\s+/u', ' ', str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $lit))); + if (i18n_lookup($norm, $dict) === null) $note($norm); // schon Übersetztes nicht noch einmal sammeln + } + } + }; + + // Attribute eines Tags + $tag = function (string $t) use ($tr, $dict, $note, $jsCollect): string { + $isButton = (bool) preg_match('/^]*\btype\s*=\s*["\']?(submit|button|reset)\b/i', $t); + return (string) preg_replace_callback('/\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(")([^"]*)"|\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(\')([^\']*)\'/i', function ($m) use ($tr, $dict, $isButton, $jsCollect) { + $name = strtolower($m[1] !== '' ? $m[1] : $m[4]); + $quote = $m[1] !== '' ? $m[2] : $m[5]; + $val = $m[1] !== '' ? $m[3] : $m[6]; + if ($name === 'value' && !$isButton) return $m[0]; + if (str_starts_with($name, 'on')) { + $js = html_entity_decode($val, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $jsCollect($js); + $new = preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($x) use ($dict) { + $raw = str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $x[2]); + $t = i18n_lookup(trim((string) preg_replace('/\s+/u', ' ', $raw)), $dict); + return $t === null ? $x[0] : "'" . addcslashes($t, "'\\") . "'"; + }, $js); + return $new === $js ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars((string) $new, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote; + } + $norm = i18n_normalize($val); + $t = $tr($norm); + return $t === null ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars($t, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote; + }, $t); + }; + + $parts = preg_split('/(|||<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE); + if ($parts === false) return $html; + $out = ''; + $skip = false; // innerhalb