Upload via GUI (94 Dateien)

This commit is contained in:
2026-10-24 20:15:16 +00:00
parent cbf6139e74
commit e7a6ba0b86
34 changed files with 2102 additions and 0 deletions
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<?php
// Spam-Filter-Plugin: bewertet neue Tickets sofort nach dem Anlegen.
require_once __DIR__ . '/lib.php';
plugin_add_hook('ticket.created', function (array $payload) {
if (!empty($payload['ticketId'])) spamfilter_handle_ticket(get_db(), (int) $payload['ticketId']);
});
// Bewertung in der Seitenleiste der Ticketansicht
plugin_add_filter('ticket.view.sidebar', function ($html, array $ctx) {
return $html . spamfilter_sidebar_html(get_db(), (int) ($ctx['ticketId'] ?? 0));
});
// Seite: wer Tickets schließen darf, darf Entscheidungen korrigieren; Einstellungen nur Administratoren
plugin_register_menu('Spamfilter', 'spamfilter', 'close_ticket');
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<?php
/**
* Optional: echter Server-Cronjob für die Überwachung, unabhängig vom Besucherverkehr.
*
* Ohne diese Datei prüft das Plugin über den eingebauten Hintergrund-Betrieb von DeskHub (höchstens alle 5 Minuten, und nur solange jemand
* die Seite oder das Backend aufruft). Wer auch nachts ohne Besucher prüfen will, trägt im Hosting-Panel (zum Beispiel Plesk → Geplante Aufgaben)
* alle 5 Minuten diesen Befehl ein:
*
* php /pfad/zu/deskhub/plugins/ueberwachung/cron.php
*
* Die Adresse im Browser aufzurufen ist nicht möglich (nur Kommandozeile).
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Nur über die Kommandozeile.');
}
require_once getenv('DESKHUB_BOOTSTRAP') ?: dirname(__DIR__, 2) . '/includes/bootstrap.php';
if (!function_exists('ueberwachung_tick')) {
fwrite(STDERR, "Das Plugin „Überwachung“ ist nicht aktiv.\n");
exit(1);
}
$n = ueberwachung_tick(get_db());
echo $n . " Ziel(e) geprüft.\n";
+432
View File
@@ -0,0 +1,432 @@
<?php
// Website- und Server-Überwachung. Alle Funktionen beginnen mit ueberwachung_.
const UEBERWACHUNG = 'ueberwachung';
require_once __DIR__ . '/meldung.php';
/**
* Selbstheilung: Fehlt nach einem Update eine Datenbank-Änderung des Plugins (ältere DeskHub-Dateien spielen sie nicht von selbst ein),
* wird sie hier nachgeholt, statt dass das Plugin mit Datenbankfehlern abgeschaltet wird.
*/
function ueberwachung_ensure_schema(bool $force = false): void
{
static $done = false;
if ($done && !$force) return;
$done = true;
try {
get_db()->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', '<p><strong>' . e($m['name']) . '</strong> ist wieder erreichbar.</p>' . 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'], '<p>Das Zertifikat von <strong>' . e($m['name']) . '</strong> wurde erneuert und ist gültig bis ' . e(date('d.m.Y', (int) $res['cert_expires'])) . '.</p>');
}
}
}
/** 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) {
}
});
+168
View File
@@ -0,0 +1,168 @@
<?php
// Texte der Überwachung: lesbare Fehler und der Aufbau der Tickets und Mails. Alle Funktionen beginnen mit ueberwachung_.
/** Rohe PHP-Fehlermeldung in verständliches Deutsch übersetzen. Beginnt mit „Nicht erreichbar:“ oder „Zertifikat/TLS-Fehler:“. */
function ueberwachung_describe_error(string $raw): string
{
$r = strtolower($raw);
if (preg_match('/getaddrinfo|php_network_getaddresses|name or service not known|nodename nor servname|temporary failure in name resolution|no address associated|could not resolve host/', $r)) return 'Nicht erreichbar: Der Name der Adresse lässt sich nicht auflösen (DNS)';
if (str_contains($r, 'connection refused')) return 'Nicht erreichbar: Verbindung abgelehnt (der Dienst läuft nicht oder der Port ist gesperrt)';
if (preg_match('/timed out|timeout|zeitüberschreitung/', $r)) return 'Nicht erreichbar: Zeitüberschreitung (keine Antwort vom Server)';
if (preg_match('/no route to host|network is unreachable/', $r)) return 'Nicht erreichbar: Der Server ist aus dem Netz nicht erreichbar';
if (preg_match('/certificate|ssl|tls|handshake/', $r)) {
$why = preg_match('/expired/', $r) ? ' (Zertifikat abgelaufen)' : (preg_match('/verify failed|self.signed|unable to get local issuer|certificate problem|does not match|no alternative certificate subject/', $r) ? ' (Zertifikat ungültig oder nicht vertrauenswürdig)' : '');
return 'Zertifikat/TLS-Fehler' . $why . ': Die gesicherte Verbindung kam nicht zustande';
}
return 'Nicht erreichbar: Die Verbindung kam nicht zustande (Dienst aus, Port zu oder Problem mit der gesicherten Verbindung)';
}
function ueberwachung_host(string $target): string
{
$h = parse_url($target, PHP_URL_HOST);
return (string) ($h ?: preg_replace('/:\d+$/', '', $target));
}
/** Betreff mit Name und Adresse (die Adresse entfällt, wenn der Name sie schon enthält). */
function ueberwachung_subject(string $prefix, array $m): string
{
$host = ueberwachung_host((string) $m['target']);
return $prefix . ': ' . $m['name'] . (stripos((string) $m['name'], $host) === false ? ' (' . $host . ')' : '');
}
function ueberwachung_type_label(string $type): string
{
return ueberwachung_types()[$type] ?? $type;
}
/** Zweispaltige Tabelle: Bezeichnung → Wert (beides wird maskiert). @param list<array{0:string,1:string}> $rows */
function ueberwachung_table(array $rows): string
{
$html = '<table border="0" cellpadding="5" cellspacing="0">';
foreach ($rows as [$k, $v]) $html .= '<tr><td><strong>' . e($k) . '</strong></td><td>' . e($v) . '</td></tr>';
return $html . '</table>';
}
function ueberwachung_dt(?string $dt): string
{
return $dt ? date('d.m.Y H:i:s', (int) strtotime($dt)) : '–';
}
/** Zeilen zu einem Zertifikat. @return list<array{0:string,1:string}> */
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<string> */
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 '<h3>Mögliche Ursachen und nächste Schritte</h3><ul>' . implode('', array_map(fn($h) => '<li>' . e($h) . '</li>', $hints)) . '</ul>';
}
/** 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 '<p><strong>' . e($m['name']) . '</strong> ist nicht erreichbar bzw. fehlerhaft.</p>' . ueberwachung_table($rows)
. ueberwachung_hint_list(ueberwachung_hints($m, (string) $res['error']))
. '<p><em>Diese Meldung stammt von der automatischen Überwachung. Sobald der Dienst wieder erreichbar ist, wird das Ticket automatisch gelöst.</em></p>';
}
/** 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 '<p><strong>' . e($headline) . '</strong></p>' . 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.'])
. '<p><em>Nach der Erneuerung wird dieses Ticket automatisch gelöst.</em></p>';
}
/** 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[^>]*>(.*?)<\/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<array<string,mixed>> $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;
}
@@ -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
@@ -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)
@@ -0,0 +1 @@
ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN first_fail_at DATETIME NULL
@@ -0,0 +1 @@
ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN first_fail_at TEXT NULL
@@ -0,0 +1 @@
ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN verify_tls TINYINT(1) NOT NULL DEFAULT 1
@@ -0,0 +1 @@
ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN verify_tls INTEGER NOT NULL DEFAULT 1
@@ -0,0 +1 @@
ALTER TABLE plugin_ueberwachung_monitors ADD COLUMN last_status INT NULL, ADD COLUMN last_title VARCHAR(150) NULL
@@ -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
@@ -0,0 +1,182 @@
<?php
// Seite des Überwachungs-Plugins. Verfügbar: $staff, $pdo. Nur für Administratoren.
if (($staff['role'] ?? '') !== 'admin') { echo '<h1>Überwachung</h1><div class="flash error">Diese Seite ist nur für Administratoren zugänglich.</div>'; 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;
?>
<h1>Website- und Server-Überwachung</h1>
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
<?php if (ueberwachung_ca_warning($monitors, ueberwachung_ca_bundle_missing())): ?>
<div class="flash error" style="max-width:860px"><div><strong>Achtung:</strong> 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 <em>jeder</em> HTTPS-Adresse „Zertifikat ungültig“, auch wenn die Seite in Ordnung ist. Stellen Sie in der PHP-Konfiguration <code>curl.cainfo</code> und <code>openssl.cafile</code> auf eine CA-Datei (zum Beispiel <code>/etc/ssl/certs/ca-certificates.crt</code>) oder schalten Sie bei den Zielen „Zertifikat prüfen“ aus.</div></div>
<?php endif; ?>
<?php $ticketError = (string) plugin_setting(UEBERWACHUNG, 'ticket_error', ''); ?>
<?php if ($ticketError !== ''): ?>
<div class="flash error" style="max-width:860px"><div>
<strong>Hinweis zum Anlegen von Tickets:</strong> <?= e($ticketError) ?>
<form method="post" style="margin-top:6px"><?= csrf_field() ?><input type="hidden" name="action" value="test_ticket"><button class="btn secondary" type="submit">Testticket anlegen</button></form>
</div></div>
<?php endif; ?>
<div class="card" style="margin-bottom:16px">
<h3>Zustand <?= $monitors ? '' : '' ?></h3>
<?php if (!$monitors): ?>
<p class="muted">Noch nichts eingetragen. Unten ein Ziel hinzufügen oder gleich diese Seite überwachen:</p>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="self"><button class="btn secondary" type="submit">Diese DeskHub-Seite überwachen</button></form>
<?php else: ?>
<p style="font-size:.95rem"><?= $down ? '<strong style="color:#b42318">' . $down . ' Ausfall' . ($down > 1 ? 'e' : '') . '</strong> · ' : '<strong style="color:#157a45">Alles in Ordnung</strong> · ' ?><?= count($monitors) ?> Ziele, geprüft vom Hintergrunddienst von DeskHub (im jeweils eingestellten Abstand).</p>
<table style="width:100%">
<thead><tr><th>Name</th><th>Ziel</th><th>Zustand</th><th>Zertifikat</th><th>Zuletzt geprüft</th><th></th></tr></thead>
<tbody>
<?php foreach ($monitors as $m): $b = $badge[$m['state']] ?? $badge['unknown']; $days = $m['cert_expires'] ? (int) floor((strtotime((string) $m['cert_expires']) - time()) / 86400) : null; ?>
<tr style="<?= $m['is_active'] ? '' : 'opacity:.55' ?>">
<td><strong><?= e($m['name']) ?></strong><div class="muted" style="font-size:.74rem"><?= e($types[$m['type']] ?? $m['type']) ?><?= $m['is_active'] ? '' : ' · pausiert' ?></div></td>
<td style="font-size:.84rem;word-break:break-all"><?= e($m['target']) ?><?php if (!empty($m['last_status']) || !empty($m['last_title'])): ?><div class="muted" style="font-size:.74rem">Antwort: <?= $m['last_status'] ? 'HTTP ' . (int) $m['last_status'] : '' ?><?= !empty($m['last_title']) ? ' · Titel „' . e(mb_strimwidth((string) $m['last_title'], 0, 60, '…')) . '“' : '' ?></div><?php endif; ?></td>
<td><span style="background:<?= $b[2] ?>;color:<?= $b[1] ?>;border-radius:20px;padding:3px 11px;font-size:.78rem;font-weight:700"><?= e($b[0]) ?></span>
<?php if ($m['state'] === 'down' && $m['last_error']): ?><div style="font-size:.74rem;color:#b42318"><?= e((string) $m['last_error']) ?></div>
<?php elseif ((int) $m['fails'] > 0): ?><div style="font-size:.74rem;color:#9a6200">Fehlschlag <?= (int) $m['fails'] ?> von <?= (int) $cfg['threshold'] ?> (Ausfall und Ticket ab Fehlschlag <?= (int) $cfg['threshold'] ?>): <?= e((string) $m['last_error']) ?></div><?php endif; ?>
<?php if ($m['is_active'] && $m['last_check_at']): $next = (int) strtotime((string) $m['last_check_at']) + ueberwachung_next_wait($m); ?><div class="muted" style="font-size:.72rem">nächste Prüfung ca. <?= e(date('H:i', max($next, time()))) ?></div><?php endif; ?></td>
<td style="font-size:.84rem"><?= $days === null ? '–' : ($days < 0 ? '<strong style="color:#b42318">abgelaufen</strong>' : ($days <= (int) $m['cert_warn_days'] ? '<strong style="color:#9a6200">' . $days . ' Tage</strong>' : $days . ' Tage')) ?></td>
<td class="muted" style="font-size:.8rem"><?= $m['last_check_at'] ? e(format_dt((string) $m['last_check_at'])) . ($m['last_ms'] !== null ? ' · ' . (int) $m['last_ms'] . ' ms' : '') : '–' ?></td>
<td style="white-space:nowrap">
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="id" value="<?= (int) $m['id'] ?>"><button class="btn secondary" name="action" value="check" type="submit" style="padding:2px 9px;font-size:.78rem">Jetzt prüfen</button>
<button class="btn secondary" name="action" value="toggle" type="submit" style="padding:2px 9px;font-size:.78rem"><?= $m['is_active'] ? 'Pausieren' : 'Fortsetzen' ?></button>
<a class="btn secondary" href="/staff/plugin.php?p=ueberwachung&amp;page=ueberwachung&amp;edit=<?= (int) $m['id'] ?>" style="padding:2px 9px;font-size:.78rem">Ändern</a>
<button class="btn danger" name="action" value="delete" type="submit" style="padding:2px 9px;font-size:.78rem" onclick="return confirm('Ziel löschen?')">Löschen</button></form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
$lastTick = (int) plugin_setting(UEBERWACHUNG, 'last_tick', '0');
$stale = $monitors && (!$lastTick || time() - $lastTick > 900);
?>
<div class="card" style="max-width:860px;margin-bottom:16px;<?= $stale ? 'border-left:4px solid #b45309' : '' ?>">
<h3>Hintergrunddienst</h3>
<p style="font-size:.9rem">
<?php if (!$lastTick): ?><strong style="color:#b45309">Noch kein Lauf.</strong> Die Prüfungen starten, sobald DeskHub das nächste Mal aufgerufen wird.
<?php elseif ($stale): ?><strong style="color:#b45309">Letzter Lauf vor <?= e(ueberwachung_duration(time() - $lastTick)) ?>.</strong> Es hat länger niemand DeskHub aufgerufen, oder der automatische Hintergrund-Betrieb ist ausgeschaltet (Einstellungen → Cron-Jobs).
<?php else: ?><strong style="color:#157a45">✓ Läuft.</strong> Letzter Lauf: <?= e(format_dt(date('Y-m-d H:i:s', $lastTick))) ?>.<?php endif; ?>
</p>
<?php
$cliLast = (string) get_setting('cron_run_plugins_last_cli', '');
$cliAge = $cliLast !== '' ? time() - (int) strtotime($cliLast) : null;
?>
<p style="font-size:.88rem">
Echter Server-Cronjob:
<?php if ($cliAge === null): ?><strong style="color:#b45309">bisher kein Lauf</strong> <span class="muted">(Einstellungen → Cron-Jobs → „Neu installieren / aktualisieren“, mit den aktuellen DeskHub-Dateien; bei älteren Dateien siehe unten)</span>
<?php elseif ($cliAge > 900): ?><strong style="color:#b45309">letzter Lauf vor <?= e(ueberwachung_duration($cliAge)) ?></strong> <span class="muted">(<?= e(format_dt($cliLast)) ?>). Er sollte jede Minute laufen: Crontab und <code>cron/run_plugins.log</code> prüfen.</span>
<?php else: ?><strong style="color:#157a45">✓ läuft</strong> <span class="muted">(letzter Lauf <?= e(format_dt($cliLast)) ?>)</span><?php endif; ?>
</p>
<p class="muted" style="font-size:.84rem">Die Prüfungen laufen mit den Hintergrundaufgaben von DeskHub. <strong>Mit dem aktuellen DeskHub-Update</strong> 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):</p>
<pre style="background:var(--bg-soft,#f3f6f8);border:1px solid var(--border,#e3e9ee);border-radius:6px;padding:8px 10px;font-size:.78rem;overflow:auto;margin:0">php <?= e(str_replace('\\', '/', plugins_dir()) . '/' . UEBERWACHUNG . '/cron.php') ?></pre>
<p class="muted" style="font-size:.8rem;margin-top:8px"><strong>Wichtig:</strong> 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.</p>
</div>
<form method="post" class="card" style="max-width:860px;margin-bottom:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" value="<?= (int) $f('id', 0) ?>">
<h3><?= $edit && !empty($edit['id']) ? 'Ziel ändern' : 'Neues Ziel' ?></h3>
<div style="display:flex;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:200px"><label>Name</label><input type="text" name="name" value="<?= e((string) $f('name')) ?>" placeholder="Support-Seite" required></div>
<div><label>Art</label><select name="type"><?php foreach ($types as $k => $l): ?><option value="<?= e($k) ?>" <?= $f('type', 'http') === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select></div>
</div>
<label style="margin-top:8px">Adresse bzw. Host:Port</label>
<input type="text" name="target" value="<?= e((string) $f('target')) ?>" placeholder="https://support.example.de/ oder mail.example.de:993" required>
<label style="margin-top:8px">Erwarteter Text auf der Seite (optional, nur bei Webseiten)</label>
<p class="muted" style="font-size:.78rem;margin:0 0 4px">Tipp: Mit einem Text, der nur auf der echten Seite steht (zum Beispiel der Name Ihrer Anwendung), erkennt die Überwachung auch Fälle, in denen ein Proxy oder Webserver eine andere Seite mit „200 OK“ ausliefert. Ohne Text werden bekannte Standard- und Fehlerseiten am Titel erkannt.</p>
<input type="text" name="expect_text" value="<?= e((string) $f('expect_text')) ?>" maxlength="200">
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:8px">
<div><label>Prüfen alle (Minuten)</label><input type="number" name="interval_min" min="1" max="1440" value="<?= (int) $f('interval_min', 5) ?>" style="max-width:110px"></div>
<div><label>Zeitlimit (Sekunden)</label><input type="number" name="timeout_sec" min="2" max="30" value="<?= (int) $f('timeout_sec', 8) ?>" style="max-width:110px"></div>
<div><label>Zertifikat-Warnung (Tage vorher)</label><input type="number" name="cert_warn_days" min="1" max="90" value="<?= (int) $f('cert_warn_days', 14) ?>" style="max-width:110px"></div>
</div>
<p class="muted" style="font-size:.78rem;margin:6px 0 0">Der eingestellte Abstand gilt, sobald der Hintergrunddienst von DeskHub jede Minute läuft (aktuelle Dateien: ein Cronjob mit „Neu installieren / aktualisieren“, oder bei Seitenaufrufen). Bei älteren Dateien läuft er nur alle 5 Minuten, kürzere Werte greifen dort erst mit dem eigenen Cronjob des Plugins (Karte „Hintergrunddienst“, Zeitplan <code>* * * * *</code>).</p>
<label style="display:block;margin-top:8px;font-weight:400"><input type="checkbox" name="verify_tls" value="1" style="width:auto" <?= !$edit || !array_key_exists('verify_tls', $edit) || !empty($edit['verify_tls']) ? 'checked' : '' ?>> Zertifikat prüfen (bei selbstsignierten oder internen Zertifikaten abschalten)</label>
<label style="display:block;margin-top:8px;font-weight:400"><input type="checkbox" name="is_active" value="1" style="width:auto" <?= !$edit || !empty($edit['is_active']) ? 'checked' : '' ?>> Aktiv</label>
<div class="btn-row"><button type="submit" class="btn">Speichern</button><?php if ($edit && !empty($edit['id'])): ?> <a class="btn secondary" href="/staff/plugin.php?p=ueberwachung&amp;page=ueberwachung">Abbrechen</a><?php endif; ?></div>
</form>
<form method="post" class="card" style="max-width:860px;margin-bottom:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="settings">
<h3>Einstellungen</h3>
<label style="display:block;font-weight:400"><input type="checkbox" name="create_tickets" value="1" style="width:auto" <?= $cfg['create_tickets'] ? 'checked' : '' ?>> Bei Ausfall und ablaufendem Zertifikat ein Ticket anlegen</label>
<label style="display:block;font-weight:400"><input type="checkbox" name="auto_resolve" value="1" style="width:auto" <?= $cfg['auto_resolve'] ? 'checked' : '' ?>> Ticket nach der Behebung automatisch auf „Gelöst“ setzen (Entwarnung)</label>
<label style="display:block;font-weight:400"><input type="checkbox" name="mail" value="1" style="width:auto" <?= $cfg['mail'] ? 'checked' : '' ?>> E-Mail bei Ausfall, Entwarnung und Zertifikat</label>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:8px">
<div><label>Ausfall melden nach … Fehlschlägen in Folge</label><input type="number" name="threshold" min="1" max="10" value="<?= (int) $cfg['threshold'] ?>" style="max-width:110px"></div>
<div><label>Abteilung der Tickets</label><select name="department_id"><option value="0">Standard</option><?php foreach ($depts as $d): ?><option value="<?= (int) $d['id'] ?>" <?= (int) $d['id'] === $cfg['department_id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option><?php endforeach; ?></select></div>
<div><label>Priorität der Tickets</label><select name="priority_id"><option value="0">Standard</option><?php foreach ($prios as $p): ?><option value="<?= (int) $p['id'] ?>" <?= (int) $p['id'] === $cfg['priority_id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
</div>
<label style="margin-top:8px">Mail an (leer = Administrator-Adresse der Installation, mehrere mit Komma)</label>
<input type="text" name="alert_email" value="<?= e((string) plugin_setting(UEBERWACHUNG, 'alert_email', '')) ?>" placeholder="<?= e((string) $cfg['alert_email']) ?>">
<div class="btn-row"><button type="submit" class="btn">Speichern</button> <button type="submit" class="btn secondary" name="action" value="test_ticket" formnovalidate>Testticket anlegen</button></div>
</form>
<div class="card">
<h3>Ereignisse</h3>
<?php if (!$events): ?><p class="muted">Noch keine Ereignisse.</p><?php else: ?>
<table style="width:100%"><thead><tr><th>Wann</th><th>Ziel</th><th>Ereignis</th></tr></thead><tbody>
<?php foreach ($events as $ev): ?>
<tr><td class="muted" style="white-space:nowrap"><?= e(format_dt((string) $ev['created_at'])) ?></td><td><?= e((string) $ev['name']) ?></td><td><?= e(['down' => 'Ausfall', 'up' => 'Entwarnung', 'cert' => 'Zertifikat', 'cert_ok' => 'Zertifikat erneuert'][$ev['kind']] ?? $ev['kind']) ?>: <?= e((string) $ev['message']) ?></td></tr>
<?php endforeach; ?>
</tbody></table>
<?php endif; ?>
</div>
+9
View File
@@ -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"]
}
+15
View File
@@ -0,0 +1,15 @@
<?php
// Website- und Server-Überwachung: regelmäßige Prüfung im Hintergrund.
require_once __DIR__ . '/lib.php';
ueberwachung_ensure_schema();
// „cron.minute“ (aktuelles DeskHub: jede Minute) hält den eingestellten Prüfabstand ein; „cron.run“ (alle 5 Minuten) ist der Rückfall für ältere Dateien.
// Doppelte Aufrufe schaden nicht: Es werden nur Ziele geprüft, die fällig sind.
foreach (['cron.minute', 'cron.run'] as $__hook) {
plugin_add_hook($__hook, function () {
ueberwachung_tick(get_db());
});
}
// Im Adminportal, nur für Administratoren (die Seite prüft das zusätzlich selbst)
plugin_register_menu('Überwachung', 'ueberwachung', null, 'admin');
Binary file not shown.
+219
View File
@@ -0,0 +1,219 @@
<?php
// Urlaub und Vertretung. Alle Funktionen beginnen mit vertretung_.
// Baut auf dem Urlaubsstatus im Profil auf (staff.is_on_vacation / vacation_substitute_id): Die automatische Zuweisung der Abteilungen
// und der E-Mail-Abruf beachten ihn bereits. Dieses Plugin setzt ihn zum Beginn und nimmt ihn zum Ende zurück.
const VERTRETUNG = 'vertretung';
function vertretung_today(): string
{
return (string) ($GLOBALS['vertretung_today'] ?? date('Y-m-d'));
}
function vertretung_status_labels(): array
{
return ['planned' => '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 .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
vertretung_mail($pdo, $to, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', '<p>Sie vertreten <strong>' . e($name) . '</strong> vom ' . e(vertretung_date((string) $abs['start_date'])) . ' bis ' . e(vertretung_date((string) $abs['end_date'])) . '.</p>'
. '<p>' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen, neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen.</p>' . ($list ? '<ul>' . $list . '</ul>' : ''));
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 .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
vertretung_mail($pdo, $staffId, 'Willkommen zurück: ' . count($back) . ' Tickets', '<p>Ihre Abwesenheit ist beendet. Diese Tickets sind wieder bei Ihnen:</p><ul>' . $list . '</ul>');
}
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;
}
@@ -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
@@ -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)
@@ -0,0 +1,86 @@
<?php
// Seite des Vertretungs-Plugins. Verfügbar: $staff, $pdo.
$isAdmin = ($staff['role'] ?? '') === 'admin';
$message = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'create') {
$staffId = $isAdmin ? (int) ($_POST['staff_id'] ?? 0) : (int) $staff['id'];
$r = vertretung_create($pdo, $staff, $staffId, (int) ($_POST['substitute_id'] ?? 0), (string) ($_POST['start_date'] ?? ''), (string) ($_POST['end_date'] ?? ''), (string) ($_POST['note'] ?? ''));
if ($r['ok']) $message = 'Die Abwesenheit ist eingetragen.'; else $error = $r['error'];
} elseif ($action === 'cancel') {
if (vertretung_cancel($pdo, $staff, (int) ($_POST['id'] ?? 0))) $message = 'Die Abwesenheit wurde beendet, offene Tickets sind wieder bei der ursprünglichen Person.';
else $error = 'Diese Abwesenheit kann nicht beendet werden.';
}
}
$people = $pdo->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();
?>
<h1>Abwesenheit und Vertretung</h1>
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
<form method="post" class="card" style="max-width:900px;margin-bottom:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="create">
<h3>Abwesenheit eintragen</h3>
<p class="muted" style="font-size:.85rem">Zum Beginn gehen Ihre offenen Tickets an die Vertretung, neue Tickets ebenfalls (auch bei automatischer Zuweisung). Am Ende bekommen Sie die Tickets zurück, die dann noch bei der Vertretung liegen. Der Urlaubsstatus im Profil wird dafür automatisch gesetzt.</p>
<div style="display:flex;gap:14px;flex-wrap:wrap">
<?php if ($isAdmin): ?>
<div><label>Wer ist abwesend?</label><select name="staff_id"><?php foreach ($people as $p): ?><option value="<?= (int) $p['id'] ?>" <?= (int) $p['id'] === (int) $staff['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
<?php endif; ?>
<div><label>Vertretung</label><select name="substitute_id" required><option value="">– wählen –</option><?php foreach ($people as $p): if ((int) $p['id'] === (int) $staff['id'] && !$isAdmin) continue; ?><option value="<?= (int) $p['id'] ?>"><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
<div><label>Von</label><input type="date" name="start_date" value="<?= e(vertretung_today()) ?>" required></div>
<div><label>Bis (einschließlich)</label><input type="date" name="end_date" required></div>
</div>
<label style="margin-top:8px">Notiz (optional)</label><input type="text" name="note" maxlength="250" placeholder="zum Beispiel Sommerurlaub">
<div class="btn-row"><button type="submit" class="btn">Eintragen</button></div>
</form>
<div class="card" style="margin-bottom:16px">
<h3>Laufend und geplant</h3>
<?php if (!$current): ?><p class="muted">Keine Abwesenheiten eingetragen.</p><?php else: ?>
<table style="width:100%">
<thead><tr><th>Wer</th><th>Vertretung</th><th>Zeitraum</th><th>Stand</th><th>Tickets</th><th></th></tr></thead>
<tbody>
<?php foreach ($current as $a): ?>
<tr>
<td><?= $name($a['staff_id']) ?><?php if ($a['note']): ?><div class="muted" style="font-size:.76rem"><?= e((string) $a['note']) ?></div><?php endif; ?></td>
<td><?= $name($a['substitute_id']) ?></td>
<td><?= e(vertretung_date((string) $a['start_date'])) ?> – <?= e(vertretung_date((string) $a['end_date'])) ?></td>
<td><?= e($labels[$a['status']] ?? $a['status']) ?></td>
<td><?= $a['status'] === 'active' ? (int) $a['holding'] . ' bei der Vertretung' : '–' ?></td>
<td>
<?php if (vertretung_can_manage($staff, (int) $a['staff_id'])): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Abwesenheit beenden? Offene Tickets gehen zurück.')"><?= csrf_field() ?><input type="hidden" name="action" value="cancel"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>"><button class="btn secondary" type="submit"><?= $a['status'] === 'active' ? 'Jetzt beenden' : 'Löschen' ?></button></form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php if ($past): ?>
<div class="card">
<h3>Frühere Abwesenheiten</h3>
<table style="width:100%">
<thead><tr><th>Wer</th><th>Vertretung</th><th>Zeitraum</th><th>Ergebnis</th><th>Weitergegeben</th></tr></thead>
<tbody>
<?php foreach ($past as $a): ?>
<tr><td><?= $name($a['staff_id']) ?></td><td><?= $name($a['substitute_id']) ?></td><td><?= e(vertretung_date((string) $a['start_date'])) ?> – <?= e(vertretung_date((string) $a['end_date'])) ?></td><td><?= e($labels[$a['status']] ?? $a['status']) ?></td><td><?= (int) $a['moved'] ?> Tickets</td></tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
+9
View File
@@ -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"]
}
+16
View File
@@ -0,0 +1,16 @@
<?php
// Urlaub und Vertretung: Zeiträume für Abwesenheiten, Umverteilung und Rückgabe von Tickets.
require_once __DIR__ . '/lib.php';
plugin_add_hook('cron.run', function () {
vertretung_tick(get_db());
});
// Ticket ist (von Regel, Hand oder API) einer abwesenden Person zugewiesen → an die Vertretung weitergeben
foreach (['ticket.created', 'ticket.assigned'] as $hook) {
plugin_add_hook($hook, function (array $payload) {
if (!empty($payload['ticketId'])) vertretung_redirect(get_db(), (int) $payload['ticketId']);
});
}
// Jeder Mitarbeiter pflegt seine eigene Abwesenheit, Administratoren alle
plugin_register_menu('Abwesenheit', 'vertretung');
Binary file not shown.
+125
View File
@@ -0,0 +1,125 @@
<?php
// Eingebaute Dateianalyse des Virenscans: braucht nichts auf dem Server, führt nie etwas aus und liest nur die ersten Megabytes.
// Sie erkennt gefährliche Dateitypen und Tricks, keine neuen Viren (dafür gibt es ClamAV und die Hash-Abfrage).
/** Dateiendungen, die Programme oder Skripte starten können. */
const VIRENSCAN_BAD_EXT = ['exe', 'scr', 'com', 'pif', 'bat', 'cmd', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh', 'ps1', 'psm1', 'msi', 'msp', 'hta', 'jar', 'cpl', 'dll', 'sys', 'lnk', 'reg', 'inf', 'sh', 'apk', 'iso', 'img', 'vhd', 'vhdx', 'gadget', 'application', 'appx', 'msix', 'settingcontent-ms', 'chm'];
/** Office-Dateien mit eingebauten Makros. */
const VIRENSCAN_MACRO_EXT = ['docm', 'xlsm', 'pptm', 'dotm', 'xltm', 'potm', 'ppam', 'ppsm', 'sldm', 'xlam'];
/** Archive, die sich nicht öffnen lassen. */
const VIRENSCAN_OTHER_ARCHIVES = ['rar', '7z', 'ace', 'arj', 'cab', 'z', 'zipx'];
function virenscan_file_ext(string $name): string
{
return strtolower(pathinfo(rtrim($name, '. '), PATHINFO_EXTENSION));
}
/** Art des Programms nach den ersten Bytes, oder null. */
function virenscan_exec_magic(string $head): ?string
{
if (str_starts_with($head, 'MZ')) return 'Windows-Programm (EXE/DLL)';
if (str_starts_with($head, "\x7fELF")) return 'Linux-Programm (ELF)';
foreach (["\xCF\xFA\xED\xFE", "\xCE\xFA\xED\xFE", "\xFE\xED\xFA\xCE", "\xFE\xED\xFA\xCF"] as $m) if (str_starts_with($head, $m)) return 'macOS-Programm (Mach-O)';
if (str_starts_with($head, "\xCA\xFE\xBA\xBE")) return 'Java-Programm oder Universalprogramm';
return null;
}
/** Namenstricks: umgekehrte Schreibrichtung, doppelte Endung. @return list<array{0:string,1:string}> */
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<array{0:string,1:string}> 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('/<script\b|javascript:|\bon(load|error|click|mouseover)\s*=/i', $body)) $f[] = ['script', 'Enthält ausführbaren Skriptcode'];
}
// RTF mit eingebetteten Objekten
if (str_starts_with($head, '{\\rtf')) {
$body = (string) @file_get_contents($path, false, null, 0, 4 * 1024 * 1024);
if (preg_match('/\\\\(objdata|objupdate|objautlink)\b/', $body)) $f[] = ['exec', 'RTF mit eingebettetem Objekt'];
}
return $f;
}
/** Entscheidet nach den Einstellungen, welche Funde zum Sperren führen. @return list<string> Gründe, leer = sauber */
function virenscan_block_reasons(array $findings, array $cfg): array
{
$reasons = [];
foreach ($findings as [$cat, $text]) {
$on = match ($cat) {
'exec', 'archive', 'script', 'eicar' => true,
'macro' => $cfg['block_macros'],
'encrypted' => $cfg['block_encrypted'],
'unscannable' => $cfg['block_unscannable'],
default => true,
};
if ($on) $reasons[] = $text;
}
return array_values(array_unique($reasons));
}
+425
View File
@@ -0,0 +1,425 @@
<?php
// Virenscan-Plugin: Prüfung, Quarantäne und Verwaltung. Alle Funktionen beginnen mit virenscan_.
const VIRENSCAN = 'virenscan';
require_once __DIR__ . '/analyse.php';
/** Einstellungen mit Standardwerten. */
/**
* Selbstheilung: Fehlt nach einem Update eine Datenbank-Änderung des Plugins (ältere DeskHub-Dateien spielen sie nicht von selbst ein),
* wird sie hier nachgeholt, statt dass das Plugin mit Datenbankfehlern abgeschaltet wird.
*/
function virenscan_ensure_schema(bool $force = false): void
{
static $done = false;
if ($done && !$force) return;
$done = true;
try {
get_db()->query('SELECT rep, sha256 FROM plugin_virenscan_files LIMIT 1');
} catch (Throwable $e) {
try {
plugin_run_migrations(get_db(), VIRENSCAN);
} catch (Throwable $e2) {
error_log('virenscan schema: ' . $e2->getMessage());
}
}
}
function virenscan_config(): array
{
$key = '';
$enc = (string) plugin_setting(VIRENSCAN, 'vt_key', '');
if ($enc !== '') { try { $key = decrypt_secret($enc); } catch (Throwable $e) { $key = ''; } }
return [
'analysis' => plugin_setting(VIRENSCAN, 'analysis', '1') === '1',
'block_macros' => plugin_setting(VIRENSCAN, 'block_macros', '1') === '1',
'block_encrypted' => plugin_setting(VIRENSCAN, 'block_encrypted', '1') === '1',
'block_unscannable' => plugin_setting(VIRENSCAN, 'block_unscannable', '0') === '1',
'vt_key' => $key,
'vt_threshold' => max(1, min(30, (int) plugin_setting(VIRENSCAN, 'vt_threshold', '3'))),
] + virenscan_config_base();
}
function virenscan_config_base(): array
{
return [
'mode' => in_array(plugin_setting(VIRENSCAN, 'mode', 'auto'), ['auto', 'clamdscan', 'clamscan', 'builtin'], true) ? plugin_setting(VIRENSCAN, 'mode', 'auto') : 'auto',
'binary' => trim((string) plugin_setting(VIRENSCAN, 'binary', '')),
'timeout' => max(5, min(300, (int) plugin_setting(VIRENSCAN, 'timeout', '60'))),
'max_mb' => max(1, min(500, (int) plugin_setting(VIRENSCAN, 'max_mb', '25'))),
'block_on_error' => plugin_setting(VIRENSCAN, 'block_on_error', '0') === '1',
'notify' => plugin_setting(VIRENSCAN, 'notify', '1') === '1',
];
}
function virenscan_uploads_dir(): string
{
return rtrim(str_replace('\\', '/', (string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads')), '/');
}
function virenscan_quarantine_dir(): string
{
$dir = virenscan_uploads_dir() . '/quarantaene';
if (!is_dir($dir)) {
@mkdir($dir, 0750, true);
@file_put_contents($dir . '/.htaccess', "Require all denied\nDeny from all\n");
@file_put_contents($dir . '/index.html', '');
}
return $dir;
}
/** Die Standard-Testzeichenfolge (EICAR), in Teilen geschrieben, damit Virenscanner diese Quelldatei nicht selbst melden. */
function virenscan_eicar(): string
{
return 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$' . 'EICAR-STANDARD-ANTIVIRUS-TEST-FILE!' . '$H+H*';
}
/**
* Führt ein Programm mit Zeitlimit aus (ohne Shell). Die Ausgabe geht in Temp-Dateien statt in Pipes, damit das Zeitlimit auch unter Windows greift
* (dort lassen sich Pipes nicht nicht-blockierend lesen). @return array{0:int,1:string,2:string} Exit-Code, Ausgabe, Fehlerausgabe
*/
function virenscan_run(array $cmd, int $timeout): array
{
$outFile = tempnam(sys_get_temp_dir(), 'vso');
$errFile = tempnam(sys_get_temp_dir(), 'vse');
$proc = @proc_open($cmd, [1 => ['file', $outFile, 'w'], 2 => ['file', $errFile, 'w']], $pipes);
if (!is_resource($proc)) {
@unlink($outFile); @unlink($errFile);
return [-1, '', 'Das Programm konnte nicht gestartet werden.'];
}
$end = microtime(true) + $timeout;
$exit = null;
$timedOut = false;
while (true) {
$status = proc_get_status($proc);
if (!$status['running']) { $exit = (int) $status['exitcode']; break; }
if (microtime(true) > $end) { proc_terminate($proc); $timedOut = true; break; }
usleep(40000);
}
proc_close($proc);
$out = (string) @file_get_contents($outFile, false, null, 0, 1048576);
$err = (string) @file_get_contents($errFile, false, null, 0, 1048576);
@unlink($outFile); @unlink($errFile);
if ($timedOut) return [-1, $out, "Zeitüberschreitung nach {$timeout} Sekunden."];
return [$exit, $out, $err];
}
/** Welches Prüfprogramm wird benutzt? @return array{mode:string,binary:string} mode: clamdscan, clamscan oder builtin */
function virenscan_resolve(): array
{
$cfg = virenscan_config();
if ($cfg['mode'] === 'builtin') return ['mode' => 'builtin', 'binary' => ''];
if ($cfg['binary'] !== '') {
$m = $cfg['mode'] === 'auto' ? (str_contains(strtolower(basename($cfg['binary'])), 'clamdscan') ? 'clamdscan' : 'clamscan') : $cfg['mode'];
return ['mode' => $m, 'binary' => $cfg['binary']];
}
$candidates = static fn(string $name) => ["/usr/bin/{$name}", "/usr/local/bin/{$name}", "/opt/clamav/bin/{$name}", "C:/Program Files/ClamAV/{$name}.exe"];
foreach ($cfg['mode'] === 'auto' ? ['clamdscan', 'clamscan'] : [$cfg['mode']] as $m) {
foreach ($candidates($m) as $path) if (is_file($path) && is_executable($path)) return ['mode' => $m, 'binary' => $path];
}
return ['mode' => 'builtin', 'binary' => ''];
}
/** Zustand des Prüfprogramms für die Einstellungsseite. @return array{mode:string,binary:string,version:?string,ok:bool,message:string} */
function virenscan_status(): array
{
$r = virenscan_resolve();
if ($r['mode'] === 'builtin') {
return $r + ['version' => null, 'ok' => false, 'message' => 'ClamAV ist auf diesem Server nicht installiert. Es läuft die eingebaute Dateianalyse' . (virenscan_config()['vt_key'] !== '' ? ' mit Hash-Abfrage bei VirusTotal.' : '. Mit einem VirusTotal-Schlüssel (kostenlos) werden Dateien zusätzlich gegen bekannte Schadsoftware abgeglichen.')];
}
[$code, $out, $err] = virenscan_run([$r['binary'], '--version'], 15);
if ($code !== 0) return $r + ['version' => null, 'ok' => false, 'message' => 'Das Prüfprogramm antwortet nicht: ' . trim($err !== '' ? $err : $out)];
return $r + ['version' => trim(strtok($out, "\n")), 'ok' => true, 'message' => 'Bereit.'];
}
/**
* Prüft eine Datei: zuerst ClamAV (falls auf dem Server vorhanden), dann die eingebaute Dateianalyse.
* @return array{status:string,signature:?string,message:?string} status: clean, infected oder error
*/
function virenscan_scan_file(string $path, string $name = ''): array
{
if (!is_file($path)) return ['status' => 'error', 'signature' => null, 'message' => 'Datei nicht gefunden.'];
$cfg = virenscan_config();
$r = virenscan_resolve();
$clam = null;
if ($r['mode'] !== 'builtin') {
$cmd = [$r['binary'], '--no-summary'];
if ($r['mode'] === 'clamdscan') $cmd[] = '--fdpass';
$cmd[] = $path;
[$code, $out, $err] = virenscan_run($cmd, $cfg['timeout']);
$clam = virenscan_interpret($code, $out, $err);
if ($clam['status'] === 'infected') return $clam;
}
// Eingebaute Analyse (läuft auch mit ClamAV zusätzlich: erkennt Dateitypen und Tricks, die ein Virenscanner durchlässt)
$findings = $cfg['analysis'] ? virenscan_analyze($path, $name !== '' ? $name : basename($path)) : [];
if (!$cfg['analysis']) {
$head = (string) @file_get_contents($path, false, null, 0, 2 * 1024 * 1024);
if (str_contains($head, virenscan_eicar())) $findings = [['eicar', 'Eicar-Test-Signature']];
}
$reasons = virenscan_block_reasons($findings, $cfg);
if ($reasons) {
$sig = in_array('Eicar-Test-Signature', $reasons, true) ? 'Eicar-Test-Signature' : 'Dateianalyse: ' . implode('; ', $reasons);
return ['status' => 'infected', 'signature' => mb_substr($sig, 0, 250), 'message' => null];
}
if ($clam !== null) return $clam;
return ['status' => 'clean', 'signature' => null, 'message' => $cfg['analysis'] ? 'Dateianalyse' : 'Nur Testprüfung (Dateianalyse ausgeschaltet)'];
}
/** Wertet die Ausgabe von clamscan/clamdscan aus: Exit-Code 0 = sauber, 1 = Fund ("Datei: Name FOUND"), sonst Fehler. */
function virenscan_interpret(int $code, string $out, string $err): array
{
if ($code === 0) return ['status' => 'clean', 'signature' => null, 'message' => null];
if ($code === 1) {
$sig = preg_match('/:\s*(.+?)\s+FOUND/i', $out, $m) ? $m[1] : 'unbekannt';
return ['status' => 'infected', 'signature' => mb_substr($sig, 0, 250), 'message' => null];
}
return ['status' => 'error', 'signature' => null, 'message' => mb_substr(trim($err !== '' ? $err : $out) ?: 'Prüfung fehlgeschlagen (Code ' . $code . ').', 0, 250)];
}
function virenscan_start_id(PDO $pdo): int
{
$v = plugin_setting(VIRENSCAN, 'start_id', null);
if ($v === null) {
// Erster Lauf: Bestand nicht nachträglich prüfen, nur neue Anhänge (über die Seite lässt sich der Bestand nachholen)
$v = (string) (int) $pdo->query('SELECT COALESCE(MAX(id), 0) FROM ticket_attachments')->fetchColumn();
plugin_set_setting(VIRENSCAN, 'start_id', $v);
}
return (int) $v;
}
/** Noch ungeprüfte Anhänge (optional nur eines Tickets). */
function virenscan_pending(PDO $pdo, int $limit = 25, ?int $ticketId = null): array
{
$sql = "SELECT a.id, a.original_name, a.stored_name, a.filesize, t.ticket_id FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id
WHERE a.id > ? AND a.stored_name <> 'gesperrt' AND a.id NOT IN (SELECT attachment_id FROM plugin_virenscan_files)";
$params = [virenscan_start_id($pdo)];
if ($ticketId !== null) { $sql .= ' AND t.ticket_id = ?'; $params[] = $ticketId; }
$stmt = $pdo->prepare($sql . ' ORDER BY a.id LIMIT ' . max(1, $limit));
$stmt->execute($params);
return $stmt->fetchAll();
}
function virenscan_record(PDO $pdo, array $att, string $status, ?string $signature, ?string $message, bool $quarantined = false): void
{
$pdo->prepare('INSERT INTO plugin_virenscan_files (attachment_id, ticket_id, original_name, stored_name, status, signature, message, quarantined, scanned_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([(int) $att['id'], $att['ticket_id'] ?? null, mb_substr((string) $att['original_name'], 0, 255), mb_substr((string) $att['stored_name'], 0, 255), $status, $signature, $message !== null ? mb_substr($message, 0, 250) : null, $quarantined ? 1 : 0, db_now()]);
}
/** Prüft einen Anhang und sperrt ihn, wenn er verseucht ist. @return string clean, infected, error, skipped oder blocked */
function virenscan_scan_attachment(PDO $pdo, array $att): string
{
$cfg = virenscan_config();
$path = virenscan_uploads_dir() . '/' . basename((string) $att['stored_name']);
if (!is_file($path)) { virenscan_record($pdo, $att, 'error', null, 'Datei nicht gefunden.'); return 'error'; }
if (filesize($path) > $cfg['max_mb'] * 1024 * 1024) { virenscan_record($pdo, $att, 'skipped', null, 'Größer als ' . $cfg['max_mb'] . ' MB, nicht geprüft.'); return 'skipped'; }
$res = virenscan_scan_file($path, (string) $att['original_name']);
if ($res['status'] === 'infected') {
virenscan_record($pdo, $att, 'infected', $res['signature'], null);
virenscan_quarantine($pdo, (int) $att['id'], (string) $res['signature']);
return 'infected';
}
if ($res['status'] === 'error') {
if ($cfg['block_on_error']) {
virenscan_record($pdo, $att, 'blocked', null, $res['message']);
virenscan_quarantine($pdo, (int) $att['id'], 'Prüfung fehlgeschlagen');
return 'blocked';
}
virenscan_record($pdo, $att, 'error', null, $res['message']);
return 'error';
}
virenscan_record($pdo, $att, 'clean', null, $res['message']);
return 'clean';
}
/** Verschiebt die Datei in die Quarantäne, benennt den Anhang um (nicht mehr herunterladbar) und vermerkt es im Ticket. */
function virenscan_quarantine(PDO $pdo, int $attachmentId, string $reason): bool
{
$stmt = $pdo->prepare('SELECT a.*, t.ticket_id FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE a.id = ?');
$stmt->execute([$attachmentId]);
$att = $stmt->fetch();
if (!$att || $att['stored_name'] === 'gesperrt') return false;
$from = virenscan_uploads_dir() . '/' . basename((string) $att['stored_name']);
$to = virenscan_quarantine_dir() . '/' . basename((string) $att['stored_name']);
if (is_file($from) && !@rename($from, $to)) {
if (!@copy($from, $to) || !@unlink($from)) return false;
}
$pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[GESPERRT] ' . $att['original_name'], 0, 255), $attachmentId]);
$pdo->prepare('UPDATE plugin_virenscan_files SET quarantined = 1 WHERE attachment_id = ?')->execute([$attachmentId]);
$ticketId = (int) $att['ticket_id'];
$text = 'Der Anhang „' . $att['original_name'] . '“ wurde vom Virenscan gesperrt (' . $reason . '). Er liegt in der Quarantäne und lässt sich unter Virenscan prüfen oder freigeben.';
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticketId, $text, db_now()]);
log_activity($pdo, $ticketId, null, 'Anhang vom Virenscan gesperrt: ' . $att['original_name'] . ' (' . $reason . ')');
if (function_exists('apply_tag_to_ticket')) apply_tag_to_ticket($pdo, $ticketId, 'Virus-Verdacht');
if (virenscan_config()['notify']) virenscan_notify($pdo, $ticketId, (string) $att['original_name'], $reason);
return true;
}
/** Mail an die Adresse des Administrators (Einstellung der Installation). */
function virenscan_notify(PDO $pdo, int $ticketId, string $name, string $reason): void
{
$to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? ''));
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) return;
$t = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id = ?');
$t->execute([$ticketId]);
$number = (string) $t->fetchColumn();
try {
send_mail($to, "Virenscan: Anhang in Ticket #{$number} gesperrt", '<p>Der Anhang <strong>' . e($name) . '</strong> in Ticket <strong>#' . e($number) . '</strong> wurde gesperrt (' . e($reason) . ').</p><p>Er liegt in der Quarantäne. Prüfen, freigeben oder löschen kannst du ihn unter <strong>Virenscan</strong> im Mitarbeiterbereich.</p>');
} catch (Throwable $e) {
// Mailfehler dürfen das Sperren nie verhindern
}
}
/** Gibt eine gesperrte Datei wieder frei (zum Beispiel bei Fehlalarm). */
function virenscan_release(PDO $pdo, int $rowId): bool
{
$stmt = $pdo->prepare('SELECT * FROM plugin_virenscan_files WHERE id = ? AND quarantined = 1');
$stmt->execute([$rowId]);
$row = $stmt->fetch();
if (!$row) return false;
$from = virenscan_quarantine_dir() . '/' . basename((string) $row['stored_name']);
$to = virenscan_uploads_dir() . '/' . basename((string) $row['stored_name']);
if (is_file($from) && !@rename($from, $to)) return false;
$pdo->prepare('UPDATE ticket_attachments SET stored_name = ?, original_name = ? WHERE id = ?')->execute([$row['stored_name'], $row['original_name'], (int) $row['attachment_id']]);
$pdo->prepare("UPDATE plugin_virenscan_files SET quarantined = 0, status = 'released', message = ? WHERE id = ?")->execute(['Freigegeben am ' . db_now(), $rowId]);
if ($row['ticket_id']) log_activity($pdo, (int) $row['ticket_id'], null, 'Gesperrter Anhang freigegeben: ' . $row['original_name']);
return true;
}
/** Löscht eine gesperrte Datei endgültig (der Anhang bleibt als „gelöscht“ im Ticket vermerkt). */
function virenscan_delete(PDO $pdo, int $rowId): bool
{
$stmt = $pdo->prepare('SELECT * FROM plugin_virenscan_files WHERE id = ? AND quarantined = 1');
$stmt->execute([$rowId]);
$row = $stmt->fetch();
if (!$row) return false;
$file = virenscan_quarantine_dir() . '/' . basename((string) $row['stored_name']);
if (is_file($file)) @unlink($file);
$pdo->prepare("UPDATE ticket_attachments SET original_name = ? WHERE id = ?")->execute([mb_substr('[GELÖSCHT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]);
$pdo->prepare("UPDATE plugin_virenscan_files SET quarantined = 0, status = 'deleted', message = ? WHERE id = ?")->execute(['Gelöscht am ' . db_now(), $rowId]);
return true;
}
/** Prüft die noch ungeprüften Anhänge eines Tickets (nach neuer Nachricht oder neuem Ticket). */
function virenscan_scan_ticket(PDO $pdo, int $ticketId): int
{
$n = 0;
foreach (virenscan_pending($pdo, 10, $ticketId) as $att) { virenscan_scan_attachment($pdo, $att); $n++; }
return $n;
}
/** Hintergrundlauf (etwa alle 5 Minuten): ungeprüfte Anhänge nachholen, zum Beispiel aus E-Mails. */
function virenscan_tick(PDO $pdo): int
{
$n = 0;
foreach (virenscan_pending($pdo, 25) as $att) { virenscan_scan_attachment($pdo, $att); $n++; }
$n += virenscan_reputation_tick($pdo);
if ($n > 0) plugin_set_setting(VIRENSCAN, 'last_run', db_now());
return $n;
}
/** Zahlen für die Seite: Anzahl je Status. @return array<string,int> */
function virenscan_stats(PDO $pdo): array
{
$out = ['clean' => 0, 'infected' => 0, 'error' => 0, 'skipped' => 0, 'blocked' => 0, 'released' => 0, 'deleted' => 0, 'quarantined' => 0];
foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM plugin_virenscan_files GROUP BY status')->fetchAll() as $r) $out[$r['status']] = (int) $r['n'];
$out['quarantined'] = (int) $pdo->query('SELECT COUNT(*) FROM plugin_virenscan_files WHERE quarantined = 1')->fetchColumn();
return $out;
}
// ── Hash-Abfrage bei VirusTotal ────────────────────────────────────────────
// Es wird nur der SHA-256-Wert der Datei gesendet, nie die Datei selbst. Kostenloser Schlüssel: 4 Abfragen pro Minute, 500 pro Tag.
/** HTTP-Aufruf (für Tests austauschbar über $GLOBALS['virenscan_http']). @return array{status:int,body:string} */
function virenscan_http(string $url, array $headers): array
{
if (isset($GLOBALS['virenscan_http']) && is_callable($GLOBALS['virenscan_http'])) return ($GLOBALS['virenscan_http'])($url, $headers);
$ctx = stream_context_create(['http' => ['method' => 'GET', 'header' => implode("\r\n", $headers), 'timeout' => 8, 'ignore_errors' => true]]);
$res = @file_get_contents($url, false, $ctx);
$status = 0;
foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d+)#', $h, $m)) $status = (int) $m[1];
return ['status' => $status, 'body' => $res === false ? '' : (string) $res];
}
/** @return array{result:string,detail:?string} result: bad, clean, unknown, limit, key oder error */
function virenscan_vt_lookup(string $sha256, string $key, int $threshold): array
{
$r = virenscan_http('https://www.virustotal.com/api/v3/files/' . $sha256, ['x-apikey: ' . $key, 'Accept: application/json']);
if ($r['status'] === 404) return ['result' => 'unknown', 'detail' => null];
if ($r['status'] === 429) return ['result' => 'limit', 'detail' => null];
if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'];
if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'];
$j = json_decode($r['body'], true);
$stats = $j['data']['attributes']['last_analysis_stats'] ?? null;
if (!is_array($stats)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.'];
$bad = (int) ($stats['malicious'] ?? 0);
if ($bad >= $threshold) {
$label = (string) ($j['data']['attributes']['popular_threat_classification']['suggested_threat_label'] ?? '');
return ['result' => 'bad', 'detail' => 'VirusTotal: ' . $bad . ' Scanner melden Schadsoftware' . ($label !== '' ? ' (' . mb_substr($label, 0, 60) . ')' : '')];
}
return ['result' => 'clean', 'detail' => null];
}
/**
* Hintergrundlauf der Hash-Abfrage: prüft saubere Dateien nachträglich gegen VirusTotal, höchstens 4 pro Lauf und 450 pro Tag.
* Findet VirusTotal Schadsoftware, wird die Datei gesperrt. @return int Zahl der abgefragten Dateien
*/
function virenscan_reputation_tick(PDO $pdo): int
{
$cfg = virenscan_config();
if ($cfg['vt_key'] === '') return 0;
$today = date('Y-m-d');
if (plugin_setting(VIRENSCAN, 'vt_day', '') !== $today) { plugin_set_setting(VIRENSCAN, 'vt_day', $today); plugin_set_setting(VIRENSCAN, 'vt_count', '0'); }
$used = (int) plugin_setting(VIRENSCAN, 'vt_count', '0');
$rows = $pdo->query("SELECT * FROM plugin_virenscan_files WHERE status = 'clean' AND rep IS NULL AND quarantined = 0 ORDER BY id LIMIT 4")->fetchAll();
$done = 0;
foreach ($rows as $row) {
if ($used >= 450) break;
$path = virenscan_uploads_dir() . '/' . basename((string) $row['stored_name']);
if (!is_file($path) || filesize($path) > $cfg['max_mb'] * 1024 * 1024) {
$pdo->prepare("UPDATE plugin_virenscan_files SET rep = 'skipped' WHERE id = ?")->execute([$row['id']]);
continue;
}
$hash = hash_file('sha256', $path);
$res = virenscan_vt_lookup($hash, $cfg['vt_key'], $cfg['vt_threshold']);
if ($res['result'] === 'limit') break;
if ($res['result'] === 'key' || $res['result'] === 'error') { plugin_set_setting(VIRENSCAN, 'vt_error', db_now() . ': ' . $res['detail']); break; }
plugin_set_setting(VIRENSCAN, 'vt_error', '');
$used++; $done++;
plugin_set_setting(VIRENSCAN, 'vt_count', (string) $used);
if ($res['result'] === 'bad') {
$pdo->prepare("UPDATE plugin_virenscan_files SET rep = 'bad', sha256 = ?, status = 'infected', signature = ? WHERE id = ?")->execute([$hash, mb_substr((string) $res['detail'], 0, 250), $row['id']]);
virenscan_quarantine($pdo, (int) $row['attachment_id'], (string) $res['detail']);
} else {
$pdo->prepare('UPDATE plugin_virenscan_files SET rep = ?, sha256 = ? WHERE id = ?')->execute([$res['result'], $hash, $row['id']]);
}
}
return $done;
}
/** Adresse eines Reiters der Plugin-Seite. */
function virenscan_tab_url(string $tab, array $extra = []): string
{
$q = ['p' => VIRENSCAN, 'page' => 'virenscan', 'tab' => $tab] + array_filter($extra, fn($v) => $v !== '' && $v !== null && $v !== 1);
return '/staff/plugin.php?' . http_build_query($q);
}
/** Reiterleiste. @param array<string,array{0:string,1:int}> $tabs Schlüssel => [Beschriftung, Anzahl (0 = keine Anzeige)] */
function virenscan_tabs(string $active, array $tabs): string
{
$html = '<div class="plugin-tabs" style="display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--border,#e3e9ee);margin:0 0 18px">';
foreach ($tabs as $key => [$label, $count]) {
$on = $key === $active;
$html .= '<a href="' . htmlspecialchars(virenscan_tab_url($key), ENT_QUOTES) . '" style="padding:8px 16px;border-radius:8px 8px 0 0;text-decoration:none;font-size:.9rem;'
. ($on ? 'background:var(--card-bg,#fff);border:1px solid var(--border,#e3e9ee);border-bottom-color:var(--card-bg,#fff);font-weight:600;color:inherit;position:relative;top:1px' : 'color:var(--muted,#64748b);border:1px solid transparent') . '">'
. htmlspecialchars($label)
. ($count > 0 ? ' <span style="background:#dc2626;color:#fff;border-radius:20px;padding:1px 8px;font-size:.72rem;font-weight:700">' . (int) $count . '</span>' : '')
. '</a>';
}
return $html . '</div>';
}
/** Deinstallation mit „Daten löschen“: Quarantäne-Dateien bleiben liegen, nur die Tabelle verschwindet. */
plugin_add_hook('plugin.uninstall.' . VIRENSCAN, function () {
try {
get_db()->exec('DROP TABLE IF EXISTS plugin_virenscan_files');
} catch (Throwable $e) {
}
});
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS plugin_virenscan_files (
id INT AUTO_INCREMENT PRIMARY KEY,
attachment_id INT NOT NULL,
ticket_id INT NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL,
signature VARCHAR(255) NULL,
message VARCHAR(255) NULL,
quarantined TINYINT(1) NOT NULL DEFAULT 0,
scanned_at DATETIME NOT NULL,
UNIQUE KEY idx_plugin_virenscan_attachment (attachment_id),
KEY idx_plugin_virenscan_status (status, quarantined)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS plugin_virenscan_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
attachment_id INTEGER NOT NULL,
ticket_id INTEGER NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL,
signature VARCHAR(255) NULL,
message VARCHAR(255) NULL,
quarantined INTEGER NOT NULL DEFAULT 0,
scanned_at DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_virenscan_attachment ON plugin_virenscan_files (attachment_id);
CREATE INDEX IF NOT EXISTS idx_plugin_virenscan_status ON plugin_virenscan_files (status, quarantined)
@@ -0,0 +1 @@
ALTER TABLE plugin_virenscan_files ADD COLUMN rep VARCHAR(12) NULL, ADD COLUMN sha256 VARCHAR(64) NULL
@@ -0,0 +1,2 @@
ALTER TABLE plugin_virenscan_files ADD COLUMN rep TEXT NULL;
ALTER TABLE plugin_virenscan_files ADD COLUMN sha256 TEXT NULL
@@ -0,0 +1,210 @@
<?php
// Seite des Virenscan-Plugins mit Reitern (Übersicht, Quarantäne, Geprüft, Einstellungen). Verfügbar: $staff, $pdo.
$isAdmin = ($staff['role'] ?? '') === 'admin';
if (!$isAdmin) { echo '<h1>Virenscan</h1><div class="flash error">Der Virenscan ist nur für Administratoren zugänglich.</div>'; return; }
$message = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
if (!$isAdmin) {
$error = 'Änderungen sind Administratoren vorbehalten.';
} else {
$action = $_POST['action'] ?? '';
if ($action === 'save') {
$mode = in_array($_POST['mode'] ?? '', ['auto', 'clamdscan', 'clamscan', 'builtin'], true) ? $_POST['mode'] : 'auto';
$binary = trim((string) ($_POST['binary'] ?? ''));
if ($binary !== '' && (preg_match('/[\r\n\0]/', $binary) || !is_file($binary) || !is_executable($binary))) {
$error = 'Der angegebene Pfad ist keine ausführbare Datei.';
} else {
plugin_set_setting(VIRENSCAN, 'mode', $mode);
plugin_set_setting(VIRENSCAN, 'binary', $binary);
plugin_set_setting(VIRENSCAN, 'timeout', (string) max(5, min(300, (int) ($_POST['timeout'] ?? 60))));
plugin_set_setting(VIRENSCAN, 'max_mb', (string) max(1, min(500, (int) ($_POST['max_mb'] ?? 25))));
plugin_set_setting(VIRENSCAN, 'block_on_error', !empty($_POST['block_on_error']) ? '1' : '0');
plugin_set_setting(VIRENSCAN, 'notify', !empty($_POST['notify']) ? '1' : '0');
foreach (['analysis', 'block_macros', 'block_encrypted', 'block_unscannable'] as $k) plugin_set_setting(VIRENSCAN, $k, !empty($_POST[$k]) ? '1' : '0');
plugin_set_setting(VIRENSCAN, 'vt_threshold', (string) max(1, min(30, (int) ($_POST['vt_threshold'] ?? 3))));
$newKey = trim((string) ($_POST['vt_key'] ?? ''));
if (!empty($_POST['vt_clear'])) plugin_set_setting(VIRENSCAN, 'vt_key', '');
elseif ($newKey !== '') {
if (!preg_match('/^[a-f0-9]{64}$/i', $newKey)) $error = 'Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).';
else { plugin_set_setting(VIRENSCAN, 'vt_key', encrypt_secret($newKey)); plugin_set_setting(VIRENSCAN, 'vt_error', ''); }
}
if (!$error) $message = 'Einstellungen gespeichert.';
}
} elseif ($action === 'test') {
$tmp = tempnam(sys_get_temp_dir(), 'vsc');
file_put_contents($tmp, virenscan_eicar());
$res = virenscan_scan_file($tmp);
@unlink($tmp);
$message = $res['status'] === 'infected'
? 'Test bestanden: Die Standard-Testdatei (EICAR) wurde erkannt' . ($res['signature'] ? ' (' . $res['signature'] . ')' : '') . '.'
: null;
if ($message === null) $error = 'Test nicht bestanden: Die Testdatei wurde nicht erkannt (' . ($res['message'] ?? $res['status']) . ').';
} elseif ($action === 'backlog') {
plugin_set_setting(VIRENSCAN, 'start_id', '0');
$message = 'Der gesamte Bestand wird jetzt nach und nach geprüft (jeweils bis zu 25 Dateien alle paar Minuten).';
} elseif ($action === 'release') {
$message = virenscan_release($pdo, (int) ($_POST['id'] ?? 0)) ? 'Datei freigegeben. Sie ist im Ticket wieder herunterladbar.' : null;
if ($message === null) $error = 'Die Datei konnte nicht freigegeben werden.';
} elseif ($action === 'delete') {
$message = virenscan_delete($pdo, (int) ($_POST['id'] ?? 0)) ? 'Datei endgültig gelöscht.' : null;
if ($message === null) $error = 'Die Datei konnte nicht gelöscht werden.';
}
}
}
$cfg = virenscan_config();
$status = virenscan_status();
$stats = virenscan_stats($pdo);
$labels = ['clean' => 'Sauber', 'infected' => 'Verseucht', 'error' => 'Fehler', 'skipped' => 'Übersprungen', 'blocked' => 'Gesperrt (Fehler)', 'released' => 'Freigegeben', 'deleted' => 'Gelöscht'];
// Reiter: Übersicht, Quarantäne (mit Anzahl), Geprüft, Einstellungen
$tabs = ['uebersicht' => ['Übersicht', 0], 'quarantaene' => ['Quarantäne', (int) $stats['quarantined']], 'geprueft' => ['Geprüft', 0], 'einstellungen' => ['Einstellungen', 0]];
$tab = isset($tabs[$_GET['tab'] ?? '']) ? (string) $_GET['tab'] : 'uebersicht';
// Geprüft: Filter und Seiten
$filter = isset($labels[$_GET['status'] ?? '']) ? (string) $_GET['status'] : '';
$perPage = 50;
$pageNo = max(1, (int) ($_GET['seite'] ?? 1));
if ($tab === 'geprueft') {
$cnt = $pdo->prepare('SELECT COUNT(*) FROM plugin_virenscan_files' . ($filter !== '' ? ' WHERE status = ?' : ''));
$cnt->execute($filter !== '' ? [$filter] : []);
$totalChecked = (int) $cnt->fetchColumn();
$pages = max(1, (int) ceil($totalChecked / $perPage));
$pageNo = min($pageNo, $pages);
$q = $pdo->prepare('SELECT f.*, t.ticket_number FROM plugin_virenscan_files f LEFT JOIN tickets t ON t.id = f.ticket_id' . ($filter !== '' ? ' WHERE f.status = ?' : '') . ' ORDER BY f.id DESC LIMIT ' . $perPage . ' OFFSET ' . (($pageNo - 1) * $perPage));
$q->execute($filter !== '' ? [$filter] : []);
$checked = $q->fetchAll();
}
if ($tab === 'quarantaene') {
$quarantine = $pdo->query('SELECT f.*, t.ticket_number FROM plugin_virenscan_files f LEFT JOIN tickets t ON t.id = f.ticket_id WHERE f.quarantined = 1 ORDER BY f.id DESC LIMIT 200')->fetchAll();
}
?>
<h1>Virenscan</h1>
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
<?= virenscan_tabs($tab, $tabs) ?>
<?php if ($tab === 'uebersicht'): ?>
<div class="card" style="max-width:820px;margin-bottom:16px">
<h3>Zustand</h3>
<p>
<?php if ($cfg['analysis']): ?><strong style="color:#15803d">✓ Dateianalyse aktiv</strong><?php else: ?><strong style="color:#b45309">Dateianalyse ausgeschaltet</strong><?php endif; ?>
· <?php if ($status['ok']): ?><strong style="color:#15803d">ClamAV bereit</strong> (<?= e($status['mode']) ?>, <?= e((string) $status['version']) ?>)<?php else: ?><span class="muted">ClamAV nicht vorhanden (optional)</span><?php endif; ?>
· <?php if ($cfg['vt_key'] !== ''): ?><strong style="color:#15803d">✓ Hash-Abfrage aktiv</strong> (heute <?= plugin_setting(VIRENSCAN, 'vt_day', '') === date('Y-m-d') ? (int) plugin_setting(VIRENSCAN, 'vt_count', '0') : 0 ?> von 450 Abfragen)<?php else: ?><span class="muted">Hash-Abfrage aus (kein Schlüssel)</span><?php endif; ?>
</p>
<p class="muted" style="font-size:.88rem"><?= e($status['message']) ?></p>
<?php if ($cfg['vt_key'] !== '' && plugin_setting(VIRENSCAN, 'vt_error', '') !== ''): ?><p style="color:#b91c1c;font-size:.88rem">Hash-Abfrage: <?= e((string) plugin_setting(VIRENSCAN, 'vt_error', '')) ?></p><?php endif; ?>
<p style="font-size:.9rem">Geprüft: <strong><?= (int) $stats['clean'] ?></strong> sauber · <strong style="color:#b91c1c"><?= (int) $stats['infected'] ?></strong> verseucht · <strong><?= (int) $stats['error'] ?></strong> Fehler · <strong><?= (int) $stats['skipped'] ?></strong> übersprungen · in Quarantäne: <strong><?= (int) $stats['quarantined'] ?></strong></p>
<p class="muted" style="font-size:.82rem">Letzter Hintergrundlauf: <?= e(plugin_setting(VIRENSCAN, 'last_run', '') ?: 'noch nicht') ?></p>
<?php if ((int) $stats['quarantined'] > 0): ?><p><a class="btn" href="<?= e(virenscan_tab_url('quarantaene')) ?>"><?= (int) $stats['quarantined'] ?> Datei(en) in Quarantäne prüfen</a></p><?php endif; ?>
</div>
<div class="card" style="max-width:820px;margin-bottom:16px;display:flex;gap:10px;flex-wrap:wrap;align-items:center">
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="test"><button type="submit" class="btn secondary">Prüfung testen (EICAR-Testdatei)</button></form>
<form method="post" onsubmit="return confirm('Alle bisherigen Anhänge nachträglich prüfen? Das läuft im Hintergrund und kann etwas dauern.')"><?= csrf_field() ?><input type="hidden" name="action" value="backlog"><button type="submit" class="btn secondary">Bestand nachträglich prüfen</button></form>
<span class="muted" style="font-size:.82rem">Ohne diesen Knopf werden nur Anhänge geprüft, die nach dem Einschalten des Plugins eingehen.</span>
</div>
<?php endif; ?>
<?php if ($tab === 'quarantaene'): ?>
<div class="card" style="margin-bottom:16px">
<h3>Quarantäne</h3>
<?php if (!$quarantine): ?><p class="muted">Keine gesperrten Dateien. Alles in Ordnung.</p><?php else: ?>
<table style="width:100%">
<thead><tr><th>Datei</th><th>Fund</th><th>Ticket</th><th>Wann</th><th></th></tr></thead>
<tbody>
<?php foreach ($quarantine as $q): ?>
<tr>
<td><?= e($q['original_name']) ?></td>
<td><?= e((string) ($q['signature'] ?: $q['message'])) ?></td>
<td><?php if ($q['ticket_number']): ?><a href="/staff/ticket_view.php?n=<?= e(urlencode((string) $q['ticket_number'])) ?>">#<?= e((string) $q['ticket_number']) ?></a><?php endif; ?></td>
<td class="muted"><?= e((string) $q['scanned_at']) ?></td>
<td style="white-space:nowrap">
<form method="post" style="display:inline" onsubmit="return confirm('Diese Datei wirklich freigeben? Sie war als verseucht erkannt.')"><?= csrf_field() ?><input type="hidden" name="action" value="release"><input type="hidden" name="id" value="<?= (int) $q['id'] ?>"><button class="btn secondary" type="submit">Freigeben</button></form>
<form method="post" style="display:inline" onsubmit="return confirm('Datei endgültig löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="<?= (int) $q['id'] ?>"><button class="btn danger" type="submit">Löschen</button></form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($tab === 'geprueft'): ?>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px">
<h3 style="margin:0">Geprüfte Dateien <span class="muted" style="font-weight:400;font-size:.85rem">(<?= (int) $totalChecked ?><?= $filter !== '' ? ' mit Ergebnis „' . e($labels[$filter]) . '“' : '' ?>)</span></h3>
<form method="get" style="display:flex;gap:6px;align-items:center">
<input type="hidden" name="p" value="<?= e(VIRENSCAN) ?>"><input type="hidden" name="page" value="virenscan"><input type="hidden" name="tab" value="geprueft">
<select name="status" onchange="this.form.submit()">
<option value="">Alle Ergebnisse</option>
<?php foreach ($labels as $k => $l): ?><option value="<?= e($k) ?>" <?= $filter === $k ? 'selected' : '' ?>><?= e($l) ?> (<?= (int) ($stats[$k] ?? 0) ?>)</option><?php endforeach; ?>
</select>
<noscript><button class="btn secondary" type="submit">Filtern</button></noscript>
</form>
</div>
<?php if (!$checked): ?><p class="muted">Keine Einträge.</p><?php else: ?>
<table style="width:100%"><thead><tr><th>Datei</th><th>Ergebnis</th><th>Hinweis</th><th>Ticket</th><th>Wann</th></tr></thead><tbody>
<?php foreach ($checked as $r): ?>
<tr>
<td><?= e($r['original_name']) ?></td>
<td><?= e($labels[$r['status']] ?? $r['status']) ?></td>
<td class="muted"><?= e((string) ($r['signature'] ?: $r['message'])) ?></td>
<td><?php if ($r['ticket_number']): ?><a href="/staff/ticket_view.php?n=<?= e(urlencode((string) $r['ticket_number'])) ?>">#<?= e((string) $r['ticket_number']) ?></a><?php endif; ?></td>
<td class="muted"><?= e((string) $r['scanned_at']) ?></td>
</tr>
<?php endforeach; ?>
</tbody></table>
<?php if ($pages > 1): ?>
<div style="display:flex;gap:6px;align-items:center;margin-top:12px;font-size:.88rem">
<?php if ($pageNo > 1): ?><a class="btn secondary" href="<?= e(virenscan_tab_url('geprueft', ['status' => $filter, 'seite' => $pageNo - 1])) ?>">← Neuere</a><?php endif; ?>
<span class="muted">Seite <?= (int) $pageNo ?> von <?= (int) $pages ?></span>
<?php if ($pageNo < $pages): ?><a class="btn secondary" href="<?= e(virenscan_tab_url('geprueft', ['status' => $filter, 'seite' => $pageNo + 1])) ?>">Ältere →</a><?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($tab === 'einstellungen'): ?>
<form method="post" class="card" style="max-width:820px;margin-bottom:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<h3>Einstellungen</h3>
<h4 style="margin:6px 0 4px">Dateianalyse (braucht nichts auf dem Server)</h4>
<p class="muted" style="font-size:.82rem">Sperrt Programme und Skripte (.exe, .js, .bat …), getarnte Dateien (zum Beispiel ein Programm als .pdf), doppelte Endungen, PDFs mit automatischem JavaScript, HTML/SVG mit Skripten und Archive mit solchen Dateien. Sie erkennt keine <em>neuen</em> Viren, die wie normale Dokumente aussehen; dafür dient die Hash-Abfrage unten oder ClamAV.</p>
<label style="display:block"><input type="checkbox" name="analysis" value="1" <?= $cfg['analysis'] ? 'checked' : '' ?>> Dateianalyse einschalten</label>
<label style="display:block"><input type="checkbox" name="block_macros" value="1" <?= $cfg['block_macros'] ? 'checked' : '' ?>> Office-Dateien mit Makros sperren (Word, Excel, PowerPoint)</label>
<label style="display:block"><input type="checkbox" name="block_encrypted" value="1" <?= $cfg['block_encrypted'] ? 'checked' : '' ?>> Passwortgeschützte ZIP-Dateien sperren (Inhalt lässt sich nicht prüfen)</label>
<label style="display:block"><input type="checkbox" name="block_unscannable" value="1" <?= $cfg['block_unscannable'] ? 'checked' : '' ?>> RAR-, 7z- und ähnliche Archive sperren (lassen sich nicht öffnen)</label>
<h4 style="margin:16px 0 4px">Hash-Abfrage bei VirusTotal (optional, kostenlos)</h4>
<p class="muted" style="font-size:.82rem">Es wird nur der Fingerabdruck (SHA-256) einer Datei gesendet, nie die Datei selbst oder Ticketinhalte. So werden bekannte Schadprogramme auch erkannt, wenn sie wie ein normales Dokument aussehen. Läuft im Hintergrund (höchstens 4 Abfragen pro Lauf, 450 pro Tag), die Sperre kann also einige Minuten nach dem Eingang kommen. Den kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com unter „API key“; die kostenlose Nutzung ist laut VirusTotal nicht für den gewerblichen Einsatz gedacht.</p>
<label for="vt_key">API-Schlüssel <?= $cfg['vt_key'] !== '' ? '– gespeichert, leer lassen zum Behalten' : '' ?></label>
<input type="password" id="vt_key" name="vt_key" value="" autocomplete="new-password">
<?php if ($cfg['vt_key'] !== ''): ?><label style="font-size:.82rem"><input type="checkbox" name="vt_clear" value="1"> Schlüssel entfernen und Abfrage ausschalten</label><?php endif; ?>
<div style="margin-top:8px"><label for="vt_threshold">Sperren ab so vielen Scannern, die Schadsoftware melden</label><input type="number" id="vt_threshold" name="vt_threshold" min="1" max="30" value="<?= (int) $cfg['vt_threshold'] ?>" style="max-width:120px"></div>
<h4 style="margin:16px 0 4px">ClamAV (optional, nur wenn schon auf dem Server vorhanden)</h4>
<label for="mode">Prüfprogramm</label>
<select id="mode" name="mode">
<option value="auto" <?= $cfg['mode'] === 'auto' ? 'selected' : '' ?>>Automatisch (clamdscan, sonst clamscan, sonst nur Dateianalyse)</option>
<option value="clamdscan" <?= $cfg['mode'] === 'clamdscan' ? 'selected' : '' ?>>clamdscan (ClamAV-Dienst, schnell)</option>
<option value="clamscan" <?= $cfg['mode'] === 'clamscan' ? 'selected' : '' ?>>clamscan (einzelnes Programm, langsamer)</option>
<option value="builtin" <?= $cfg['mode'] === 'builtin' ? 'selected' : '' ?>>Kein ClamAV verwenden</option>
</select>
<label for="binary" style="margin-top:10px">Eigener Pfad zum Programm (optional)</label>
<input type="text" id="binary" name="binary" value="<?= e($cfg['binary']) ?>" placeholder="/usr/bin/clamdscan">
<div style="display:flex;gap:14px;margin-top:10px;flex-wrap:wrap">
<div><label for="timeout">Zeitlimit je Datei (Sekunden)</label><input type="number" id="timeout" name="timeout" min="5" max="300" value="<?= (int) $cfg['timeout'] ?>" style="max-width:140px"></div>
<div><label for="max_mb">Größere Dateien nicht prüfen (MB)</label><input type="number" id="max_mb" name="max_mb" min="1" max="500" value="<?= (int) $cfg['max_mb'] ?>" style="max-width:140px"></div>
</div>
<label style="display:block;margin-top:10px"><input type="checkbox" name="block_on_error" value="1" <?= $cfg['block_on_error'] ? 'checked' : '' ?>> Datei sperren, wenn die Prüfung fehlschlägt (sicherer, kann aber auch saubere Dateien sperren)</label>
<label style="display:block"><input type="checkbox" name="notify" value="1" <?= $cfg['notify'] ? 'checked' : '' ?>> E-Mail an den Administrator, wenn eine Datei gesperrt wird</label>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
<?php endif; ?>
+9
View File
@@ -0,0 +1,9 @@
{
"slug": "virenscan",
"name": "Anhänge-Virenscan",
"version": "1.2.0",
"description": "Prüft Anhänge von Tickets und sperrt gefährliche Dateien, bevor ein Mitarbeiter sie öffnet. Eingebaute Dateianalyse ohne Installation auf dem Server (Programme, Skripte, getarnte Dateien, Makros, PDF-Tricks, Archive), optionale Hash-Abfrage bei VirusTotal und ClamAV, falls vorhanden. Gesperrte Dateien liegen in Quarantäne und lassen sich freigeben oder löschen. Nur für Administratoren.",
"author": "DeskHub",
"requires": "1.0.0",
"permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
}
+16
View File
@@ -0,0 +1,16 @@
<?php
// Virenscan-Plugin: prüft Anhänge neuer Tickets und Antworten mit ClamAV, den Rest nebenbei im Hintergrund.
require_once __DIR__ . '/lib.php';
virenscan_ensure_schema();
foreach (['ticket.created', 'ticket.replied'] as $hook) {
plugin_add_hook($hook, function (array $payload) {
if (!empty($payload['ticketId'])) virenscan_scan_ticket(get_db(), (int) $payload['ticketId']);
});
}
plugin_add_hook('cron.run', function () {
virenscan_tick(get_db());
});
// Seite im Adminportal, nur für Administratoren (die Seite prüft das zusätzlich selbst)
plugin_register_menu('Virenscan', 'virenscan', null, 'admin');