diff --git a/Plugins/plugins/spamfilter-1.0.0.zip b/Plugins/plugins/spamfilter-1.0.0.zip
new file mode 100644
index 00000000..57b68334
Binary files /dev/null and b/Plugins/plugins/spamfilter-1.0.0.zip differ
diff --git a/Plugins/plugins/spamfilter/plugin.php b/Plugins/plugins/spamfilter/plugin.php
new file mode 100644
index 00000000..e370b6fd
--- /dev/null
+++ b/Plugins/plugins/spamfilter/plugin.php
@@ -0,0 +1,15 @@
+query('SELECT first_fail_at, verify_tls, last_status, last_title FROM plugin_ueberwachung_monitors LIMIT 1');
+ } catch (Throwable $e) {
+ try {
+ plugin_run_migrations(get_db(), UEBERWACHUNG);
+ } catch (Throwable $e2) {
+ error_log('ueberwachung schema: ' . $e2->getMessage());
+ }
+ }
+}
+
+function ueberwachung_config(): array
+{
+ $mail = trim((string) plugin_setting(UEBERWACHUNG, 'alert_email', ''));
+ return [
+ 'threshold' => max(1, min(10, (int) plugin_setting(UEBERWACHUNG, 'threshold', '2'))),
+ 'department_id' => (int) plugin_setting(UEBERWACHUNG, 'department_id', '0'),
+ 'priority_id' => (int) plugin_setting(UEBERWACHUNG, 'priority_id', '0'),
+ 'create_tickets' => plugin_setting(UEBERWACHUNG, 'create_tickets', '1') === '1',
+ 'auto_resolve' => plugin_setting(UEBERWACHUNG, 'auto_resolve', '1') === '1',
+ 'mail' => plugin_setting(UEBERWACHUNG, 'mail', '1') === '1',
+ 'alert_email' => $mail !== '' ? $mail : (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? '')),
+ ];
+}
+
+function ueberwachung_types(): array
+{
+ return ['http' => 'Webseite (HTTP/HTTPS)', 'port' => 'Port (TCP)', 'tls' => 'Zertifikat eines Dienstes (TLS-Port)'];
+}
+
+/** Prüft die Eingaben eines Überwachungsziels. @return array{ok:bool,error?:string,data?:array} */
+function ueberwachung_validate(array $in): array
+{
+ $name = trim((string) ($in['name'] ?? ''));
+ $type = (string) ($in['type'] ?? 'http');
+ $target = trim((string) ($in['target'] ?? ''));
+ if ($name === '' || mb_strlen($name) > 120) return ['ok' => false, 'error' => 'Bitte einen Namen angeben (höchstens 120 Zeichen).'];
+ if (!isset(ueberwachung_types()[$type])) return ['ok' => false, 'error' => 'Unbekannte Art der Prüfung.'];
+ if ($type === 'http') {
+ $p = parse_url($target);
+ if (!$p || !in_array($p['scheme'] ?? '', ['http', 'https'], true) || empty($p['host']) || preg_match('/[\s\x00-\x1F]/', $target)) return ['ok' => false, 'error' => 'Bitte eine Adresse mit http:// oder https:// angeben.'];
+ } else {
+ if (!preg_match('/^([A-Za-z0-9.-]{1,253}|\[[0-9a-fA-F:]+\]):(\d{1,5})$/', $target, $m) || (int) $m[2] < 1 || (int) $m[2] > 65535) return ['ok' => false, 'error' => 'Bitte Host und Port im Format host:port angeben (zum Beispiel mail.example.de:993).'];
+ }
+ return ['ok' => true, 'data' => [
+ 'name' => $name, 'type' => $type, 'target' => mb_substr($target, 0, 500),
+ 'expect_text' => $type === 'http' ? mb_substr(trim((string) ($in['expect_text'] ?? '')), 0, 200) : '',
+ 'interval_min' => max(1, min(1440, (int) ($in['interval_min'] ?? 5))),
+ 'timeout_sec' => max(2, min(30, (int) ($in['timeout_sec'] ?? 8))),
+ 'cert_warn_days' => max(1, min(90, (int) ($in['cert_warn_days'] ?? 14))),
+ 'verify_tls' => array_key_exists('verify_tls', $in) ? (!empty($in['verify_tls']) ? 1 : 0) : 1,
+ 'is_active' => array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : 1,
+ ]];
+}
+
+function ueberwachung_save(PDO $pdo, array $in, int $id = 0): array
+{
+ $v = ueberwachung_validate($in);
+ if (!$v['ok']) return $v;
+ $d = $v['data'];
+ if ($id === 0) {
+ $pdo->prepare('INSERT INTO plugin_ueberwachung_monitors (name, type, target, expect_text, interval_min, timeout_sec, cert_warn_days, verify_tls, is_active, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
+ ->execute([$d['name'], $d['type'], $d['target'], $d['expect_text'], $d['interval_min'], $d['timeout_sec'], $d['cert_warn_days'], $d['verify_tls'], $d['is_active'], db_now()]);
+ return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
+ }
+ $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET name=?, type=?, target=?, expect_text=?, interval_min=?, timeout_sec=?, cert_warn_days=?, verify_tls=?, is_active=? WHERE id=?')
+ ->execute([$d['name'], $d['type'], $d['target'], $d['expect_text'], $d['interval_min'], $d['timeout_sec'], $d['cert_warn_days'], $d['verify_tls'], $d['is_active'], $id]);
+ return ['ok' => true, 'id' => $id];
+}
+
+function ueberwachung_delete(PDO $pdo, int $id): void
+{
+ $pdo->prepare('DELETE FROM plugin_ueberwachung_events WHERE monitor_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM plugin_ueberwachung_monitors WHERE id = ?')->execute([$id]);
+}
+
+// ── Prüfung ────────────────────────────────────────────────────────────────
+
+/** Eine Prüfung ausführen. @return array{ok:bool,error:?string,ms:int,cert_expires:?int} (für Tests über $GLOBALS['ueberwachung_probe'] austauschbar) */
+function ueberwachung_probe(array $m): array
+{
+ if (isset($GLOBALS['ueberwachung_probe']) && is_callable($GLOBALS['ueberwachung_probe'])) return ($GLOBALS['ueberwachung_probe'])($m);
+ $timeout = max(2, (int) $m['timeout_sec']);
+ $start = microtime(true);
+ $ms = fn() => (int) round((microtime(true) - $start) * 1000);
+ $verifyTls = !isset($m['verify_tls']) || (int) $m['verify_tls'] === 1;
+ $ssl = ['capture_peer_cert' => true, 'verify_peer' => $verifyTls, 'verify_peer_name' => $verifyTls];
+ $certInfo = [];
+ $certTs = function ($ctx) use (&$certInfo): ?int {
+ $params = stream_context_get_params($ctx);
+ $cert = $params['options']['ssl']['peer_certificate'] ?? null;
+ if (!$cert) return null;
+ $info = @openssl_x509_parse($cert);
+ if (!$info) return null;
+ $certInfo = ['issuer' => (string) ($info['issuer']['O'] ?? $info['issuer']['CN'] ?? ''), 'cert_subject' => (string) ($info['subject']['CN'] ?? '')];
+ return (int) $info['validTo_time_t'];
+ };
+ if ($m['type'] === 'http' && function_exists('curl_init') && empty($GLOBALS['ueberwachung_no_curl'])) return ueberwachung_probe_curl($m, $timeout);
+ if ($m['type'] === 'http') {
+ $ctx = stream_context_create(['http' => ['method' => 'GET', 'timeout' => $timeout, 'ignore_errors' => true, 'follow_location' => 1, 'max_redirects' => 5, 'header' => "User-Agent: DeskHub-Ueberwachung\r\nAccept: */*\r\n"], 'ssl' => $ssl]);
+ $old = ini_set('default_socket_timeout', (string) $timeout);
+ $warnings = [];
+ set_error_handler(function (int $no, string $str) use (&$warnings): bool { $warnings[] = $str; return true; });
+ try {
+ $fh = fopen((string) $m['target'], 'r', false, $ctx);
+ } finally {
+ restore_error_handler();
+ }
+ if ($old !== false) ini_set('default_socket_timeout', (string) $old);
+ if (!$fh) {
+ return ['ok' => false, 'error' => ueberwachung_describe_error(implode(' | ', $warnings)), 'ms' => $ms(), 'cert_expires' => null];
+ }
+ stream_set_timeout($fh, $timeout);
+ $body = (string) stream_get_contents($fh, 65536);
+ $meta = stream_get_meta_data($fh);
+ fclose($fh);
+ $status = 0;
+ foreach ((array) ($meta['wrapper_data'] ?? []) as $h) if (is_string($h) && preg_match('#^HTTP/\S+\s+(\d+)#', $h, $mm)) $status = (int) $mm[1];
+ $cert = $certTs($ctx);
+ $extra = $certInfo + ['status' => $status, 'title' => ueberwachung_page_title($body)];
+ if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP-Status ' . ($status ?: 'unbekannt'), 'ms' => $ms(), 'cert_expires' => $cert] + $extra;
+ if ($m['expect_text'] !== '' && !str_contains($body, (string) $m['expect_text'])) return ['ok' => false, 'error' => 'Der erwartete Text fehlt auf der Seite.', 'ms' => $ms(), 'cert_expires' => $cert] + $extra;
+ if ($m['expect_text'] === '' && ($why = ueberwachung_default_page($extra['title'], $body))) return ['ok' => false, 'error' => $why, 'ms' => $ms(), 'cert_expires' => $cert] + $extra;
+ return ['ok' => true, 'error' => null, 'ms' => $ms(), 'cert_expires' => $cert] + $extra;
+ }
+ [$host, $port] = [preg_replace('/:\d+$/', '', (string) $m['target']), (int) substr((string) strrchr((string) $m['target'], ':'), 1)];
+ $isTls = $m['type'] === 'tls';
+ $ctx = stream_context_create(['ssl' => $ssl + ['peer_name' => trim($host, '[]')]]);
+ $fh = @stream_socket_client(($isTls ? 'ssl://' : 'tcp://') . $host . ':' . $port, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
+ if (!$fh) return ['ok' => false, 'error' => ueberwachung_describe_error((string) $errstr ?: 'connection refused'), 'ms' => $ms(), 'cert_expires' => null];
+ $cert = $isTls ? $certTs($ctx) : null;
+ fclose($fh);
+ return ['ok' => true, 'error' => null, 'ms' => $ms(), 'cert_expires' => $cert] + $certInfo;
+}
+
+/** Kennt dieses PHP keine vertrauenswürdigen Zertifizierungsstellen? Dann scheitert jede HTTPS-Prüfung mit „Zertifikat ungültig“, auch bei gesunden Seiten. */
+function ueberwachung_ca_bundle_missing(): bool
+{
+ // Mit open_basedir (bei Plesk üblich) lassen sich die Dateien nicht prüfen: dann nichts behaupten
+ if ((string) ini_get('open_basedir') !== '') return false;
+ foreach ([(string) ini_get('curl.cainfo'), (string) ini_get('openssl.cafile'), '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem'] as $file) {
+ if ($file !== '' && is_file($file)) return false;
+ }
+ $loc = function_exists('openssl_get_cert_locations') ? openssl_get_cert_locations() : [];
+ if (!empty($loc['default_cert_file']) && is_file($loc['default_cert_file'])) return false;
+ $dir = (string) ($loc['default_cert_dir'] ?? '');
+ return !($dir !== '' && is_dir($dir) && (glob($dir . '/*') ?: []));
+}
+
+/** Prüfung einer Webseite mit cURL: genaue Fehlertexte, HTTP-Status und Zertifikatsdaten. */
+function ueberwachung_probe_curl(array $m, int $timeout): array
+{
+ $verify = !isset($m['verify_tls']) || (int) $m['verify_tls'] === 1;
+ $body = '';
+ $ch = curl_init((string) $m['target']);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5,
+ CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => min($timeout, 10),
+ CURLOPT_USERAGENT => 'DeskHub-Ueberwachung', CURLOPT_HTTPHEADER => ['Accept: */*'],
+ CURLOPT_SSL_VERIFYPEER => $verify, CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, CURLOPT_CERTINFO => true,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
+ // nur die ersten 64 KB lesen (reicht für den erwarteten Text)
+ CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int {
+ $body .= $data;
+ return strlen($body) > 65536 ? 0 : strlen($data);
+ },
+ ]);
+ $start = microtime(true);
+ $ok = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $error = curl_error($ch);
+ $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ $ms = (int) round((microtime(true) - $start) * 1000);
+ $cert = null;
+ $extra = [];
+ $info = curl_getinfo($ch, CURLINFO_CERTINFO);
+ if (is_array($info) && !empty($info[0]['Expire date'])) {
+ $ts = strtotime((string) $info[0]['Expire date']);
+ if ($ts) $cert = $ts;
+ $issuer = (string) ($info[0]['Issuer'] ?? '');
+ $subject = (string) ($info[0]['Subject'] ?? '');
+ $extra = ['issuer' => preg_match('/O\s*=\s*([^,\/]+)/', $issuer, $x) ? trim($x[1]) : (preg_match('/CN\s*=\s*([^,\/]+)/', $issuer, $x) ? trim($x[1]) : ''), 'cert_subject' => preg_match('/CN\s*=\s*([^,\/]+)/', $subject, $x) ? trim($x[1]) : ''];
+ }
+ curl_close($ch);
+ $extra += ['status' => $status];
+ // Zertifikatsfehler: ohne Prüfung nachsehen, ob die Seite antwortet und was für ein Zertifikat sie hat
+ if ($ok === false && $verify && preg_match('/certificate|ssl|tls/i', $error)) {
+ $r2 = ueberwachung_probe_curl(array_merge($m, ['verify_tls' => 0]), $timeout);
+ $msg = ueberwachung_describe_error($error);
+ if (!empty($r2['cert_expires']) && $r2['cert_expires'] < time()) $msg = 'Zertifikat/TLS-Fehler (Zertifikat abgelaufen): Die gesicherte Verbindung kam nicht zustande';
+ if (ueberwachung_ca_bundle_missing()) $msg .= ' – möglicherweise fehlt auf diesem Server die Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle). Das Ziel kann in Ordnung sein: Zertifikatsprüfung für dieses Ziel abschalten oder curl.cainfo/openssl.cafile in der PHP-Konfiguration setzen';
+ elseif (!empty($r2['status'])) $msg .= ' (die Seite antwortet ohne Prüfung mit HTTP ' . $r2['status'] . ')';
+ return ['ok' => false, 'error' => $msg, 'ms' => $ms, 'cert_expires' => $r2['cert_expires'] ?? null] + array_intersect_key($r2, ['issuer' => 1, 'cert_subject' => 1, 'status' => 1]);
+ }
+ // Abbruch durch unsere 64-KB-Grenze (Fehler 23) ist kein Fehler
+ if ($ok === false && !($errno === 23 && strlen($body) > 65536 && $status > 0)) {
+ return ['ok' => false, 'error' => ueberwachung_describe_error($error !== '' ? $error : 'curl error ' . $errno), 'ms' => $ms, 'cert_expires' => $cert] + $extra;
+ }
+ $extra['title'] = ueberwachung_page_title($body);
+ if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP-Status ' . ($status ?: 'unbekannt'), 'ms' => $ms, 'cert_expires' => $cert] + $extra;
+ if ($m['expect_text'] !== '' && !str_contains($body, (string) $m['expect_text'])) return ['ok' => false, 'error' => 'Der erwartete Text fehlt auf der Seite.', 'ms' => $ms, 'cert_expires' => $cert] + $extra;
+ if ($m['expect_text'] === '' && ($why = ueberwachung_default_page($extra['title'], $body))) return ['ok' => false, 'error' => $why, 'ms' => $ms, 'cert_expires' => $cert] + $extra;
+ return ['ok' => true, 'error' => null, 'ms' => $ms, 'cert_expires' => $cert] + $extra;
+}
+
+function ueberwachung_event(PDO $pdo, int $monitorId, string $kind, string $message): void
+{
+ $pdo->prepare('INSERT INTO plugin_ueberwachung_events (monitor_id, kind, message, created_at) VALUES (?,?,?,?)')->execute([$monitorId, $kind, mb_substr($message, 0, 400), db_now()]);
+}
+
+/** Mail an den Administrator (für Tests über $GLOBALS['ueberwachung_mail'] austauschbar). */
+function ueberwachung_alert(string $subject, string $html): void
+{
+ $cfg = ueberwachung_config();
+ if (!$cfg['mail']) return;
+ foreach (preg_split('/[,;\s]+/', $cfg['alert_email'], -1, PREG_SPLIT_NO_EMPTY) ?: [] as $to) {
+ if (!filter_var($to, FILTER_VALIDATE_EMAIL)) continue;
+ try {
+ if (isset($GLOBALS['ueberwachung_mail']) && is_callable($GLOBALS['ueberwachung_mail'])) ($GLOBALS['ueberwachung_mail'])($to, $subject, $html);
+ else send_mail($to, $subject, $html);
+ } catch (Throwable $e) {
+ error_log('ueberwachung mail: ' . $e->getMessage());
+ }
+ }
+}
+
+function ueberwachung_duration(int $seconds): string
+{
+ if ($seconds < 90) return $seconds . ' Sekunden';
+ if ($seconds < 5400) return round($seconds / 60) . ' Minuten';
+ if ($seconds < 172800) return round($seconds / 3600, 1) . ' Stunden';
+ return round($seconds / 86400, 1) . ' Tage';
+}
+
+/**
+ * Legt ein Ticket ohne Ticket-Regeln, Absender-Sperre und Antwort-Mail an. Das ist der Rückfall, wenn das normale Anlegen scheitert:
+ * Eine Störungsmeldung darf nicht daran hängen, dass eine Regel („Spam ablehnen“) oder die Sperrliste die eigene Adresse trifft.
+ */
+function ueberwachung_create_direct(PDO $pdo, string $email, string $subject, string $message, ?int $deptId, ?int $prioId): int
+{
+ $deptId = active_department_id($pdo, $deptId ?: (int) get_setting('default_department_id', '1'));
+ if (!$deptId) throw new RuntimeException('Es ist keine Abteilung eingerichtet.');
+ if (!$prioId) $prioId = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
+ $p = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
+ $p->execute([$prioId]);
+ $priority = $p->fetch();
+ if (!$priority) throw new RuntimeException('Die Priorität gibt es nicht.');
+ $sla = ['first_response_due_at' => null, 'resolution_due_at' => null];
+ try { $sla = calc_sla_dates($priority, null, $deptId, null); } catch (Throwable $e) { /* ohne Fristen */ }
+ $number = generate_ticket_number($pdo);
+ $now = db_now();
+ $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
+ ->execute([$number, mb_substr($subject, 0, 250), 'Überwachung', $email, $deptId, $prioId, 'open', 'api', '0.0.0.0', $sla['first_response_due_at'] ?? null, $sla['resolution_due_at'] ?? null, $now, $now]);
+ $id = (int) $pdo->lastInsertId();
+ $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', ?, 0, ?)")->execute([$id, $message, $now]);
+ log_activity($pdo, $id, null, 'Ticket von der Überwachung angelegt');
+ apply_tag_to_ticket($pdo, $id, 'Überwachung');
+ $d = $pdo->prepare('SELECT name FROM departments WHERE id = ?');
+ $d->execute([$deptId]);
+ try {
+ notify_staff_new_ticket(['ticket_number' => $number, 'subject' => $subject, 'requester_name' => 'Überwachung', 'requester_email' => $email, 'department_id' => $deptId, 'department_name' => $d->fetchColumn() ?: null]);
+ } catch (Throwable $e) { /* Benachrichtigung ist nachrangig */ }
+ try { webhook_dispatch($pdo, 'ticket.created', $id); } catch (Throwable $e) { /* dito */ }
+ return $id;
+}
+
+/** Legt ein Ticket an (erst auf dem normalen Weg, sonst direkt). Merkt sich den letzten Fehler für die Seite. @return int|null Ticket-ID */
+function ueberwachung_open_ticket(PDO $pdo, string $subject, string $message): ?int
+{
+ $cfg = ueberwachung_config();
+ if (!$cfg['create_tickets']) return null;
+ $first = preg_split('/[,;\s]+/', $cfg['alert_email'], -1, PREG_SPLIT_NO_EMPTY)[0] ?? '';
+ $email = filter_var($first, FILTER_VALIDATE_EMAIL) ? $first : 'ueberwachung@system.invalid';
+ $why = '';
+ try {
+ $r = public_ticket_create($pdo, [
+ 'name' => 'Überwachung', 'email' => $email, 'subject' => mb_substr($subject, 0, 250), 'message' => $message,
+ 'departmentId' => $cfg['department_id'] ?: null, 'priorityId' => $cfg['priority_id'] ?: null,
+ ], '0.0.0.0', ['activity' => 'Ticket von der Überwachung angelegt', 'tag' => 'Überwachung']);
+ plugin_set_setting(UEBERWACHUNG, 'ticket_error', '');
+ return (int) $r['id'];
+ } catch (Throwable $e) {
+ $why = $e->getMessage();
+ error_log('ueberwachung ticket: ' . $why);
+ }
+ // Ist das Ticket trotz Fehler (zum Beispiel bei der Benachrichtigung) schon angelegt worden, nicht doppelt anlegen
+ $dup = $pdo->prepare("SELECT id FROM tickets WHERE subject = ? AND requester_name = 'Überwachung' AND created_at >= ? ORDER BY id DESC LIMIT 1");
+ $dup->execute([mb_substr($subject, 0, 250), date('Y-m-d H:i:s', time() - 120)]);
+ if ($id = $dup->fetchColumn()) { plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': ' . $why . ' (Ticket wurde trotzdem angelegt)'); return (int) $id; }
+ try {
+ $id = ueberwachung_create_direct($pdo, $email, $subject, $message, $cfg['department_id'] ?: null, $cfg['priority_id'] ?: null);
+ plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': Das Ticket musste am normalen Weg vorbei angelegt werden, weil dort abgelehnt wurde: ' . $why);
+ return $id;
+ } catch (Throwable $e) {
+ error_log('ueberwachung ticket (direkt): ' . $e->getMessage());
+ plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': Ticket konnte nicht angelegt werden: ' . $why . ' / ' . $e->getMessage());
+ return null;
+ }
+}
+/** Setzt das Ticket auf „Gelöst“ und vermerkt die Entwarnung (nur wenn es noch offen ist und die Einstellung es erlaubt). */
+function ueberwachung_resolve_ticket(PDO $pdo, ?int $ticketId, string $note): void
+{
+ if (!$ticketId) return;
+ $s = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
+ $s->execute([$ticketId]);
+ $status = $s->fetchColumn();
+ if ($status === false) return;
+ $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticketId, $note, db_now()]);
+ log_activity($pdo, $ticketId, null, 'Überwachung: ' . $note);
+ if (!ueberwachung_config()['auto_resolve'] || in_array($status, ['resolved', 'closed'], true)) return;
+ $now = db_now();
+ $pdo->prepare("UPDATE tickets SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
+ webhook_dispatch($pdo, 'ticket.status_changed', $ticketId, ['previousStatus' => (string) $status]);
+}
+
+/** Wertet ein Prüfergebnis aus: Zustand, Ticket, Mail, Entwarnung. */
+function ueberwachung_apply(PDO $pdo, array $m, array $res): void
+{
+ $cfg = ueberwachung_config();
+ $now = db_now();
+ $id = (int) $m['id'];
+ $certStr = $res['cert_expires'] ? date('Y-m-d H:i:s', (int) $res['cert_expires']) : ($m['cert_expires'] ?? null);
+ $upd = $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ?, last_ms = ?, cert_expires = ?, last_status = ?, last_title = ? WHERE id = ?');
+ $upd->execute([$now, $res['ms'], $certStr, !empty($res['status']) ? (int) $res['status'] : null, isset($res['title']) && $res['title'] !== '' ? mb_substr((string) $res['title'], 0, 150) : null, $id]);
+
+ if ($res['ok']) {
+ $wasDown = $m['state'] === 'down';
+ $pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'up', fails = 0, last_ok_at = ?, last_error = NULL, down_since = NULL, first_fail_at = NULL, ticket_id = ? WHERE id = ?")->execute([$now, $wasDown ? null : $m['ticket_id'], $id]);
+ if ($wasDown) {
+ $dur = $m['down_since'] ? ueberwachung_duration(max(0, time() - (int) strtotime((string) $m['down_since']))) : 'unbekannt';
+ ueberwachung_event($pdo, $id, 'up', 'Wieder erreichbar nach ' . $dur . '.');
+ ueberwachung_resolve_ticket($pdo, $m['ticket_id'] ? (int) $m['ticket_id'] : null, ueberwachung_up_note($m, $dur));
+ ueberwachung_alert(ueberwachung_subject('Entwarnung', $m) . ' ist wieder erreichbar', '
' . e($m['name']) . ' ist wieder erreichbar.
' . ueberwachung_table([['Dienst', (string) $m['name']], ['Adresse', (string) $m['target']], ['Ausfall', ueberwachung_dt((string) ($m['first_fail_at'] ?? $m['down_since'] ?? '')) . ' bis ' . date('d.m.Y H:i:s')], ['Dauer', $dur], ['Antwortzeit jetzt', (int) $res['ms'] . ' ms']]));
+ }
+ } else {
+ $fails = (int) $m['fails'] + 1;
+ $firstFail = ($fails === 1 || empty($m['first_fail_at'])) ? $now : (string) $m['first_fail_at'];
+ $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET fails = ?, last_error = ?, first_fail_at = ? WHERE id = ?')->execute([$fails, mb_substr((string) $res['error'], 0, 300), $firstFail, $id]);
+ if ($fails >= $cfg['threshold'] && $m['state'] !== 'down') {
+ $body = ueberwachung_down_body($m, $res, $fails, $firstFail);
+ $ticket = ueberwachung_open_ticket($pdo, ueberwachung_subject('Ausfall', $m), $body);
+ $pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'down', down_since = ?, ticket_id = ? WHERE id = ?")->execute([$firstFail, $ticket, $id]);
+ ueberwachung_event($pdo, $id, 'down', 'Ausfall: ' . $res['error'] . ($ticket ? '' : ($cfg['create_tickets'] ? ' (Ticket konnte nicht angelegt werden, siehe Hinweis oben)' : ' (Tickets sind ausgeschaltet)')));
+ ueberwachung_alert(ueberwachung_subject('Ausfall', $m), $body);
+ } elseif ($fails < $cfg['threshold'] && $m['state'] === 'unknown') {
+ $pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'up' WHERE id = ? AND state = 'unknown'")->execute([$id]);
+ }
+ }
+
+ // Zertifikat
+ if ($res['cert_expires']) {
+ $days = (int) floor(((int) $res['cert_expires'] - time()) / 86400);
+ $warn = (int) $m['cert_warn_days'];
+ if ($days <= $warn && !$m['cert_ticket_id']) {
+ $text = $days < 0 ? 'Das Zertifikat von ' . $m['name'] . ' ist seit ' . abs($days) . ' Tagen abgelaufen.' : 'Das Zertifikat von ' . $m['name'] . ' läuft in ' . $days . ' Tagen ab (' . date('d.m.Y', (int) $res['cert_expires']) . ').';
+ $certBody = ueberwachung_cert_body($m, $res, $days);
+ $ticket = ueberwachung_open_ticket($pdo, ueberwachung_subject('Zertifikat', $m), $certBody);
+ $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET cert_ticket_id = ? WHERE id = ?')->execute([$ticket ?: -1, $id]);
+ ueberwachung_event($pdo, $id, 'cert', $text);
+ ueberwachung_alert(ueberwachung_subject($days < 0 ? 'Zertifikat abgelaufen' : 'Zertifikat läuft bald ab', $m), $certBody);
+ } elseif ($days > $warn && $m['cert_ticket_id']) {
+ $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET cert_ticket_id = NULL WHERE id = ?')->execute([$id]);
+ ueberwachung_event($pdo, $id, 'cert_ok', 'Zertifikat erneuert, gültig bis ' . date('d.m.Y', (int) $res['cert_expires']) . '.');
+ ueberwachung_resolve_ticket($pdo, (int) $m['cert_ticket_id'] > 0 ? (int) $m['cert_ticket_id'] : null, 'Zertifikat erneuert (' . $m['name'] . '), gültig bis ' . date('d.m.Y', (int) $res['cert_expires']) . '.');
+ ueberwachung_alert('Zertifikat erneuert: ' . $m['name'], 'Das Zertifikat von ' . e($m['name']) . ' wurde erneuert und ist gültig bis ' . e(date('d.m.Y', (int) $res['cert_expires'])) . '.
');
+ }
+ }
+}
+
+/** Prüft ein Ziel jetzt. */
+function ueberwachung_check(PDO $pdo, int $id): bool
+{
+ $s = $pdo->prepare('SELECT * FROM plugin_ueberwachung_monitors WHERE id = ?');
+ $s->execute([$id]);
+ $m = $s->fetch();
+ if (!$m) return false;
+ try {
+ $res = ueberwachung_probe($m);
+ } catch (Throwable $e) {
+ $res = ['ok' => false, 'error' => 'Prüfung fehlgeschlagen: ' . mb_substr($e->getMessage(), 0, 150), 'ms' => 0, 'cert_expires' => null];
+ }
+ ueberwachung_apply($pdo, $m, $res);
+ return true;
+}
+
+/** Wartezeit bis zur nächsten Prüfung in Sekunden: der eingestellte Abstand, nach einem ersten Fehlschlag (Ausfall noch nicht gemeldet) nur eine Minute. */
+function ueberwachung_next_wait(array $m): int
+{
+ if ((int) ($m['fails'] ?? 0) > 0 && ($m['state'] ?? '') !== 'down') return 45;
+ return max(45, (int) $m['interval_min'] * 60 - 15);
+}
+
+/** Hintergrundlauf: fällige Ziele prüfen (höchstens 6 pro Lauf, damit der Lauf kurz bleibt). */
+function ueberwachung_tick(PDO $pdo): int
+{
+ plugin_set_setting(UEBERWACHUNG, 'last_tick', (string) time());
+ $due = [];
+ foreach ($pdo->query('SELECT id, interval_min, last_check_at, fails, state FROM plugin_ueberwachung_monitors WHERE is_active = 1 ORDER BY last_check_at')->fetchAll() as $m) {
+ if ($m['last_check_at'] === null || time() - (int) strtotime((string) $m['last_check_at']) >= ueberwachung_next_wait($m)) $due[] = (int) $m['id'];
+ if (count($due) >= 6) break;
+ }
+ foreach ($due as $id) ueberwachung_check($pdo, $id);
+ if ($due && time() - (int) plugin_setting(UEBERWACHUNG, 'last_prune', '0') > 86400) {
+ plugin_set_setting(UEBERWACHUNG, 'last_prune', (string) time());
+ $pdo->prepare('DELETE FROM plugin_ueberwachung_events WHERE created_at < ?')->execute([date('Y-m-d H:i:s', time() - 90 * 86400)]);
+ }
+ return count($due);
+}
+
+plugin_add_hook('plugin.uninstall.' . UEBERWACHUNG, function () {
+ try {
+ get_db()->exec('DROP TABLE IF EXISTS plugin_ueberwachung_events');
+ get_db()->exec('DROP TABLE IF EXISTS plugin_ueberwachung_monitors');
+ } catch (Throwable $e) {
+ }
+});
diff --git a/Plugins/plugins/ueberwachung/meldung.php b/Plugins/plugins/ueberwachung/meldung.php
new file mode 100644
index 00000000..ed140d8b
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/meldung.php
@@ -0,0 +1,168 @@
+ $rows */
+function ueberwachung_table(array $rows): string
+{
+ $html = '';
+ foreach ($rows as [$k, $v]) $html .= '| ' . e($k) . ' | ' . e($v) . ' |
';
+ return $html . '
';
+}
+
+function ueberwachung_dt(?string $dt): string
+{
+ return $dt ? date('d.m.Y H:i:s', (int) strtotime($dt)) : '–';
+}
+
+/** Zeilen zu einem Zertifikat. @return list */
+function ueberwachung_cert_rows(array $m, array $res, ?int $days = null): array
+{
+ if (empty($res['cert_expires'])) return [];
+ $days ??= (int) floor(((int) $res['cert_expires'] - time()) / 86400);
+ $rows = [['Zertifikat gültig bis', date('d.m.Y H:i', (int) $res['cert_expires']) . ($days < 0 ? ' (seit ' . abs($days) . ' Tagen abgelaufen)' : ' (noch ' . $days . ' Tage)')]];
+ if (!empty($res['issuer'])) $rows[] = ['Aussteller', (string) $res['issuer']];
+ if (!empty($res['cert_subject'])) $rows[] = ['Ausgestellt für', (string) $res['cert_subject']];
+ return $rows;
+}
+
+/** Hinweise zur Fehlersuche je nach Art und Fehler. @return list */
+function ueberwachung_hints(array $m, string $error): array
+{
+ $e = strtolower($error);
+ if (str_starts_with($error, 'Zertifikat')) return ['Zertifikat prüfen: abgelaufen, falscher Name oder fehlende Zwischenzertifikate?', 'Zertifikat erneuern (zum Beispiel Let’s Encrypt in Plesk) und die Seite im Browser öffnen.'];
+ if (preg_match('/http-status 5\d\d/', $e)) return ['Der Server antwortet, aber die Anwendung meldet einen Fehler: Fehlerprotokoll des Webservers und der Anwendung ansehen.', 'Datenbank, Speicherplatz und PHP-Prozess prüfen.', 'Kürzlich eingespielte Änderungen oder Updates zurücknehmen.'];
+ if (preg_match('/http-status 4\d\d/', $e)) return ['Die Adresse antwortet mit einem Fehler (zum Beispiel 404 oder 403): Adresse, Weiterleitung und Zugriffsschutz prüfen.'];
+ if (str_contains($e, 'standard- oder fehlerseite')) return ['Der Server liefert nur seine Standard- oder Fehlerseite aus: Ist der Eintrag im Reverse-Proxy (zum Beispiel Nginx Proxy Manager) aktiviert, und läuft der Dienst dahinter?', 'Adresse und Port des Ziels im Proxy prüfen und den Dienst dahinter ansehen.'];
+ if (str_contains($e, 'text fehlt')) return ['Die Seite antwortet, enthält aber nicht den erwarteten Text: Wartungsseite, Fehlerseite oder veränderter Inhalt?'];
+ if (str_contains($e, 'dns')) return ['DNS-Eintrag der Adresse prüfen (Domain abgelaufen? Eintrag geändert?).'];
+ $hints = ['Läuft der Server und der Dienst? Neustart des Webservers bzw. Dienstes versuchen.', 'Firewall und Port-Freigaben prüfen.'];
+ if ($m['type'] === 'http') $hints[] = 'Die Seite einmal im Browser und von einem anderen Netz aus öffnen, um ein lokales Problem auszuschließen.';
+ return $hints;
+}
+
+function ueberwachung_hint_list(array $hints): string
+{
+ return 'Mögliche Ursachen und nächste Schritte
' . implode('', array_map(fn($h) => '- ' . e($h) . '
', $hints)) . '
';
+}
+
+/** Text des Ausfall-Tickets. */
+function ueberwachung_down_body(array $m, array $res, int $fails, ?string $firstFail): string
+{
+ $rows = [
+ ['Dienst', (string) $m['name']],
+ ['Art der Prüfung', ueberwachung_type_label((string) $m['type'])],
+ ['Adresse', (string) $m['target']],
+ ['Zustand', 'Ausfall'],
+ ['Fehler', (string) $res['error']],
+ ];
+ if (!empty($res['status'])) $rows[] = ['HTTP-Status', (string) $res['status']];
+ $rows[] = ['Seit', ueberwachung_dt($firstFail) . ($firstFail ? ' (vor ' . ueberwachung_duration(max(0, time() - (int) strtotime($firstFail))) . ')' : '')];
+ $rows[] = ['Fehlgeschlagene Prüfungen in Folge', $fails . ' (Prüfung alle ' . (int) $m['interval_min'] . ' Minuten, Ausfall ab ' . ueberwachung_config()['threshold'] . ' in Folge)'];
+ $rows[] = ['Letzter erfolgreicher Abruf', $m['last_ok_at'] ? ueberwachung_dt((string) $m['last_ok_at']) : 'noch nie'];
+ $rows[] = ['Dauer der letzten Prüfung', (int) $res['ms'] . ' ms (Zeitlimit ' . (int) $m['timeout_sec'] . ' Sekunden)'];
+ if (!empty($m['expect_text'])) $rows[] = ['Erwarteter Text', (string) $m['expect_text']];
+ foreach (ueberwachung_cert_rows($m, $res) as $r) $rows[] = $r;
+ $rows[] = ['Geprüft von', (string) (gethostname() ?: 'DeskHub-Server') . ', ' . date('d.m.Y H:i:s')];
+ return '' . e($m['name']) . ' ist nicht erreichbar bzw. fehlerhaft.
' . ueberwachung_table($rows)
+ . ueberwachung_hint_list(ueberwachung_hints($m, (string) $res['error']))
+ . 'Diese Meldung stammt von der automatischen Überwachung. Sobald der Dienst wieder erreichbar ist, wird das Ticket automatisch gelöst.
';
+}
+
+/** Text des Zertifikat-Tickets. */
+function ueberwachung_cert_body(array $m, array $res, int $days): string
+{
+ $headline = $days < 0 ? 'Das Zertifikat ist seit ' . abs($days) . ' Tagen abgelaufen.' : 'Das Zertifikat läuft in ' . $days . ' Tagen ab.';
+ $rows = array_merge([
+ ['Dienst', (string) $m['name']],
+ ['Art der Prüfung', ueberwachung_type_label((string) $m['type'])],
+ ['Adresse', (string) $m['target']],
+ ], ueberwachung_cert_rows($m, $res, $days), [
+ ['Warnung ab', (int) $m['cert_warn_days'] . ' Tagen vor Ablauf'],
+ ['Geprüft von', (string) (gethostname() ?: 'DeskHub-Server') . ', ' . date('d.m.Y H:i:s')],
+ ]);
+ return '' . e($headline) . '
' . ueberwachung_table($rows)
+ . ueberwachung_hint_list(['Zertifikat erneuern (zum Beispiel Let’s Encrypt in Plesk oder beim Anbieter).', 'Nach der Erneuerung die Seite im Browser öffnen und die Kette prüfen.', 'Bei automatischer Erneuerung: prüfen, warum sie nicht gelaufen ist.'])
+ . 'Nach der Erneuerung wird dieses Ticket automatisch gelöst.
';
+}
+
+/** Interne Notiz bei Entwarnung (Ausfall). */
+function ueberwachung_up_note(array $m, string $dur): string
+{
+ $since = $m['first_fail_at'] ?? $m['down_since'] ?? null;
+ return 'Wieder erreichbar (' . $m['name'] . '). Der Ausfall dauerte ' . $dur . '.'
+ . ($since ? ' Von ' . ueberwachung_dt((string) $since) . ' bis ' . date('d.m.Y H:i:s') . '.' : '');
+}
+
+/** Titel der Seite (für Anzeige und die Erkennung von Standard- und Fehlerseiten). */
+function ueberwachung_page_title(string $body): string
+{
+ if (!preg_match('/]*>(.*?)<\/title>/is', $body, $m)) return '';
+ return mb_substr(trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 0, 150);
+}
+
+/**
+ * Antwortet ein Server mit 200, liefert aber nur seine Standard- oder Fehlerseite (zum Beispiel ein abgeschalteter Eintrag im Proxy), ist das
+ * Ziel trotzdem ausgefallen. Erkannt wird das am Seitentitel. Mit „erwartetem Text“ am Ziel entfällt diese Prüfung. @return string|null Fehlertext
+ */
+function ueberwachung_default_page(string $title, string $body = ''): ?string
+{
+ // Titel bekannter Standard- und Fehlerseiten (Nginx Proxy Manager: „Default Site“)
+ if ($title !== '' && preg_match('/^default site$|congratulations|welcome to nginx|nginx.*default|default (web )?(server )?page|test page for|apache2? .*default page|^it works!?$|^index of \/|iis windows server|^welcome to openresty|bad gateway|service (temporarily )?unavailable|gateway time-?out|^50[234]\b|^404\b|not found$/i', $title)) {
+ return 'Der Server antwortet mit einer Standard- oder Fehlerseite statt mit der Anwendung (Seitentitel: „' . mb_substr($title, 0, 80) . '“)';
+ }
+ // Typische Sätze der Standardseiten, auch wenn der Titel nichtssagend ist
+ if ($body !== '' && preg_match('/successfully started the nginx proxy manager|trying to access a host that isn.?t set up yet|if you see this page, the nginx web server is successfully installed|apache2 (ubuntu|debian) default page|this is the default welcome page|web server\'s default page|domain (is )?(not configured|parked)/i', $body, $m)) {
+ return 'Der Server antwortet mit einer Standardseite statt mit der Anwendung (Text auf der Seite: „' . mb_substr($m[0], 0, 80) . '“)';
+ }
+ return null;
+}
+/**
+ * Soll die Warnung „PHP kennt keine vertrauenswürdigen Zertifizierungsstellen“ erscheinen? Nur wenn das System es annimmt UND es auch so aussieht:
+ * Mindestens ein HTTPS-Ziel mit Zertifikatsprüfung scheitert an der Zertifikatsprüfung und keines wurde erfolgreich geprüft. Wird irgendeine
+ * HTTPS-Seite mit Prüfung erreicht, funktioniert die Prüfung, egal was die Dateisuche meint.
+ * @param list> $monitors
+ */
+function ueberwachung_ca_warning(array $monitors, bool $systemSaysMissing): bool
+{
+ if (!$systemSaysMissing) return false;
+ $bad = 0;
+ $good = 0;
+ foreach ($monitors as $m) {
+ if (($m['type'] ?? '') !== 'http' || !str_starts_with((string) $m['target'], 'https') || (int) ($m['verify_tls'] ?? 1) !== 1 || empty($m['last_check_at'])) continue;
+ if (str_starts_with((string) ($m['last_error'] ?? ''), 'Zertifikat/TLS-Fehler')) $bad++; else $good++;
+ }
+ return $bad > 0 && $good === 0;
+}
\ No newline at end of file
diff --git a/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.mysql.sql b/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.mysql.sql
new file mode 100644
index 00000000..a0bbf171
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.mysql.sql
@@ -0,0 +1,30 @@
+CREATE TABLE IF NOT EXISTS plugin_ueberwachung_monitors (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(120) NOT NULL,
+ type VARCHAR(8) NOT NULL DEFAULT 'http',
+ target VARCHAR(500) NOT NULL,
+ expect_text VARCHAR(200) NOT NULL DEFAULT '',
+ interval_min INT NOT NULL DEFAULT 5,
+ timeout_sec INT NOT NULL DEFAULT 8,
+ cert_warn_days INT NOT NULL DEFAULT 14,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ state VARCHAR(8) NOT NULL DEFAULT 'unknown',
+ fails INT NOT NULL DEFAULT 0,
+ last_check_at DATETIME NULL,
+ last_ok_at DATETIME NULL,
+ last_error VARCHAR(300) NULL,
+ last_ms INT NULL,
+ cert_expires DATETIME NULL,
+ down_since DATETIME NULL,
+ ticket_id INT NULL,
+ cert_ticket_id INT NULL,
+ created_at DATETIME NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE IF NOT EXISTS plugin_ueberwachung_events (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ monitor_id INT NOT NULL,
+ kind VARCHAR(12) NOT NULL,
+ message VARCHAR(400) NOT NULL,
+ created_at DATETIME NOT NULL,
+ KEY idx_plugin_ueberwachung_events (monitor_id, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
diff --git a/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.sqlite.sql b/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.sqlite.sql
new file mode 100644
index 00000000..7f5b662e
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/migrations/001_ueberwachung.sqlite.sql
@@ -0,0 +1,30 @@
+CREATE TABLE IF NOT EXISTS plugin_ueberwachung_monitors (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL DEFAULT 'http',
+ target TEXT NOT NULL,
+ expect_text TEXT NOT NULL DEFAULT '',
+ interval_min INTEGER NOT NULL DEFAULT 5,
+ timeout_sec INTEGER NOT NULL DEFAULT 8,
+ cert_warn_days INTEGER NOT NULL DEFAULT 14,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ state TEXT NOT NULL DEFAULT 'unknown',
+ fails INTEGER NOT NULL DEFAULT 0,
+ last_check_at TEXT NULL,
+ last_ok_at TEXT NULL,
+ last_error TEXT NULL,
+ last_ms INTEGER NULL,
+ cert_expires TEXT NULL,
+ down_since TEXT NULL,
+ ticket_id INTEGER NULL,
+ cert_ticket_id INTEGER NULL,
+ created_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS plugin_ueberwachung_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ monitor_id INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ message TEXT NOT NULL,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_plugin_ueberwachung_events ON plugin_ueberwachung_events (monitor_id, id)
diff --git a/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.mysql.sql b/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.mysql.sql
new file mode 100644
index 00000000..e76a4560
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.mysql.sql
@@ -0,0 +1 @@
+ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN first_fail_at DATETIME NULL
\ No newline at end of file
diff --git a/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.sqlite.sql b/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.sqlite.sql
new file mode 100644
index 00000000..49c6d6dd
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/migrations/002_erster_fehler.sqlite.sql
@@ -0,0 +1 @@
+ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN first_fail_at TEXT NULL
\ No newline at end of file
diff --git a/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.mysql.sql b/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.mysql.sql
new file mode 100644
index 00000000..7b74da29
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.mysql.sql
@@ -0,0 +1 @@
+ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN verify_tls TINYINT(1) NOT NULL DEFAULT 1
\ No newline at end of file
diff --git a/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.sqlite.sql b/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.sqlite.sql
new file mode 100644
index 00000000..eab03931
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/migrations/003_tls_pruefung.sqlite.sql
@@ -0,0 +1 @@
+ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN verify_tls INTEGER NOT NULL DEFAULT 1
\ No newline at end of file
diff --git a/Plugins/plugins/ueberwachung/migrations/004_antwort.mysql.sql b/Plugins/plugins/ueberwachung/migrations/004_antwort.mysql.sql
new file mode 100644
index 00000000..8e5ac369
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/migrations/004_antwort.mysql.sql
@@ -0,0 +1 @@
+ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN last_status INT NULL, ADD COLUMN last_title VARCHAR(150) NULL
\ No newline at end of file
diff --git a/Plugins/plugins/ueberwachung/migrations/004_antwort.sqlite.sql b/Plugins/plugins/ueberwachung/migrations/004_antwort.sqlite.sql
new file mode 100644
index 00000000..9727cb6c
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/migrations/004_antwort.sqlite.sql
@@ -0,0 +1,2 @@
+ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN last_status INTEGER NULL;
+ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN last_title TEXT NULL
\ No newline at end of file
diff --git a/Plugins/plugins/ueberwachung/pages/ueberwachung.php b/Plugins/plugins/ueberwachung/pages/ueberwachung.php
new file mode 100644
index 00000000..56d9e687
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/pages/ueberwachung.php
@@ -0,0 +1,182 @@
+ÜberwachungDiese Seite ist nur für Administratoren zugänglich.
'; return; }
+$message = null;
+$error = null;
+$edit = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+ if ($action === 'save') {
+ $id = (int) ($_POST['id'] ?? 0);
+ $r = ueberwachung_save($pdo, $_POST + ['is_active' => !empty($_POST['is_active']) ? 1 : 0, 'verify_tls' => !empty($_POST['verify_tls']) ? 1 : 0], $id);
+ if ($r['ok']) { $message = $id ? 'Gespeichert.' : 'Das Ziel wird jetzt überwacht.'; if (!$id) ueberwachung_check($pdo, $r['id']); }
+ else { $error = $r['error']; $edit = $_POST; }
+ } elseif ($action === 'self') {
+ $r = ueberwachung_save($pdo, ['name' => 'Diese DeskHub-Seite', 'type' => 'http', 'target' => rtrim(base_url(), '/') . '/', 'interval_min' => 5]);
+ if ($r['ok']) { ueberwachung_check($pdo, $r['id']); $message = 'Diese Seite wird jetzt überwacht.'; } else $error = $r['error'];
+ } elseif ($action === 'test_ticket') {
+ $tid = ueberwachung_open_ticket($pdo, 'Test: Überwachung', 'Dies ist ein Testticket der Überwachung. Es kann gelöscht oder geschlossen werden.');
+ if ($tid) { $tn = $pdo->query('SELECT ticket_number FROM tickets WHERE id = ' . (int) $tid)->fetchColumn(); $message = 'Testticket #' . $tn . ' wurde angelegt.'; }
+ else $error = 'Das Testticket konnte nicht angelegt werden: ' . (plugin_setting(UEBERWACHUNG, 'ticket_error', '') ?: 'Tickets sind ausgeschaltet.');
+ } elseif ($action === 'check') {
+ $message = ueberwachung_check($pdo, (int) ($_POST['id'] ?? 0)) ? 'Geprüft.' : null;
+ if ($message === null) $error = 'Ziel nicht gefunden.';
+ } elseif ($action === 'toggle') {
+ $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET is_active = 1 - is_active WHERE id = ?')->execute([(int) ($_POST['id'] ?? 0)]);
+ $message = 'Gespeichert.';
+ } elseif ($action === 'delete') {
+ ueberwachung_delete($pdo, (int) ($_POST['id'] ?? 0));
+ $message = 'Das Ziel wurde gelöscht (bereits angelegte Tickets bleiben).';
+ } elseif ($action === 'settings') {
+ plugin_set_setting(UEBERWACHUNG, 'threshold', (string) max(1, min(10, (int) ($_POST['threshold'] ?? 2))));
+ plugin_set_setting(UEBERWACHUNG, 'department_id', (string) (int) ($_POST['department_id'] ?? 0));
+ plugin_set_setting(UEBERWACHUNG, 'priority_id', (string) (int) ($_POST['priority_id'] ?? 0));
+ foreach (['create_tickets', 'auto_resolve', 'mail'] as $k) plugin_set_setting(UEBERWACHUNG, $k, !empty($_POST[$k]) ? '1' : '0');
+ $mailTo = trim((string) ($_POST['alert_email'] ?? ''));
+ $ok = true;
+ foreach (preg_split('/[,;\s]+/', $mailTo, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $a) if (!filter_var($a, FILTER_VALIDATE_EMAIL)) { $ok = false; $error = 'Ungültige Adresse: ' . $a; }
+ if ($ok) { plugin_set_setting(UEBERWACHUNG, 'alert_email', $mailTo); $message = 'Einstellungen gespeichert.'; }
+ }
+}
+if (isset($_GET['edit'])) {
+ $s = $pdo->prepare('SELECT * FROM plugin_ueberwachung_monitors WHERE id = ?');
+ $s->execute([(int) $_GET['edit']]);
+ $edit = $s->fetch() ?: null;
+}
+
+$cfg = ueberwachung_config();
+$monitors = $pdo->query('SELECT * FROM plugin_ueberwachung_monitors ORDER BY name')->fetchAll();
+$events = $pdo->query('SELECT e.*, m.name FROM plugin_ueberwachung_events e LEFT JOIN plugin_ueberwachung_monitors m ON m.id = e.monitor_id ORDER BY e.id DESC LIMIT 40')->fetchAll();
+$depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
+$prios = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
+$types = ueberwachung_types();
+$down = count(array_filter($monitors, fn($m) => $m['state'] === 'down'));
+$badge = ['up' => ['In Ordnung', '#157a45', '#e6f7ee'], 'down' => ['Ausfall', '#b42318', '#fde8e8'], 'unknown' => ['Noch nicht geprüft', '#5b6b77', '#eef1f4']];
+$f = fn(string $k, $d = '') => $edit[$k] ?? $d;
+?>
+Website- und Server-Überwachung
+= e($message) ?>
+= e($error) ?>
+
+
+Achtung: Die Zertifikatsprüfung scheitert bei allen HTTPS-Zielen, und auf diesem Server findet PHP keine Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle). Vermutlich meldet die Prüfung deshalb bei jeder HTTPS-Adresse „Zertifikat ungültig“, auch wenn die Seite in Ordnung ist. Stellen Sie in der PHP-Konfiguration curl.cainfo und openssl.cafile auf eine CA-Datei (zum Beispiel /etc/ssl/certs/ca-certificates.crt) oder schalten Sie bei den Zielen „Zertifikat prüfen“ aus.
+
+
+
+
+ Hinweis zum Anlegen von Tickets: = e($ticketError) ?>
+
+
+
+
+
Zustand = $monitors ? '' : '' ?>
+
+
Noch nichts eingetragen. Unten ein Ziel hinzufügen oder gleich diese Seite überwachen:
+
+
+
= $down ? '' . $down . ' Ausfall' . ($down > 1 ? 'e' : '') . ' · ' : 'Alles in Ordnung · ' ?>= count($monitors) ?> Ziele, geprüft vom Hintergrunddienst von DeskHub (im jeweils eingestellten Abstand).
+
+ | Name | Ziel | Zustand | Zertifikat | Zuletzt geprüft | |
+
+
+
+ | = e($m['name']) ?> = e($types[$m['type']] ?? $m['type']) ?>= $m['is_active'] ? '' : ' · pausiert' ?> |
+ = e($m['target']) ?> Antwort: = $m['last_status'] ? 'HTTP ' . (int) $m['last_status'] : '' ?>= !empty($m['last_title']) ? ' · Titel „' . e(mb_strimwidth((string) $m['last_title'], 0, 60, '…')) . '“' : '' ?> |
+ = e($b[0]) ?>
+ = e((string) $m['last_error']) ?>
+ 0): ?>Fehlschlag = (int) $m['fails'] ?> von = (int) $cfg['threshold'] ?> (Ausfall und Ticket ab Fehlschlag = (int) $cfg['threshold'] ?>): = e((string) $m['last_error']) ?>
+ nächste Prüfung ca. = e(date('H:i', max($next, time()))) ?> |
+ = $days === null ? '–' : ($days < 0 ? 'abgelaufen' : ($days <= (int) $m['cert_warn_days'] ? '' . $days . ' Tage' : $days . ' Tage')) ?> |
+ = $m['last_check_at'] ? e(format_dt((string) $m['last_check_at'])) . ($m['last_ms'] !== null ? ' · ' . (int) $m['last_ms'] . ' ms' : '') : '–' ?> |
+
+
+ |
+
+
+
+
+
+
+
+ 900);
+?>
+
+
Hintergrunddienst
+
+ Noch kein Lauf. Die Prüfungen starten, sobald DeskHub das nächste Mal aufgerufen wird.
+ Letzter Lauf vor = e(ueberwachung_duration(time() - $lastTick)) ?>. Es hat länger niemand DeskHub aufgerufen, oder der automatische Hintergrund-Betrieb ist ausgeschaltet (Einstellungen → Cron-Jobs).
+ ✓ Läuft. Letzter Lauf: = e(format_dt(date('Y-m-d H:i:s', $lastTick))) ?>.
+
+
+
+ Echter Server-Cronjob:
+ bisher kein Lauf (Einstellungen → Cron-Jobs → „Neu installieren / aktualisieren“, mit den aktuellen DeskHub-Dateien; bei älteren Dateien siehe unten)
+ 900): ?>letzter Lauf vor = e(ueberwachung_duration($cliAge)) ?> (= e(format_dt($cliLast)) ?>). Er sollte jede Minute laufen: Crontab und cron/run_plugins.log prüfen.
+ ✓ läuft (letzter Lauf = e(format_dt($cliLast)) ?>)
+
+
Die Prüfungen laufen mit den Hintergrundaufgaben von DeskHub. Mit dem aktuellen DeskHub-Update gehören sie zum echten Server-Cronjob: Einstellungen → Cron-Jobs → „Neu installieren / aktualisieren“ trägt die Zeile ein (jede Minute), dann wird auch nachts ohne Besucher in dem Abstand geprüft, den Sie je Ziel einstellen (mindestens 1 Minute). Bei älteren Dateien oder ohne Server-Cronjob läuft die Prüfung nur, solange jemand die Seite oder das Backend aufruft. Dann hilft ein eigener Cronjob für dieses Plugin (zum Beispiel Plesk → Geplante Aufgaben, alle 5 Minuten):
+
php = e(str_replace('\\', '/', plugins_dir()) . '/' . UEBERWACHUNG . '/cron.php') ?>
+
Wichtig: Fällt der Server aus, auf dem DeskHub selbst läuft, kann DeskHub das nicht melden. Die eigene Erreichbarkeit überwacht am besten ein Dienst auf einem anderen Server.
+
+
+
+
+
+
+
Ereignisse
+
Noch keine Ereignisse.
+
| Wann | Ziel | Ereignis |
+
+ | = e(format_dt((string) $ev['created_at'])) ?> | = e((string) $ev['name']) ?> | = e(['down' => 'Ausfall', 'up' => 'Entwarnung', 'cert' => 'Zertifikat', 'cert_ok' => 'Zertifikat erneuert'][$ev['kind']] ?? $ev['kind']) ?>: = e((string) $ev['message']) ?> |
+
+
+
+
diff --git a/Plugins/plugins/ueberwachung/plugin.json b/Plugins/plugins/ueberwachung/plugin.json
new file mode 100644
index 00000000..e5a13774
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/plugin.json
@@ -0,0 +1,9 @@
+{
+ "slug": "ueberwachung",
+ "name": "Website- und Server-Überwachung",
+ "version": "1.1.1",
+ "description": "Prüft regelmäßig Webseiten, Ports und Zertifikate. Fällt etwas aus oder läuft ein Zertifikat bald ab, legt das Plugin automatisch ein Ticket an, benachrichtigt den Administrator und gibt nach der Behebung Entwarnung (Ticket wird auf „Gelöst“ gesetzt). Nur für Administratoren.",
+ "author": "DeskHub",
+ "requires": "1.0.0",
+ "permissions": ["hooks.cron", "ui.menu", "db.migrations"]
+}
diff --git a/Plugins/plugins/ueberwachung/plugin.php b/Plugins/plugins/ueberwachung/plugin.php
new file mode 100644
index 00000000..46750d0c
--- /dev/null
+++ b/Plugins/plugins/ueberwachung/plugin.php
@@ -0,0 +1,15 @@
+ 'Geplant', 'active' => 'Läuft', 'done' => 'Beendet', 'cancelled' => 'Abgebrochen', 'missed' => 'Nicht angetreten'];
+}
+
+function vertretung_staff(PDO $pdo, int $id): ?array
+{
+ $s = $pdo->prepare('SELECT id, name, email, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
+ $s->execute([$id]);
+ return $s->fetch() ?: null;
+}
+
+function vertretung_name(PDO $pdo, int $id): string
+{
+ return (string) (vertretung_staff($pdo, $id)['name'] ?? ('#' . $id));
+}
+
+function vertretung_can_manage(array $actor, int $staffId): bool
+{
+ return ($actor['role'] ?? '') === 'admin' || (int) ($actor['id'] ?? 0) === $staffId;
+}
+
+function vertretung_mail(PDO $pdo, int $staffId, string $subject, string $html): void
+{
+ $st = vertretung_staff($pdo, $staffId);
+ if (!$st || !(int) $st['is_active'] || trim((string) $st['email']) === '') return;
+ try {
+ send_mail((string) $st['email'], $subject, $html);
+ } catch (Throwable $e) {
+ error_log('vertretung mail: ' . $e->getMessage());
+ }
+}
+
+/**
+ * Wer arbeitet an Stelle von $staffId? Folgt Vertretungsketten (höchstens drei Stufen, ohne Schleifen). null, wenn die Person
+ * anwesend ist oder keine nutzbare Vertretung hat.
+ */
+function vertretung_target(PDO $pdo, int $staffId): ?int
+{
+ $row = vertretung_staff($pdo, $staffId);
+ if (!$row || !(int) $row['is_on_vacation']) return null;
+ $seen = [$staffId];
+ for ($i = 0; $i < 3; $i++) {
+ $sub = (int) ($row['vacation_substitute_id'] ?? 0);
+ if ($sub <= 0 || in_array($sub, $seen, true)) return null;
+ $row = vertretung_staff($pdo, $sub);
+ if (!$row || !(int) $row['is_active']) return null;
+ if (!(int) $row['is_on_vacation']) return $sub;
+ $seen[] = $sub;
+ }
+ return null;
+}
+
+/** Gibt ein Ticket an $to weiter und merkt es für die Rückgabe. */
+function vertretung_move(PDO $pdo, int $ticketId, int $from, int $to, int $absenceId): void
+{
+ $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$to, $ticketId]);
+ log_activity($pdo, $ticketId, null, 'An die Vertretung ' . vertretung_name($pdo, $to) . ' weitergegeben (' . vertretung_name($pdo, $from) . ' ist abwesend)');
+ $pdo->prepare('INSERT INTO plugin_vertretung_moves (absence_id, ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?,?)')
+ ->execute([$absenceId, $ticketId, $from, $to, db_now()]);
+}
+
+/** Hook: Ticket gehört einer abwesenden Person → an die Vertretung. */
+function vertretung_redirect(PDO $pdo, int $ticketId): void
+{
+ $t = $pdo->prepare('SELECT assigned_staff_id, status, is_spam FROM tickets WHERE id = ?');
+ $t->execute([$ticketId]);
+ $ticket = $t->fetch();
+ if (!$ticket || !$ticket['assigned_staff_id'] || !empty($ticket['is_spam']) || in_array($ticket['status'], ['resolved', 'closed'], true)) return;
+ $from = (int) $ticket['assigned_staff_id'];
+ $to = vertretung_target($pdo, $from);
+ if ($to === null || $to === $from) return;
+ $a = $pdo->prepare("SELECT id FROM plugin_vertretung_absences WHERE staff_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1");
+ $a->execute([$from]);
+ vertretung_move($pdo, $ticketId, $from, $to, (int) $a->fetchColumn());
+}
+
+/** Prüft Eingaben für eine neue Abwesenheit. Liefert eine Fehlermeldung oder null. */
+function vertretung_validate(PDO $pdo, int $staffId, int $subId, string $start, string $end): ?string
+{
+ $okDate = static fn(string $d) => preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $d, $m) === 1 && checkdate((int) $m[2], (int) $m[3], (int) $m[1]);
+ if (!$okDate($start) || !$okDate($end)) return 'Bitte Beginn und Ende als Datum angeben.';
+ if ($end < $start) return 'Das Ende darf nicht vor dem Beginn liegen.';
+ if ($end < vertretung_today()) return 'Der Zeitraum liegt schon in der Vergangenheit.';
+ $st = vertretung_staff($pdo, $staffId);
+ if (!$st || !(int) $st['is_active']) return 'Dieser Mitarbeiter ist nicht aktiv.';
+ $sub = vertretung_staff($pdo, $subId);
+ if (!$sub || !(int) $sub['is_active']) return 'Bitte eine aktive Vertretung wählen.';
+ if ($subId === $staffId) return 'Die Vertretung muss eine andere Person sein.';
+ $o = $pdo->prepare("SELECT COUNT(*) FROM plugin_vertretung_absences WHERE staff_id = ? AND status IN ('planned','active') AND start_date <= ? AND end_date >= ?");
+ $o->execute([$staffId, $end, $start]);
+ if ((int) $o->fetchColumn() > 0) return 'In diesem Zeitraum ist für diese Person schon eine Abwesenheit eingetragen.';
+ return null;
+}
+
+/** @return array{ok:bool,error?:string,id?:int} */
+function vertretung_create(PDO $pdo, array $actor, int $staffId, int $subId, string $start, string $end, string $note): array
+{
+ if (!vertretung_can_manage($actor, $staffId)) return ['ok' => false, 'error' => 'Sie dürfen nur Ihre eigene Abwesenheit eintragen.'];
+ $err = vertretung_validate($pdo, $staffId, $subId, $start, $end);
+ if ($err !== null) return ['ok' => false, 'error' => $err];
+ $pdo->prepare("INSERT INTO plugin_vertretung_absences (staff_id, substitute_id, start_date, end_date, note, status, created_by, created_at) VALUES (?,?,?,?,?,'planned',?,?)")
+ ->execute([$staffId, $subId, $start, $end, mb_substr(trim($note), 0, 250), (int) ($actor['id'] ?? 0) ?: null, db_now()]);
+ $id = (int) $pdo->lastInsertId();
+ if ($start <= vertretung_today()) vertretung_tick($pdo); // beginnt sofort
+ return ['ok' => true, 'id' => $id];
+}
+
+function vertretung_cancel(PDO $pdo, array $actor, int $id): bool
+{
+ $s = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE id = ? AND status IN ('planned','active')");
+ $s->execute([$id]);
+ $abs = $s->fetch();
+ if (!$abs || !vertretung_can_manage($actor, (int) $abs['staff_id'])) return false;
+ if ($abs['status'] === 'active') vertretung_deactivate($pdo, $abs, 'cancelled');
+ else $pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'cancelled' WHERE id = ?")->execute([$id]);
+ return true;
+}
+
+/** Beginn: Urlaubsstatus setzen und offene Tickets an die Vertretung verteilen. Liefert die Zahl der weitergegebenen Tickets. */
+function vertretung_activate(PDO $pdo, array $abs): int
+{
+ $staffId = (int) $abs['staff_id'];
+ $pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'active' WHERE id = ?")->execute([$abs['id']]);
+ $pdo->prepare('UPDATE staff SET is_on_vacation = 1, vacation_substitute_id = ? WHERE id = ?')->execute([$abs['substitute_id'], $staffId]);
+ $to = vertretung_target($pdo, $staffId);
+ if ($to === null) return 0;
+ $t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id");
+ $t->execute([$staffId]);
+ $tickets = $t->fetchAll();
+ foreach ($tickets as $row) vertretung_move($pdo, (int) $row['id'], $staffId, $to, (int) $abs['id']);
+ $name = vertretung_name($pdo, $staffId);
+ $list = '';
+ foreach (array_slice($tickets, 0, 15) as $row) $list .= '#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '';
+ vertretung_mail($pdo, $to, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', 'Sie vertreten ' . e($name) . ' vom ' . e(vertretung_date((string) $abs['start_date'])) . ' bis ' . e(vertretung_date((string) $abs['end_date'])) . '.
'
+ . '' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen, neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen.
' . ($list ? '' : ''));
+ return count($tickets);
+}
+
+/** Ende oder Abbruch: Urlaubsstatus zurücknehmen und noch offene Tickets zurückgeben. Liefert die Zahl der zurückgegebenen Tickets. */
+function vertretung_deactivate(PDO $pdo, array $abs, string $newStatus): int
+{
+ $staffId = (int) $abs['staff_id'];
+ $pdo->prepare("UPDATE plugin_vertretung_absences SET status = ? WHERE id = ?")->execute([$newStatus, $abs['id']]);
+ // nur zurücknehmen, was dieses Plugin gesetzt hat (hat jemand die Vertretung im Profil geändert, bleibt es so)
+ $pdo->prepare('UPDATE staff SET is_on_vacation = 0, vacation_substitute_id = NULL WHERE id = ? AND is_on_vacation = 1 AND vacation_substitute_id = ?')->execute([$staffId, $abs['substitute_id']]);
+ $m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id, t.assigned_staff_id, t.status, t.ticket_number, t.subject FROM plugin_vertretung_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.absence_id = ? AND m.returned_at IS NULL ORDER BY m.id');
+ $m->execute([$abs['id']]);
+ $back = [];
+ foreach ($m->fetchAll() as $row) {
+ $stillThere = (int) $row['assigned_staff_id'] === (int) $row['to_staff_id'] && !in_array($row['status'], ['resolved', 'closed'], true);
+ if ($stillThere) {
+ $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$staffId, $row['ticket_id']]);
+ log_activity($pdo, (int) $row['ticket_id'], null, 'Zurück an ' . vertretung_name($pdo, $staffId) . ' (Abwesenheit beendet)');
+ $back[] = $row;
+ }
+ $pdo->prepare('UPDATE plugin_vertretung_moves SET returned_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
+ }
+ if ($back) {
+ $list = '';
+ foreach (array_slice($back, 0, 15) as $row) $list .= '#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '';
+ vertretung_mail($pdo, $staffId, 'Willkommen zurück: ' . count($back) . ' Tickets', 'Ihre Abwesenheit ist beendet. Diese Tickets sind wieder bei Ihnen:
');
+ }
+ return count($back);
+}
+
+/** Cron: abgelaufene Abwesenheiten beenden, fällige beginnen. */
+function vertretung_tick(PDO $pdo): void
+{
+ $today = vertretung_today();
+ $q = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE status = 'active' AND end_date < ?");
+ $q->execute([$today]);
+ foreach ($q->fetchAll() as $abs) vertretung_deactivate($pdo, $abs, 'done');
+ $pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'missed' WHERE status = 'planned' AND end_date < ?")->execute([$today]);
+ $q = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE status = 'planned' AND start_date <= ? AND end_date >= ? ORDER BY start_date, id");
+ $q->execute([$today, $today]);
+ foreach ($q->fetchAll() as $abs) vertretung_activate($pdo, $abs);
+ vertretung_rebalance($pdo);
+}
+
+/**
+ * Ist die erste Vertretung zurück (oder hat sich die Kette geändert), wandern die für die Abwesende weitergegebenen Tickets,
+ * die noch unberührt bei der bisherigen Vertretung liegen, zur jetzt zuständigen Person.
+ */
+function vertretung_rebalance(PDO $pdo): void
+{
+ foreach ($pdo->query("SELECT id, staff_id FROM plugin_vertretung_absences WHERE status = 'active'")->fetchAll() as $abs) {
+ $target = vertretung_target($pdo, (int) $abs['staff_id']);
+ if ($target === null) continue;
+ $m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id FROM plugin_vertretung_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.absence_id = ? AND m.returned_at IS NULL AND m.to_staff_id <> ? AND t.assigned_staff_id = m.to_staff_id');
+ $m->execute([$abs['id'], $target]);
+ foreach ($m->fetchAll() as $row) {
+ $t = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
+ $t->execute([$row['ticket_id']]);
+ if (in_array($t->fetchColumn(), ['resolved', 'closed'], true)) continue;
+ $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$target, $row['ticket_id']]);
+ $pdo->prepare('UPDATE plugin_vertretung_moves SET to_staff_id = ? WHERE id = ?')->execute([$target, $row['id']]);
+ log_activity($pdo, (int) $row['ticket_id'], null, 'An die Vertretung ' . vertretung_name($pdo, $target) . ' weitergegeben (Vertretung ist zurück)');
+ }
+ }
+}
+
+function vertretung_date(string $ymd): string
+{
+ $t = strtotime($ymd . ' 12:00:00');
+ return $t ? date('d.m.Y', $t) : $ymd;
+}
diff --git a/Plugins/plugins/vertretung/migrations/001_abwesenheit.mysql.sql b/Plugins/plugins/vertretung/migrations/001_abwesenheit.mysql.sql
new file mode 100644
index 00000000..4a974c48
--- /dev/null
+++ b/Plugins/plugins/vertretung/migrations/001_abwesenheit.mysql.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS plugin_vertretung_absences (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ staff_id INT NOT NULL,
+ substitute_id INT NOT NULL,
+ start_date DATE NOT NULL,
+ end_date DATE NOT NULL,
+ note VARCHAR(255) NOT NULL DEFAULT '',
+ status VARCHAR(12) NOT NULL DEFAULT 'planned',
+ created_by INT NULL,
+ created_at DATETIME NOT NULL,
+ KEY idx_plugin_vertretung_status (status, start_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE IF NOT EXISTS plugin_vertretung_moves (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ absence_id INT NOT NULL,
+ ticket_id INT NOT NULL,
+ from_staff_id INT NOT NULL,
+ to_staff_id INT NOT NULL,
+ created_at DATETIME NOT NULL,
+ returned_at DATETIME NULL,
+ KEY idx_plugin_vertretung_moves_absence (absence_id, returned_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
diff --git a/Plugins/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql b/Plugins/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql
new file mode 100644
index 00000000..d6a172a6
--- /dev/null
+++ b/Plugins/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS plugin_vertretung_absences (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ staff_id INTEGER NOT NULL,
+ substitute_id INTEGER NOT NULL,
+ start_date TEXT NOT NULL,
+ end_date TEXT NOT NULL,
+ note TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'planned',
+ created_by INTEGER NULL,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_status ON plugin_vertretung_absences (status, start_date);
+CREATE TABLE IF NOT EXISTS plugin_vertretung_moves (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ absence_id INTEGER NOT NULL,
+ ticket_id INTEGER NOT NULL,
+ from_staff_id INTEGER NOT NULL,
+ to_staff_id INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ returned_at TEXT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_moves_absence ON plugin_vertretung_moves (absence_id, returned_at)
diff --git a/Plugins/plugins/vertretung/pages/vertretung.php b/Plugins/plugins/vertretung/pages/vertretung.php
new file mode 100644
index 00000000..829ac4fc
--- /dev/null
+++ b/Plugins/plugins/vertretung/pages/vertretung.php
@@ -0,0 +1,86 @@
+query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
+$labels = vertretung_status_labels();
+$names = [];
+foreach ($people as $p) $names[(int) $p['id']] = $p['name'];
+$name = fn($id) => e($names[(int) $id] ?? vertretung_name($pdo, (int) $id));
+$current = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id AND m.returned_at IS NULL) AS holding FROM plugin_vertretung_absences a WHERE a.status IN ('planned','active') ORDER BY a.start_date, a.id")->fetchAll();
+$past = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved FROM plugin_vertretung_absences a WHERE a.status NOT IN ('planned','active') ORDER BY a.id DESC LIMIT 20")->fetchAll();
+?>
+Abwesenheit und Vertretung
+= e($message) ?>
+= e($error) ?>
+
+
+
+
+
Laufend und geplant
+
Keine Abwesenheiten eingetragen.
+
+ | Wer | Vertretung | Zeitraum | Stand | Tickets | |
+
+
+
+ | = $name($a['staff_id']) ?> = e((string) $a['note']) ?> |
+ = $name($a['substitute_id']) ?> |
+ = e(vertretung_date((string) $a['start_date'])) ?> – = e(vertretung_date((string) $a['end_date'])) ?> |
+ = e($labels[$a['status']] ?? $a['status']) ?> |
+ = $a['status'] === 'active' ? (int) $a['holding'] . ' bei der Vertretung' : '–' ?> |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
Frühere Abwesenheiten
+
+ | Wer | Vertretung | Zeitraum | Ergebnis | Weitergegeben |
+
+
+ | = $name($a['staff_id']) ?> | = $name($a['substitute_id']) ?> | = e(vertretung_date((string) $a['start_date'])) ?> – = e(vertretung_date((string) $a['end_date'])) ?> | = e($labels[$a['status']] ?? $a['status']) ?> | = (int) $a['moved'] ?> Tickets |
+
+
+
+
+
diff --git a/Plugins/plugins/vertretung/plugin.json b/Plugins/plugins/vertretung/plugin.json
new file mode 100644
index 00000000..abc968f6
--- /dev/null
+++ b/Plugins/plugins/vertretung/plugin.json
@@ -0,0 +1,9 @@
+{
+ "slug": "vertretung",
+ "name": "Urlaub und Vertretung",
+ "version": "1.0.0",
+ "description": "Mitarbeiter tragen Abwesenheiten mit Vertretung ein. Zum Beginn werden ihre offenen Tickets an die Vertretung verteilt, neue Tickets gehen automatisch dorthin, und am Ende bekommt der Mitarbeiter seine noch offenen Tickets zurück. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf.",
+ "author": "DeskHub",
+ "requires": "1.0.0",
+ "permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
+}
diff --git a/Plugins/plugins/vertretung/plugin.php b/Plugins/plugins/vertretung/plugin.php
new file mode 100644
index 00000000..0e5a4f87
--- /dev/null
+++ b/Plugins/plugins/vertretung/plugin.php
@@ -0,0 +1,16 @@
+ */
+function virenscan_name_findings(string $name): array
+{
+ $out = [];
+ if (preg_match('/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u', $name)) $out[] = ['exec', 'Verschleierter Dateiname (Zeichen zur Umkehr der Schreibrichtung)'];
+ $ext = virenscan_file_ext($name);
+ if (in_array($ext, VIRENSCAN_BAD_EXT, true)) $out[] = ['exec', 'Gefährlicher Dateityp (.' . $ext . ')'];
+ elseif (preg_match('/\.(' . implode('|', array_map('preg_quote', VIRENSCAN_BAD_EXT)) . ')\s*\.[a-z0-9]{2,5}$/i', $name, $m)) $out[] = ['exec', 'Doppelte Endung (.' . strtolower($m[1]) . ' versteckt)'];
+ return $out;
+}
+
+/**
+ * Untersucht eine Datei. @return list Funde als [Kategorie, Text]. Kategorien: exec (Programme, Skripte, Tricks),
+ * macro (Office-Makros), script (Skripte in HTML/SVG/RTF/PDF), archive (auffällige Archive), encrypted (passwortgeschützt), unscannable (nicht prüfbare Archive), eicar.
+ */
+function virenscan_analyze(string $path, string $name, int $depth = 0): array
+{
+ $f = [];
+ $size = (int) @filesize($path);
+ $head = (string) @file_get_contents($path, false, null, 0, 4096);
+ if ($head === '' && $size === 0) return $f;
+ $ext = virenscan_file_ext($name);
+
+ if (str_contains((string) @file_get_contents($path, false, null, 0, 2 * 1024 * 1024), virenscan_eicar())) return [['eicar', 'Eicar-Test-Signature']];
+ foreach (virenscan_name_findings($name) as $x) $f[] = $x;
+ if (($kind = virenscan_exec_magic($head)) !== null) $f[] = ['exec', $kind . ($ext !== '' && !in_array($ext, VIRENSCAN_BAD_EXT, true) ? ', getarnt als .' . $ext : '')];
+ if (in_array($ext, VIRENSCAN_MACRO_EXT, true)) $f[] = ['macro', 'Office-Datei mit Makros (.' . $ext . ')'];
+ if (in_array($ext, VIRENSCAN_OTHER_ARCHIVES, true)) $f[] = ['unscannable', 'Archiv, das nicht geöffnet werden kann (.' . $ext . ')'];
+
+ // ZIP (auch Office-Dateien): Inhalt prüfen
+ if (str_starts_with($head, "PK\x03\x04") && class_exists('ZipArchive')) {
+ $za = new ZipArchive();
+ if ($za->open($path) === true) {
+ $n = $za->numFiles;
+ if ($n > 2000) $f[] = ['archive', 'Archiv mit sehr vielen Dateien (' . $n . ')'];
+ $total = 0; $encrypted = false; $macro = false; $bad = [];
+ for ($i = 0; $i < min($n, 2000); $i++) {
+ $st = $za->statIndex($i);
+ if (!$st) continue;
+ $total += (int) $st['size'];
+ $en = (string) $st['name'];
+ if (!empty($st['encryption_method'])) { $encrypted = true; continue; }
+ if (str_ends_with(strtolower($en), 'vbaproject.bin')) $macro = true;
+ foreach (virenscan_name_findings(basename($en)) as $x) { $bad[] = basename($en); break; }
+ if ($i < 300 && (int) $st['size'] > 0 && virenscan_exec_magic((string) $za->getFromIndex($i, 8)) !== null) $bad[] = basename($en);
+ }
+ if ($total > 1024 * 1024 * 1024 || ($size > 0 && $total / max(1, $size) > 2000 && $total > 100 * 1024 * 1024)) $f[] = ['archive', 'Archiv entpackt sich auf ein Vielfaches (Zip-Bombe)'];
+ if ($macro) $f[] = ['macro', 'Office-Datei mit Makros (VBA)'];
+ if ($bad) $f[] = ['exec', 'Archiv enthält ' . implode(', ', array_slice(array_values(array_unique($bad)), 0, 3))];
+ if ($encrypted) $f[] = ['encrypted', 'Passwortgeschütztes Archiv (Inhalt nicht prüfbar)'];
+ $za->close();
+ } elseif (!in_array($ext, ['docx', 'xlsx', 'pptx', 'odt', 'ods', 'jar', 'apk'], true)) {
+ $f[] = ['unscannable', 'Archiv lässt sich nicht öffnen'];
+ }
+ }
+
+ // Altes Office-Format (OLE): Makros erkennen
+ if (str_starts_with($head, "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1")) {
+ $body = (string) @file_get_contents($path, false, null, 0, 4 * 1024 * 1024);
+ if (str_contains($body, '_VBA_PROJECT') || str_contains($body, "_\0V\0B\0A\0_\0P\0R\0O\0J\0E\0C\0T") || str_contains($body, "M\0a\0c\0r\0o\0s\0")) $f[] = ['macro', 'Office-Datei mit Makros (VBA)'];
+ }
+
+ // PDF: startende Programme oder automatisches JavaScript
+ if (str_contains(substr($head, 0, 1024), '%PDF')) {
+ $body = (string) @file_get_contents($path, false, null, 0, 4 * 1024 * 1024);
+ if (preg_match('/\/Launch\b/', $body)) $f[] = ['script', 'PDF startet Programme (/Launch)'];
+ if (preg_match('/\/(JavaScript|JS)\b/', $body) && preg_match('/\/(OpenAction|AA)\b/', $body)) $f[] = ['script', 'PDF mit automatisch startendem JavaScript'];
+ if (preg_match('/\/EmbeddedFile\b/', $body) && preg_match('/\.(exe|scr|bat|cmd|vbs|js|jar|ps1|msi)\b/i', $body)) $f[] = ['exec', 'PDF mit eingebetteter ausführbarer Datei'];
+ }
+
+ // Webseiten und Bilder mit Skriptcode
+ if (in_array($ext, ['html', 'htm', 'xhtml', 'shtml', 'svg', 'mht', 'mhtml', 'xml'], true)) {
+ $body = (string) @file_get_contents($path, false, null, 0, 1024 * 1024);
+ if (preg_match('/