diff --git a/theme/plugins/vertretung-1.1.0.zip b/theme/plugins/vertretung-1.1.0.zip
new file mode 100644
index 00000000..6aec19f9
Binary files /dev/null and b/theme/plugins/vertretung-1.1.0.zip differ
diff --git a/theme/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql b/theme/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql
new file mode 100644
index 00000000..d6a172a6
--- /dev/null
+++ b/theme/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS plugin_vertretung_absences (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ staff_id INTEGER NOT NULL,
+ substitute_id INTEGER NOT NULL,
+ start_date TEXT NOT NULL,
+ end_date TEXT NOT NULL,
+ note TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'planned',
+ created_by INTEGER NULL,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_status ON plugin_vertretung_absences (status, start_date);
+CREATE TABLE IF NOT EXISTS plugin_vertretung_moves (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ absence_id INTEGER NOT NULL,
+ ticket_id INTEGER NOT NULL,
+ from_staff_id INTEGER NOT NULL,
+ to_staff_id INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ returned_at TEXT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_moves_absence ON plugin_vertretung_moves (absence_id, returned_at)
diff --git a/theme/plugins/vertretung/pages/vertretung.php b/theme/plugins/vertretung/pages/vertretung.php
new file mode 100644
index 00000000..24e29a15
--- /dev/null
+++ b/theme/plugins/vertretung/pages/vertretung.php
@@ -0,0 +1,93 @@
+query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
+$labels = vertretung_status_labels();
+$names = [];
+foreach ($people as $p) $names[(int) $p['id']] = $p['name'];
+$name = fn($id) => e($names[(int) $id] ?? vertretung_name($pdo, (int) $id));
+$current = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id AND m.returned_at IS NULL) AS holding FROM plugin_vertretung_absences a WHERE a.status IN ('planned','active') ORDER BY a.start_date, a.id")->fetchAll();
+$past = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved FROM plugin_vertretung_absences a WHERE a.status NOT IN ('planned','active') ORDER BY a.id DESC LIMIT 20")->fetchAll();
+?>
+
Abwesenheit und Vertretung
+= e($message) ?>
+= e($error) ?>
+
+
+
+
+
Laufend und geplant
+
Keine Abwesenheiten eingetragen.
+
+ | Wer | Vertretung | Zeitraum | Stand | Tickets | Die Vertretung sieht | |
+
+ prepare('SELECT d.name FROM departments d WHERE d.id = (SELECT department_id FROM staff WHERE id = ?) OR d.id IN (SELECT department_id FROM staff_department WHERE staff_id = ?) ORDER BY d.name');
+ $dq->execute([(int) $a['staff_id'], (int) $a['staff_id']]);
+ $deptNames = $dq->fetchAll(PDO::FETCH_COLUMN);
+ $absentAdmin = ($pdo->query('SELECT role FROM staff WHERE id = ' . (int) $a['staff_id'])->fetchColumn() === 'admin');
+ ?>
+
+ | = $name($a['staff_id']) ?> = e((string) $a['note']) ?> |
+ = $name($a['substitute_id']) ?> |
+ = e(vertretung_date((string) $a['start_date'])) ?> – = e(vertretung_date((string) $a['end_date'])) ?> |
+ = e($labels[$a['status']] ?? $a['status']) ?> |
+ = $a['status'] === 'active' ? (int) $a['holding'] . ' bei der Vertretung' : '–' ?> |
+ = $absentAdmin ? 'Abteilungen der übernommenen Tickets' : ($deptNames ? e(implode(', ', $deptNames)) : '–') ?> |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
Frühere Abwesenheiten
+
+ | Wer | Vertretung | Zeitraum | Ergebnis | Weitergegeben |
+
+
+ | = $name($a['staff_id']) ?> | = $name($a['substitute_id']) ?> | = e(vertretung_date((string) $a['start_date'])) ?> – = e(vertretung_date((string) $a['end_date'])) ?> | = e($labels[$a['status']] ?? $a['status']) ?> | = (int) $a['moved'] ?> Tickets |
+
+
+
+
+
diff --git a/theme/plugins/vertretung/plugin.json b/theme/plugins/vertretung/plugin.json
new file mode 100644
index 00000000..ad6ad5a5
--- /dev/null
+++ b/theme/plugins/vertretung/plugin.json
@@ -0,0 +1,9 @@
+{
+ "slug": "vertretung",
+ "name": "Urlaub und Vertretung",
+ "version": "1.1.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. Die Vertretung sieht während der Abwesenheit auch die Abteilungen der abwesenden Person, selbst ohne eigenen Zugang. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf (Server ab 1.0.55).",
+ "author": "DeskHub",
+ "requires": "1.0.55",
+ "permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
+}
diff --git a/theme/plugins/vertretung/plugin.php b/theme/plugins/vertretung/plugin.php
new file mode 100644
index 00000000..0e5a4f87
--- /dev/null
+++ b/theme/plugins/vertretung/plugin.php
@@ -0,0 +1,16 @@
+.json, for example lang/fr.json.",
+ "VirusTotal-Anhangsprüfung": "VirusTotal attachment check",
+ "Hält jeden Anhang eines Tickets zurück, bis VirusTotal ihn geprüft hat: Erst wird nur der Fingerabdruck (SHA-256) abgefragt, die Datei selbst verlässt den Server nicht. Ist die Datei sauber, wird sie freigegeben; melden mehrere Scanner Schadsoftware, bleibt sie gesperrt (Quarantäne, Hinweis im Ticket, Mail an den Administrator). Unbekannte Dateien werden zur manuellen Prüfung zurückgehalten, freigegeben oder (nur wenn ausdrücklich eingeschaltet) zur Analyse bei VirusTotal hochgeladen. Arbeitet auch neben dem Plugin „Anhänge-Virenscan“. Nur für Administratoren.": "Holds back every ticket attachment until VirusTotal has checked it: first only the fingerprint (SHA-256) is looked up; the file itself does not leave the server. If the file is clean it is released; if several scanners report malware it stays blocked (quarantine, note in the ticket, email to the administrator). Unknown files are held back for manual review, released, or (only if explicitly switched on) uploaded to VirusTotal for analysis. Also works alongside the “Attachment virus scan” plugin. For administrators only.",
+ "VirusTotal": "VirusTotal",
+ "Zustand": "Status",
+ "Noch kein Schlüssel.": "No key yet.",
+ "Ohne VirusTotal-Schlüssel werden Anhänge": "Without a VirusTotal key, attachments are",
+ "nicht": "not",
+ "zurückgehalten und nicht geprüft. Einen kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com im Profil unter „API Key“.": "held back and not checked. A free key is available after signing in at virustotal.com, in your profile under “API Key”.",
+ "✓ Aktiv.": "✓ Active.",
+ "Neue Anhänge sind gesperrt, bis VirusTotal sie geprüft hat.": "New attachments are blocked until VirusTotal has checked them.",
+ "warten ·": "waiting ·",
+ "gesperrt ·": "blocked ·",
+ "unbekannt ·": "unknown ·",
+ "mit Fehler ·": "with error ·",
+ "freigegeben": "released",
+ "Abfragen heute: {#} von {#} (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: {}.": "Queries today: {} of {} (the free key allows 4 per minute and 500 per day). Last run: {}.",
+ "Abfragen heute: {#} von {#} (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: noch nicht.": "Queries today: {} of {} (the free key allows 4 per minute and 500 per day). Last run: not yet.",
+ "Vorgehen: Jeder neue Anhang wird sofort zurückgehalten (nicht herunterladbar). Dann fragt das Plugin bei VirusTotal nur den": "Procedure: every new attachment is held back immediately (not downloadable). The plugin then asks VirusTotal only for the",
+ "Fingerabdruck (SHA-256)": "fingerprint (SHA-256)",
+ "der Datei ab. Ist die Datei bekannt und sauber, wird sie freigegeben; melden mindestens {#} Scanner Schadsoftware, bleibt sie gesperrt. Anhänge, die schon vor dem Einschalten da waren, bleiben unberührt.": "of the file. If the file is known and clean it is released; if at least {} scanners report malware it stays blocked. Attachments that existed before the plugin was switched on are left untouched.",
+ "Einstellungen": "Settings",
+ "VirusTotal-Schlüssel (API Key)": "VirusTotal key (API key)",
+ "64 Zeichen": "64 characters",
+ "Schlüssel entfernen (Anhänge werden dann nicht mehr geprüft)": "Remove key (attachments are then no longer checked)",
+ "Sperren ab so vielen Scannern, die Schadsoftware melden": "Block from this many scanners that report malware",
+ "Dateien, die VirusTotal nicht kennt": "Files that VirusTotal does not know",
+ "Zurückhalten, bis ein Administrator sie prüft und freigibt (sicher)": "Hold back until an administrator reviews and releases them (safe)",
+ "Freigeben (bequem, aber neue Schadsoftware kennt VirusTotal oft noch nicht)": "Release (convenient, but VirusTotal often does not know new malware yet)",
+ "Unbekannte Dateien zur Analyse bei VirusTotal hochladen (bis 32 MB).": "Upload unknown files to VirusTotal for analysis (up to 32 MB).",
+ "Achtung Datenschutz:": "Privacy warning:",
+ "Hochgeladene Dateien werden bei VirusTotal gespeichert und stehen Sicherheitsfirmen und zahlenden Kunden von VirusTotal zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten. Ausgeschaltet verlässt": "Uploaded files are stored at VirusTotal and are available to security companies and paying customers of VirusTotal. Ticket attachments can contain personal content. When switched off,",
+ "keine Datei": "no file",
+ "den Server, nur der Fingerabdruck wird abgefragt.": "leaves the server; only the fingerprint is looked up.",
+ "Wenn VirusTotal nach mehreren Versuchen nicht erreichbar ist": "If VirusTotal cannot be reached after several attempts",
+ "Zurückhalten (sicher)": "Hold back (safe)",
+ "Mail an den Administrator, wenn ein Anhang gesperrt wird": "Email the administrator when an attachment is blocked",
+ "Speichern": "Save",
+ "Schlüssel testen": "Test key",
+ "Fragt bei VirusTotal den Fingerabdruck der Standard-Testdatei (EICAR) ab. Es wird nichts hochgeladen. Vorher speichern.": "Looks up the fingerprint of the standard test file (EICAR) at VirusTotal. Nothing is uploaded. Save first.",
+ "Zurückgehaltene und gesperrte Anhänge": "Held-back and blocked attachments",
+ "Nichts zurückgehalten.": "Nothing held back.",
+ "Zuletzt geprüft": "Recently checked",
+ "Noch nichts geprüft.": "Nothing checked yet.",
+ "Datei": "File",
+ "Ticket": "Ticket",
+ "Größe": "Size",
+ "Hinweis": "Note",
+ "Ergebnis": "Result",
+ "Wann": "When",
+ "Freigeben": "Release",
+ "Erneut prüfen": "Check again",
+ "Löschen": "Delete",
+ "Diese Datei freigeben? Sie wird für Mitarbeiter herunterladbar.": "Release this file? Staff will be able to download it.",
+ "Diese Datei endgültig löschen?": "Delete this file permanently?",
+ "Wartet auf VirusTotal": "Waiting for VirusTotal",
+ "Wird bei VirusTotal analysiert": "Being analysed at VirusTotal",
+ "Unbekannt (zurückgehalten)": "Unknown (held back)",
+ "Fehler (zurückgehalten)": "Error (held back)",
+ "Gesperrt (Schadsoftware)": "Blocked (malware)",
+ "Sauber, freigegeben": "Clean, released",
+ "Von Hand freigegeben": "Released by hand",
+ "Gelöscht": "Deleted",
+ "Gespeichert.": "Saved.",
+ "Die Datei ist freigegeben.": "The file has been released.",
+ "Die Datei ließ sich nicht freigeben.": "The file could not be released.",
+ "Die Datei ist gelöscht.": "The file has been deleted.",
+ "Die Datei ließ sich nicht löschen.": "The file could not be deleted.",
+ "Die Datei wird beim nächsten Lauf noch einmal geprüft.": "The file will be checked again on the next run.",
+ "Das ging nicht.": "That did not work.",
+ "Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).": "The VirusTotal key consists of 64 characters (0–9, a–f).",
+ "Bitte zuerst einen Schlüssel eintragen und speichern.": "Please enter a key and save it first.",
+ "Der Schlüssel funktioniert: Die Standard-Testdatei (EICAR) wurde von VirusTotal als gefährlich erkannt ({} von {} Scannern).": "The key works: VirusTotal recognised the standard test file (EICAR) as dangerous ({} of {} scanners).",
+ "Das Abfrage-Limit von VirusTotal ist gerade erreicht. Bitte in einer Minute noch einmal versuchen.": "The VirusTotal query limit has just been reached. Please try again in a minute.",
+ "Der Schlüssel wird angenommen, die Testdatei wurde aber unerwartet bewertet ({}).": "The key is accepted, but the test file was rated unexpectedly ({}).",
+ "Die Abfrage ist fehlgeschlagen.": "The query failed.",
+ "Keine Berechtigung.": "No permission.",
+ "VirusTotal: {} von {} Scannern melden Schadsoftware": "VirusTotal: {} of {} scanners report malware",
+ "VirusTotal: {} von {} Scannern melden etwas": "VirusTotal: {} of {} scanners report something",
+ "VirusTotal: keine Funde": "VirusTotal: no findings",
+ "Der VirusTotal-Schlüssel wird abgelehnt.": "The VirusTotal key is rejected.",
+ "VirusTotal nicht erreichbar (Status {}).": "VirusTotal not reachable (status {}).",
+ "Unerwartete Antwort von VirusTotal.": "Unexpected response from VirusTotal.",
+ "Upload zu VirusTotal fehlgeschlagen (Status {}).": "Upload to VirusTotal failed (status {}).",
+ "Zur Analyse bei VirusTotal hochgeladen": "Uploaded to VirusTotal for analysis",
+ "Die Analyse bei VirusTotal wurde nicht rechtzeitig fertig.": "The analysis at VirusTotal was not finished in time.",
+ "Bei VirusTotal unbekannt, freigegeben (Einstellung)": "Unknown at VirusTotal, released (setting)",
+ "Bei VirusTotal unbekannt und zu groß für den Upload (über 32 MB).": "Unknown at VirusTotal and too large for the upload (over 32 MB).",
+ "Bei VirusTotal unbekannt: bitte selbst prüfen und freigeben oder löschen.": "Unknown at VirusTotal: please check it yourself and release or delete it.",
+ "VirusTotal nicht erreichbar, freigegeben (Einstellung)": "VirusTotal not reachable, released (setting)",
+ "Von Hand freigegeben am {}": "Released by hand on {}",
+ "Gelöscht am {}": "Deleted on {}",
+ "Datei nicht gefunden.": "File not found.",
+ "Die Datei konnte nicht zurückgehalten werden (Schreibrechte im Ordner uploads prüfen).": "The file could not be held back (check write permissions in the uploads folder).",
+ "{}: Das Tageslimit des VirusTotal-Schlüssels ist erreicht, es geht morgen weiter.": "{}: The daily limit of the VirusTotal key has been reached; it continues tomorrow.",
+ "{}: Der VirusTotal-Schlüssel wird abgelehnt.": "{}: The VirusTotal key is rejected.",
+ "{}: VirusTotal nicht erreichbar (Status {}).": "{}: VirusTotal not reachable (status {}).",
+ "{}: Unerwartete Antwort von VirusTotal.": "{}: Unexpected response from VirusTotal."
+}
diff --git a/theme/plugins/virustotal/lib.php b/theme/plugins/virustotal/lib.php
new file mode 100644
index 00000000..5fbddecd
--- /dev/null
+++ b/theme/plugins/virustotal/lib.php
@@ -0,0 +1,420 @@
+query('SELECT sha256, analysis_id FROM plugin_virustotal_files LIMIT 1');
+ } catch (Throwable $e) {
+ try {
+ plugin_run_migrations(get_db(), VT);
+ } catch (Throwable $e2) {
+ error_log('virustotal schema: ' . $e2->getMessage());
+ }
+ }
+}
+
+function vt_config(): array
+{
+ $key = '';
+ $enc = (string) plugin_setting(VT, 'key_enc', '');
+ if ($enc !== '') { try { $key = decrypt_secret($enc); } catch (Throwable $e) { $key = ''; } }
+ $unknown = (string) plugin_setting(VT, 'unknown', 'hold');
+ return [
+ 'key' => $key,
+ 'threshold' => max(1, min(30, (int) plugin_setting(VT, 'threshold', '3'))),
+ 'unknown' => in_array($unknown, ['hold', 'allow'], true) ? $unknown : 'hold',
+ 'upload' => plugin_setting(VT, 'upload', '0') === '1',
+ 'notify' => plugin_setting(VT, 'notify', '1') === '1',
+ 'on_error' => plugin_setting(VT, 'on_error', 'hold') === 'allow' ? 'allow' : 'hold',
+ ];
+}
+
+function vt_uploads_dir(): string
+{
+ return rtrim(str_replace('\\', '/', (string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads')), '/');
+}
+
+/** Ordner für zurückgehaltene und gesperrte Dateien (nicht von außen erreichbar). */
+function vt_hold_dir(): string
+{
+ $dir = vt_uploads_dir() . '/vt-pruefung';
+ 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;
+}
+
+/**
+ * HTTP-Aufruf zu VirusTotal (für Tests austauschbar über $GLOBALS['virustotal_http']). Nur Adressen der VirusTotal-Schnittstelle.
+ * @param array{file?:string,name?:string} $opts bei POST: Datei für den Upload
+ * @return array{status:int,body:string}
+ */
+function vt_http(string $method, string $path, string $key, array $opts = []): array
+{
+ $url = VT_API . '/' . ltrim($path, '/');
+ if (isset($GLOBALS['virustotal_http']) && is_callable($GLOBALS['virustotal_http'])) return ($GLOBALS['virustotal_http'])($method, $url, $key, $opts);
+ if (!function_exists('curl_init')) return ['status' => 0, 'body' => ''];
+ $body = '';
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => $method === 'POST' ? 120 : 12, CURLOPT_CONNECTTIMEOUT => 8,
+ CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_HTTPHEADER => ['x-apikey: ' . $key, 'Accept: application/json', 'User-Agent: DeskHub-VirusTotal'],
+ CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int {
+ $body .= $data;
+ return strlen($body) > 1048576 ? 0 : strlen($data);
+ },
+ ]);
+ if ($method === 'POST') {
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => new CURLFile((string) ($opts['file'] ?? ''), 'application/octet-stream', (string) ($opts['name'] ?? 'datei.bin'))]);
+ }
+ curl_exec($ch);
+ $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ return ['status' => $status, 'body' => $body];
+}
+
+/** Wertet Scan-Zahlen aus. @return array{verdict:string,malicious:int,engines:int} verdict: bad, clean oder none (noch keine Analyse) */
+function vt_verdict(array $stats, int $threshold): array
+{
+ $malicious = (int) ($stats['malicious'] ?? 0);
+ $engines = 0;
+ foreach (['malicious', 'suspicious', 'undetected', 'harmless'] as $k) $engines += (int) ($stats[$k] ?? 0);
+ if ($engines === 0) return ['verdict' => 'none', 'malicious' => 0, 'engines' => 0];
+ return ['verdict' => $malicious >= $threshold ? 'bad' : 'clean', 'malicious' => $malicious, 'engines' => $engines];
+}
+
+/** Fragt den Fingerabdruck einer Datei ab. @return array{result:string,detail:?string,malicious:int,engines:int} result: bad, clean, unknown, none, limit, key, error */
+function vt_lookup(string $sha256, string $key, int $threshold): array
+{
+ $r = vt_http('GET', 'files/' . $sha256, $key);
+ $base = ['detail' => null, 'malicious' => 0, 'engines' => 0];
+ if ($r['status'] === 404) return ['result' => 'unknown'] + $base;
+ if ($r['status'] === 429) return ['result' => 'limit'] + $base;
+ if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'] + $base;
+ if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'] + $base;
+ $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.'] + $base;
+ $v = vt_verdict($stats, $threshold);
+ $label = (string) ($j['data']['attributes']['popular_threat_classification']['suggested_threat_label'] ?? '');
+ $detail = $v['verdict'] === 'bad' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden Schadsoftware' . ($label !== '' ? ' (' . mb_substr($label, 0, 60) . ')' : '')
+ : ($v['verdict'] === 'clean' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden etwas' : null);
+ return ['result' => $v['verdict'], 'detail' => $detail, 'malicious' => $v['malicious'], 'engines' => $v['engines']];
+}
+
+/** Lädt eine Datei zur Analyse hoch. @return array{ok:bool,id:?string,detail:?string} */
+function vt_upload(string $path, string $name, string $key): array
+{
+ $r = vt_http('POST', 'files', $key, ['file' => $path, 'name' => $name]);
+ if ($r['status'] === 429) return ['ok' => false, 'id' => null, 'detail' => 'limit'];
+ if ($r['status'] === 401 || $r['status'] === 403) return ['ok' => false, 'id' => null, 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'];
+ $j = json_decode($r['body'], true);
+ $id = (string) ($j['data']['id'] ?? '');
+ if ($r['status'] !== 200 || $id === '') return ['ok' => false, 'id' => null, 'detail' => 'Upload zu VirusTotal fehlgeschlagen (Status ' . $r['status'] . ').'];
+ return ['ok' => true, 'id' => mb_substr($id, 0, 250), 'detail' => null];
+}
+
+/** Fragt eine Analyse nach einem Upload ab. @return array{result:string,detail:?string,malicious:int,engines:int} result: bad, clean, wait, none, limit, key, error */
+function vt_analysis(string $id, string $key, int $threshold): array
+{
+ $r = vt_http('GET', 'analyses/' . rawurlencode($id), $key);
+ $base = ['detail' => null, 'malicious' => 0, 'engines' => 0];
+ if ($r['status'] === 429) return ['result' => 'limit'] + $base;
+ if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'] + $base;
+ if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'] + $base;
+ $j = json_decode($r['body'], true);
+ $attr = $j['data']['attributes'] ?? null;
+ if (!is_array($attr)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.'] + $base;
+ if (($attr['status'] ?? '') !== 'completed') return ['result' => 'wait'] + $base;
+ $v = vt_verdict((array) ($attr['stats'] ?? []), $threshold);
+ return ['result' => $v['verdict'], 'detail' => $v['verdict'] === 'bad' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden Schadsoftware' : null, 'malicious' => $v['malicious'], 'engines' => $v['engines']];
+}
+
+// ── Anhänge zurückhalten ───────────────────────────────────────────────────
+
+/** Ab welchem Anhang wird geprüft? Bestand vor dem Einschalten bleibt unberührt. */
+function vt_start_id(PDO $pdo): int
+{
+ $v = plugin_setting(VT, 'start_id', null);
+ if ($v === null) {
+ $v = (string) (int) $pdo->query('SELECT COALESCE(MAX(id), 0) FROM ticket_attachments')->fetchColumn();
+ plugin_set_setting(VT, 'start_id', $v);
+ }
+ return (int) $v;
+}
+
+/** Anhänge ohne Eintrag (noch nicht zurückgehalten), optional nur eines Tickets. */
+function vt_unseen(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_virustotal_files)";
+ $params = [vt_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();
+}
+
+/** Hält einen Anhang zurück (Datei in den Prüfordner, Anhang nicht mehr herunterladbar) und legt den Eintrag an. @return string pending oder error */
+function vt_hold(PDO $pdo, array $att): string
+{
+ $now = db_now();
+ $from = vt_uploads_dir() . '/' . basename((string) $att['stored_name']);
+ $insert = fn(string $status, ?string $sha, ?string $detail) => $pdo->prepare('INSERT INTO plugin_virustotal_files (attachment_id, ticket_id, original_name, stored_name, filesize, sha256, status, detail, tries, next_try, created_at) VALUES (?,?,?,?,?,?,?,?,0,?,?)')
+ ->execute([(int) $att['id'], $att['ticket_id'] ?? null, mb_substr((string) $att['original_name'], 0, 255), mb_substr((string) $att['stored_name'], 0, 255), (int) ($att['filesize'] ?? 0), $sha, $status, $detail, $now, $now]);
+ if (!is_file($from)) { $insert('error', null, 'Datei nicht gefunden.'); return 'error'; }
+ $sha = hash_file('sha256', $from);
+ $to = vt_hold_dir() . '/' . basename((string) $att['stored_name']);
+ if (!@rename($from, $to) && (!@copy($from, $to) || !@unlink($from))) {
+ // Ohne Zurückhalten gäbe es keine Sperre: ehrlich vermerken statt so zu tun
+ $insert('error', $sha, 'Die Datei konnte nicht zurückgehalten werden (Schreibrechte im Ordner uploads prüfen).');
+ return 'error';
+ }
+ $pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[IN PRÜFUNG] ' . $att['original_name'], 0, 255), (int) $att['id']]);
+ $insert('pending', $sha, null);
+ return 'pending';
+}
+
+/** Hält die neuen Anhänge eines Tickets zurück und vermerkt es einmal im Ticket. @return int Anzahl */
+function vt_hold_ticket(PDO $pdo, int $ticketId): int
+{
+ $n = 0;
+ $names = [];
+ foreach (vt_unseen($pdo, 25, $ticketId) as $att) {
+ if (vt_hold($pdo, $att) === 'pending') { $n++; $names[] = (string) $att['original_name']; }
+ }
+ if ($n > 0) {
+ $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")
+ ->execute([$ticketId, e('Anhang' . ($n > 1 ? 'e' : '') . ' wird bei VirusTotal geprüft und ist bis dahin gesperrt: ' . implode(', ', array_map(fn($x) => '„' . $x . '“', array_slice($names, 0, 5))) . ($n > 5 ? ' …' : '')), db_now()]);
+ log_activity($pdo, $ticketId, null, 'VirusTotal: ' . $n . ' Anhang/Anhänge zur Prüfung zurückgehalten');
+ }
+ return $n;
+}
+
+function vt_row(PDO $pdo, int $id): ?array
+{
+ $s = $pdo->prepare('SELECT * FROM plugin_virustotal_files WHERE id = ?');
+ $s->execute([$id]);
+ return $s->fetch() ?: null;
+}
+
+/** Gibt eine zurückgehaltene Datei frei (sauber, bewusst freigegeben oder zugelassen). */
+function vt_release(PDO $pdo, array $row, string $status, string $detail): bool
+{
+ $from = vt_hold_dir() . '/' . basename((string) $row['stored_name']);
+ $to = vt_uploads_dir() . '/' . basename((string) $row['stored_name']);
+ if (is_file($from) && !@rename($from, $to) && (!@copy($from, $to) || !@unlink($from))) 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_virustotal_files SET status = ?, detail = ?, checked_at = ? WHERE id = ?')->execute([$status, mb_substr($detail, 0, 250), db_now(), (int) $row['id']]);
+ if ($row['ticket_id']) log_activity($pdo, (int) $row['ticket_id'], null, 'VirusTotal: Anhang freigegeben: ' . $row['original_name'] . ' (' . $detail . ')');
+ return true;
+}
+
+/** Sperrt eine Datei dauerhaft (bleibt im Prüfordner), vermerkt es im Ticket und benachrichtigt den Administrator. */
+function vt_block(PDO $pdo, array $row, string $detail, int $malicious = 0, int $engines = 0): void
+{
+ $pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[GESPERRT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]);
+ $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'bad', detail = ?, malicious = ?, engines = ?, checked_at = ? WHERE id = ?")->execute([mb_substr($detail, 0, 250), $malicious, $engines, db_now(), (int) $row['id']]);
+ $ticketId = (int) ($row['ticket_id'] ?? 0);
+ if ($ticketId > 0) {
+ $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")
+ ->execute([$ticketId, e('Der Anhang „' . $row['original_name'] . '“ wurde von VirusTotal als gefährlich eingestuft (' . $detail . ') und bleibt gesperrt. Er liegt in der Quarantäne und lässt sich unter VirusTotal prüfen oder löschen.'), db_now()]);
+ log_activity($pdo, $ticketId, null, 'VirusTotal: Anhang gesperrt: ' . $row['original_name'] . ' (' . $detail . ')');
+ if (function_exists('apply_tag_to_ticket')) apply_tag_to_ticket($pdo, $ticketId, 'Virus-Verdacht');
+ }
+ if (vt_config()['notify']) vt_notify($pdo, $ticketId, (string) $row['original_name'], $detail);
+}
+
+function vt_notify(PDO $pdo, int $ticketId, string $name, string $detail): 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, 'VirusTotal: Anhang in Ticket #' . $number . ' gesperrt', 'Der Anhang ' . e($name) . ' in Ticket #' . e($number) . ' wurde gesperrt (' . e($detail) . ').
Er liegt in der Quarantäne. Prüfen oder löschen kannst du ihn unter VirusTotal im Mitarbeiterbereich.
');
+ } catch (Throwable $e) {
+ // Mailfehler dürfen das Sperren nie verhindern
+ }
+}
+
+/** Löscht eine zurückgehaltene oder gesperrte Datei endgültig (der Anhang bleibt als „gelöscht“ im Ticket vermerkt). */
+function vt_delete(PDO $pdo, int $rowId): bool
+{
+ $row = vt_row($pdo, $rowId);
+ if (!$row || !in_array($row['status'], ['bad', 'unknown', 'error', 'pending', 'analyzing'], true)) return false;
+ $file = vt_hold_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_virustotal_files SET status = 'deleted', detail = ?, checked_at = ? WHERE id = ?")->execute(['Gelöscht am ' . db_now(), db_now(), $rowId]);
+ return true;
+}
+
+/** Von Hand freigeben (zum Beispiel bei Fehlalarm oder unbekannter Datei). */
+function vt_release_manual(PDO $pdo, int $rowId): bool
+{
+ $row = vt_row($pdo, $rowId);
+ if (!$row || !in_array($row['status'], ['bad', 'unknown', 'error', 'pending', 'analyzing'], true)) return false;
+ return vt_release($pdo, $row, 'released', 'Von Hand freigegeben am ' . db_now());
+}
+
+/** Noch einmal prüfen lassen (zum Beispiel nach einem Fehler). */
+function vt_recheck(PDO $pdo, int $rowId): bool
+{
+ $row = vt_row($pdo, $rowId);
+ if (!$row || !in_array($row['status'], ['unknown', 'error'], true)) return false;
+ $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'pending', tries = 0, next_try = ?, detail = NULL, analysis_id = NULL WHERE id = ?")->execute([db_now(), $rowId]);
+ return true;
+}
+
+// ── Hintergrundlauf ────────────────────────────────────────────────────────
+
+function vt_day_used(): int
+{
+ $today = date('Y-m-d');
+ if (plugin_setting(VT, 'day', '') !== $today) { plugin_set_setting(VT, 'day', $today); plugin_set_setting(VT, 'day_count', '0'); }
+ return (int) plugin_setting(VT, 'day_count', '0');
+}
+
+function vt_day_add(int $n = 1): void
+{
+ plugin_set_setting(VT, 'day_count', (string) (vt_day_used() + $n));
+}
+
+/**
+ * Entscheidung nach einer Abfrage. @return bool false = Lauf beenden (Tageslimit, Minutenlimit oder Schlüsselproblem)
+ */
+function vt_apply(PDO $pdo, array $row, array $res, array $cfg): bool
+{
+ $soon = fn(int $sec) => date('Y-m-d H:i:s', time() + $sec);
+ switch ($res['result']) {
+ case 'limit':
+ $pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]);
+ return false;
+ case 'key':
+ plugin_set_setting(VT, 'last_error', db_now() . ': ' . $res['detail']);
+ return false;
+ case 'error':
+ plugin_set_setting(VT, 'last_error', db_now() . ': ' . $res['detail']);
+ $tries = (int) $row['tries'] + 1;
+ if ($tries >= 6) {
+ if ($cfg['on_error'] === 'allow') vt_release($pdo, $row, 'released', 'VirusTotal nicht erreichbar, freigegeben (Einstellung)');
+ else $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'error', detail = ?, tries = ?, checked_at = ? WHERE id = ?")->execute([mb_substr((string) $res['detail'], 0, 250), $tries, db_now(), $row['id']]);
+ } else {
+ $pdo->prepare('UPDATE plugin_virustotal_files SET tries = ?, next_try = ?, detail = ? WHERE id = ?')->execute([$tries, $soon(min(3600, 60 * (2 ** $tries))), mb_substr((string) $res['detail'], 0, 250), $row['id']]);
+ }
+ return true;
+ case 'bad':
+ plugin_set_setting(VT, 'last_error', '');
+ vt_block($pdo, $row, (string) $res['detail'], (int) $res['malicious'], (int) $res['engines']);
+ return true;
+ case 'clean':
+ plugin_set_setting(VT, 'last_error', '');
+ vt_release($pdo, $row, 'clean', (string) ($res['detail'] ?? 'VirusTotal: keine Funde'));
+ $pdo->prepare('UPDATE plugin_virustotal_files SET malicious = ?, engines = ? WHERE id = ?')->execute([(int) $res['malicious'], (int) $res['engines'], $row['id']]);
+ return true;
+ case 'wait':
+ if (time() - (int) strtotime((string) $row['created_at']) > 3600) {
+ $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'error', detail = ?, checked_at = ? WHERE id = ?")->execute(['Die Analyse bei VirusTotal wurde nicht rechtzeitig fertig.', db_now(), $row['id']]);
+ } else {
+ $pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]);
+ }
+ return true;
+ default: // unknown oder none: VirusTotal kennt die Datei (noch) nicht
+ $path = vt_hold_dir() . '/' . basename((string) $row['stored_name']);
+ $size = is_file($path) ? (int) filesize($path) : 0;
+ if ($cfg['upload'] && $size > 0 && $size <= VT_UPLOAD_MAX && $row['status'] === 'pending') {
+ $up = vt_upload($path, (string) $row['original_name'], $cfg['key']);
+ vt_day_add();
+ if (!$up['ok']) {
+ if ($up['detail'] === 'limit') { $pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]); return false; }
+ return vt_apply($pdo, $row, ['result' => 'error', 'detail' => $up['detail'], 'malicious' => 0, 'engines' => 0], $cfg);
+ }
+ $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'analyzing', analysis_id = ?, next_try = ?, detail = 'Zur Analyse bei VirusTotal hochgeladen' WHERE id = ?")->execute([$up['id'], $soon(60), $row['id']]);
+ return true;
+ }
+ if ($cfg['unknown'] === 'allow') {
+ vt_release($pdo, $row, 'released', 'Bei VirusTotal unbekannt, freigegeben (Einstellung)');
+ return true;
+ }
+ $why = $cfg['upload'] && $size > VT_UPLOAD_MAX ? 'Bei VirusTotal unbekannt und zu groß für den Upload (über 32 MB).' : 'Bei VirusTotal unbekannt: bitte selbst prüfen und freigeben oder löschen.';
+ $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'unknown', detail = ?, checked_at = ? WHERE id = ?")->execute([$why, db_now(), $row['id']]);
+ if ((int) $row['ticket_id'] > 0) log_activity($pdo, (int) $row['ticket_id'], null, 'VirusTotal: Anhang bleibt gesperrt (unbekannt): ' . $row['original_name']);
+ return true;
+ }
+}
+
+/**
+ * Hintergrundlauf (jede Minute oder alle 5 Minuten): zurückgehaltene Anhänge abfragen, höchstens 4 pro Lauf (Grenze des kostenlosen
+ * Schlüssels) und 480 pro Tag. @return int Zahl der Abfragen
+ */
+function vt_tick(PDO $pdo): int
+{
+ $cfg = vt_config();
+ if ($cfg['key'] === '') return 0;
+ // Zwei Auslöser (jede Minute und alle 5 Minuten) sollen sich nicht überlappen
+ if (time() - (int) plugin_setting(VT, 'tick_at', '0') < 25) return 0;
+ plugin_set_setting(VT, 'tick_at', (string) time());
+ // Anhänge, die kein Ereignis gemeldet hat (zum Beispiel aus E-Mails), nachträglich zurückhalten
+ foreach (vt_unseen($pdo, 25) as $att) vt_hold($pdo, $att);
+ $rows = $pdo->prepare("SELECT * FROM plugin_virustotal_files WHERE status IN ('pending','analyzing') AND next_try <= ? ORDER BY id LIMIT 4");
+ $rows->execute([db_now()]);
+ $done = 0;
+ foreach ($rows->fetchAll() as $row) {
+ if (vt_day_used() >= VT_DAY_LIMIT) { plugin_set_setting(VT, 'last_error', db_now() . ': Das Tageslimit des VirusTotal-Schlüssels ist erreicht, es geht morgen weiter.'); break; }
+ $res = $row['status'] === 'analyzing' && $row['analysis_id']
+ ? vt_analysis((string) $row['analysis_id'], $cfg['key'], $cfg['threshold'])
+ : vt_lookup((string) $row['sha256'], $cfg['key'], $cfg['threshold']);
+ vt_day_add();
+ $done++;
+ $pdo->prepare('UPDATE plugin_virustotal_files SET checked_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
+ if (!vt_apply($pdo, $row, $res, $cfg)) break;
+ }
+ if ($done > 0) plugin_set_setting(VT, 'last_run', db_now());
+ return $done;
+}
+
+/** Zahlen für die Seite. @return array */
+function vt_stats(PDO $pdo): array
+{
+ $out = ['pending' => 0, 'analyzing' => 0, 'clean' => 0, 'bad' => 0, 'unknown' => 0, 'error' => 0, 'released' => 0, 'deleted' => 0];
+ foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM plugin_virustotal_files GROUP BY status')->fetchAll() as $r) $out[$r['status']] = (int) $r['n'];
+ return $out;
+}
+
+/** Prüft, ob der Schlüssel funktioniert (Abfrage des Fingerabdrucks der Standard-Testdatei EICAR). @return array{ok:bool,message:string} */
+function vt_test_key(string $key, int $threshold): array
+{
+ if ($key === '') return ['ok' => false, 'message' => 'Bitte zuerst einen Schlüssel eintragen und speichern.'];
+ $r = vt_lookup(VT_EICAR_SHA256, $key, $threshold);
+ if ($r['result'] === 'bad') return ['ok' => true, 'message' => 'Der Schlüssel funktioniert: Die Standard-Testdatei (EICAR) wurde von VirusTotal als gefährlich erkannt (' . $r['malicious'] . ' von ' . $r['engines'] . ' Scannern).'];
+ if ($r['result'] === 'limit') return ['ok' => false, 'message' => 'Das Abfrage-Limit von VirusTotal ist gerade erreicht. Bitte in einer Minute noch einmal versuchen.'];
+ if ($r['result'] === 'clean' || $r['result'] === 'unknown' || $r['result'] === 'none') return ['ok' => true, 'message' => 'Der Schlüssel wird angenommen, die Testdatei wurde aber unerwartet bewertet (' . $r['result'] . ').'];
+ return ['ok' => false, 'message' => (string) ($r['detail'] ?? 'Die Abfrage ist fehlgeschlagen.')];
+}
+
+plugin_add_hook('plugin.uninstall.' . VT, function () {
+ try {
+ get_db()->exec('DROP TABLE IF EXISTS plugin_virustotal_files');
+ } catch (Throwable $e) {
+ // Die Dateien im Ordner uploads/vt-pruefung bleiben liegen
+ }
+});
diff --git a/theme/plugins/virustotal/migrations/001_dateien.mysql.sql b/theme/plugins/virustotal/migrations/001_dateien.mysql.sql
new file mode 100644
index 00000000..6af4d6ed
--- /dev/null
+++ b/theme/plugins/virustotal/migrations/001_dateien.mysql.sql
@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS plugin_virustotal_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,
+ filesize BIGINT NOT NULL DEFAULT 0,
+ sha256 VARCHAR(64) NULL,
+ status VARCHAR(20) NOT NULL,
+ detail VARCHAR(255) NULL,
+ analysis_id VARCHAR(255) NULL,
+ malicious INT NULL,
+ engines INT NULL,
+ tries INT NOT NULL DEFAULT 0,
+ next_try DATETIME NOT NULL,
+ created_at DATETIME NOT NULL,
+ checked_at DATETIME NULL,
+ UNIQUE KEY idx_plugin_virustotal_attachment (attachment_id),
+ KEY idx_plugin_virustotal_status (status, next_try)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
diff --git a/theme/plugins/virustotal/migrations/001_dateien.sqlite.sql b/theme/plugins/virustotal/migrations/001_dateien.sqlite.sql
new file mode 100644
index 00000000..e13ae659
--- /dev/null
+++ b/theme/plugins/virustotal/migrations/001_dateien.sqlite.sql
@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS plugin_virustotal_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,
+ filesize INTEGER NOT NULL DEFAULT 0,
+ sha256 VARCHAR(64) NULL,
+ status VARCHAR(20) NOT NULL,
+ detail VARCHAR(255) NULL,
+ analysis_id VARCHAR(255) NULL,
+ malicious INTEGER NULL,
+ engines INTEGER NULL,
+ tries INTEGER NOT NULL DEFAULT 0,
+ next_try DATETIME NOT NULL,
+ created_at DATETIME NOT NULL,
+ checked_at DATETIME NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_virustotal_attachment ON plugin_virustotal_files (attachment_id);
+CREATE INDEX IF NOT EXISTS idx_plugin_virustotal_status ON plugin_virustotal_files (status, next_try)
diff --git a/theme/plugins/virustotal/pages/virustotal.php b/theme/plugins/virustotal/pages/virustotal.php
new file mode 100644
index 00000000..98ad0a0e
--- /dev/null
+++ b/theme/plugins/virustotal/pages/virustotal.php
@@ -0,0 +1,161 @@
+Keine Berechtigung.';
+ return;
+}
+$pdo = get_db();
+vt_ensure_schema();
+$message = '';
+$messageType = 'success';
+
+if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+ verify_csrf();
+ $action = (string) ($_POST['action'] ?? '');
+ $id = (int) ($_POST['id'] ?? 0);
+ if ($action === 'save') {
+ $keyIn = trim((string) ($_POST['key'] ?? ''));
+ if ($keyIn !== '' && !preg_match('/^[a-f0-9]{64}$/i', $keyIn)) {
+ $message = 'Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).';
+ $messageType = 'error';
+ } else {
+ if ($keyIn !== '') plugin_set_setting(VT, 'key_enc', encrypt_secret($keyIn));
+ if (!empty($_POST['clear_key'])) plugin_set_setting(VT, 'key_enc', '');
+ plugin_set_setting(VT, 'threshold', (string) max(1, min(30, (int) ($_POST['threshold'] ?? 3))));
+ plugin_set_setting(VT, 'unknown', ($_POST['unknown'] ?? 'hold') === 'allow' ? 'allow' : 'hold');
+ plugin_set_setting(VT, 'on_error', ($_POST['on_error'] ?? 'hold') === 'allow' ? 'allow' : 'hold');
+ plugin_set_setting(VT, 'upload', !empty($_POST['upload']) ? '1' : '0');
+ plugin_set_setting(VT, 'notify', !empty($_POST['notify']) ? '1' : '0');
+ audit_log((int) $staff['id'], 'VirusTotal: Einstellungen geändert' . (!empty($_POST['upload']) ? ' (Upload unbekannter Dateien an)' : ''));
+ $message = 'Gespeichert.';
+ }
+ } elseif ($action === 'test') {
+ $t = vt_test_key(vt_config()['key'], vt_config()['threshold']);
+ $message = $t['message'];
+ $messageType = $t['ok'] ? 'success' : 'error';
+ } elseif ($action === 'release' && $id > 0) {
+ $ok = vt_release_manual($pdo, $id);
+ if ($ok) audit_log((int) $staff['id'], 'VirusTotal: Anhang #' . $id . ' von Hand freigegeben');
+ $message = $ok ? 'Die Datei ist freigegeben.' : 'Die Datei ließ sich nicht freigeben.';
+ $messageType = $ok ? 'success' : 'error';
+ } elseif ($action === 'delete' && $id > 0) {
+ $ok = vt_delete($pdo, $id);
+ if ($ok) audit_log((int) $staff['id'], 'VirusTotal: Anhang #' . $id . ' gelöscht');
+ $message = $ok ? 'Die Datei ist gelöscht.' : 'Die Datei ließ sich nicht löschen.';
+ $messageType = $ok ? 'success' : 'error';
+ } elseif ($action === 'recheck' && $id > 0) {
+ $ok = vt_recheck($pdo, $id);
+ $message = $ok ? 'Die Datei wird beim nächsten Lauf noch einmal geprüft.' : 'Das ging nicht.';
+ $messageType = $ok ? 'success' : 'error';
+ }
+}
+
+$cfg = vt_config();
+$stats = vt_stats($pdo);
+$used = vt_day_used();
+$lastError = (string) plugin_setting(VT, 'last_error', '');
+$lastRun = (string) plugin_setting(VT, 'last_run', '');
+$held = $pdo->query("SELECT f.*, t.ticket_number FROM plugin_virustotal_files f LEFT JOIN tickets t ON t.id = f.ticket_id WHERE f.status IN ('pending','analyzing','unknown','error','bad') ORDER BY (CASE f.status WHEN 'bad' THEN 0 WHEN 'unknown' THEN 1 WHEN 'error' THEN 2 ELSE 3 END), f.id DESC LIMIT 100")->fetchAll();
+$recent = $pdo->query("SELECT * FROM plugin_virustotal_files WHERE status IN ('clean','released','deleted') ORDER BY id DESC LIMIT 30")->fetchAll();
+$labels = ['pending' => ['Wartet auf VirusTotal', '#b45309'], 'analyzing' => ['Wird bei VirusTotal analysiert', '#b45309'], 'unknown' => ['Unbekannt (zurückgehalten)', '#b45309'], 'error' => ['Fehler (zurückgehalten)', '#b91c1c'],
+ 'bad' => ['Gesperrt (Schadsoftware)', '#b91c1c'], 'clean' => ['Sauber, freigegeben', '#15803d'], 'released' => ['Von Hand freigegeben', '#15803d'], 'deleted' => ['Gelöscht', '#64748b']];
+$mb = fn($b) => $b >= 1048576 ? round($b / 1048576, 1) . ' MB' : max(1, (int) round($b / 1024)) . ' KB';
+?>
+VirusTotal
+= e($message) ?>
+
+
+ Zustand
+
+ Noch kein Schlüssel. Ohne VirusTotal-Schlüssel werden Anhänge nicht zurückgehalten und nicht geprüft. Einen kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com im Profil unter „API Key“.
+
+ ✓ Aktiv. Neue Anhänge sind gesperrt, bis VirusTotal sie geprüft hat.
+
+
+ = (int) ($stats['pending'] + $stats['analyzing']) ?> warten ·
+ = (int) $stats['bad'] ?> gesperrt ·
+ = (int) $stats['unknown'] ?> unbekannt ·
+ = (int) $stats['error'] ?> mit Fehler ·
+ = (int) ($stats['clean'] + $stats['released']) ?> freigegeben
+
+ Abfragen heute: = (int) $used ?> von = (int) VT_DAY_LIMIT ?> (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: = $lastRun !== '' ? e(format_dt($lastRun)) : 'noch nicht' ?>.
+ = e($lastError) ?>
+ Vorgehen: Jeder neue Anhang wird sofort zurückgehalten (nicht herunterladbar). Dann fragt das Plugin bei VirusTotal nur den Fingerabdruck (SHA-256) der Datei ab. Ist die Datei bekannt und sauber, wird sie freigegeben; melden mindestens = (int) $cfg['threshold'] ?> Scanner Schadsoftware, bleibt sie gesperrt. Anhänge, die schon vor dem Einschalten da waren, bleiben unberührt.
+
+
+
+
+
+
+
+ Zurückgehaltene und gesperrte Anhänge
+
+ Nichts zurückgehalten.
+
+
+ | Datei | Ticket | Größe | Zustand | Hinweis | |
+
+
+
+ | = e($r['original_name']) ?> = e((string) $r['sha256']) ?> |
+ #= e((string) $r['ticket_number']) ?> |
+ = e($mb((int) $r['filesize'])) ?> |
+ = e($label) ?> |
+ = e((string) $r['detail']) ?> |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ Zuletzt geprüft
+
+ Noch nichts geprüft.
+
+
+ | Datei | Zustand | Ergebnis | Wann |
+
+
+ | = e($r['original_name']) ?> | = e($label) ?> | = e((string) $r['detail']) ?> | = e(format_dt((string) ($r['checked_at'] ?: $r['created_at']))) ?> |
+
+
+
+
+
diff --git a/theme/plugins/virustotal/plugin.json b/theme/plugins/virustotal/plugin.json
new file mode 100644
index 00000000..accb6cee
--- /dev/null
+++ b/theme/plugins/virustotal/plugin.json
@@ -0,0 +1,10 @@
+{
+ "slug": "virustotal",
+ "name": "VirusTotal-Anhangsprüfung",
+ "version": "1.0.0",
+ "description": "Hält jeden Anhang eines Tickets zurück, bis VirusTotal ihn geprüft hat: Erst wird nur der Fingerabdruck (SHA-256) abgefragt, die Datei selbst verlässt den Server nicht. Ist die Datei sauber, wird sie freigegeben; melden mehrere Scanner Schadsoftware, bleibt sie gesperrt (Quarantäne, Hinweis im Ticket, Mail an den Administrator). Unbekannte Dateien werden zur manuellen Prüfung zurückgehalten, freigegeben oder (nur wenn ausdrücklich eingeschaltet) zur Analyse bei VirusTotal hochgeladen. Arbeitet auch neben dem Plugin „Anhänge-Virenscan“. Nur für Administratoren.",
+ "author": "DeskHub",
+ "entry": "plugin.php",
+ "requires": "1.0.0",
+ "permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
+}
diff --git a/theme/plugins/virustotal/plugin.php b/theme/plugins/virustotal/plugin.php
new file mode 100644
index 00000000..83b3ebd2
--- /dev/null
+++ b/theme/plugins/virustotal/plugin.php
@@ -0,0 +1,27 @@
+getMessage());
+}
+
+foreach (['ticket.created', 'ticket.replied'] as $hook) {
+ plugin_add_hook($hook, function (array $payload) {
+ // Nur zurückhalten, wenn ein Schlüssel eingetragen ist: ohne Schlüssel gäbe es nie ein Ergebnis und die Anhänge blieben für immer gesperrt
+ if (!empty($payload['ticketId']) && vt_config()['key'] !== '') vt_hold_ticket(get_db(), (int) $payload['ticketId']);
+ });
+}
+// Abfragen: jede Minute (wenn der Hintergrunddienst es kann), sonst alle 5 Minuten
+plugin_add_hook('cron.minute', function () {
+ vt_tick(get_db());
+});
+plugin_add_hook('cron.run', function () {
+ vt_tick(get_db());
+});
+
+plugin_register_menu('VirusTotal', 'virustotal', null, 'admin');