diff --git a/Plugins/plugin-beispiel/lang/en.json b/Plugins/plugin-beispiel/lang/en.json
new file mode 100644
index 00000000..e40f4d61
--- /dev/null
+++ b/Plugins/plugin-beispiel/lang/en.json
@@ -0,0 +1,5 @@
+{
+ "_info": "English translations of this plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number. Further languages: lang/.json, for example lang/fr.json.",
+ "Beispiel: Ticket-Protokoll": "Example: ticket log",
+ "Demonstriert Hooks, Menüpunkt, Seite, API-Endpunkt, Ticket-Seitenleiste, Einstellungen und Datenbank-Migration.": "Demonstrates hooks, menu item, page, API endpoint, ticket sidebar, settings and database migration."
+}
diff --git a/Plugins/plugins/authentik-1.2.4.zip b/Plugins/plugins/authentik-1.2.4.zip
new file mode 100644
index 00000000..5739f83d
Binary files /dev/null and b/Plugins/plugins/authentik-1.2.4.zip differ
diff --git a/Plugins/plugins/authentik-vorschau.png b/Plugins/plugins/authentik-vorschau.png
new file mode 100644
index 00000000..7c5b6ee7
Binary files /dev/null and b/Plugins/plugins/authentik-vorschau.png differ
diff --git a/Plugins/plugins/authentik/README.md b/Plugins/plugins/authentik/README.md
new file mode 100644
index 00000000..8a1f8ed9
--- /dev/null
+++ b/Plugins/plugins/authentik/README.md
@@ -0,0 +1,42 @@
+# authentik-Anmeldung für DeskHub
+
+Mitarbeiter und Kunden melden sich mit **authentik** an (OpenID Connect, Authorization-Code-Ablauf mit PKCE). Auf der Anmeldeseite des Mitarbeiterbereichs und, wenn eingeschaltet, des Kundenportals erscheint zusätzlich der Knopf „Mit authentik anmelden“. E-Mail und Passwort bleiben als Zugang erhalten.
+
+Voraussetzung: DeskHub **1.0.52 oder neuer** (Berechtigungen `auth.login` und `auth.customer`, Anmelde-Adressen `/staff/plugin_login.php` und `/plugin_login.php`).
+
+## Einrichtung
+
+1. **In authentik** unter *Anwendungen → Provider* einen Provider vom Typ **OAuth2/OpenID** anlegen, Client-Typ *vertraulich*.
+2. **Umleitungs-URI** (Modus „Strict“): `https://.json, for example lang/fr.json.",
+ "(seit {}).": "(since {}).",
+ ". Alternativ klappt die erste Anmeldung auch, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist (dann wird automatisch verknüpft).": ". Alternatively the first sign-in also works if the email address is the same in authentik and DeskHub (it is then linked automatically).",
+ ". Für die Gruppenprüfung zusätzlich den Bereich, der": ". For the group check, additionally add the scope that",
+ "Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein": "Only someone who is confirmed in authentik and has",
+ "Anmeldung mit authentik einschalten": "Switch on sign-in with authentik",
+ "Anwendung": "application",
+ "Anwendungen → Provider": "Applications → Providers",
+ "Auch Kunden mit authentik anmelden.": "Also sign in customers with authentik.",
+ "Auf der Anmeldeseite des Mitarbeiterbereichs erscheint der Knopf „{}“.": "The button “{}” appears on the sign-in page of the staff area.",
+ "Auf diesem Server fehlt die PHP-Erweiterung cURL.": "The PHP extension cURL is missing on this server.",
+ "Ausgeschaltet.": "Switched off.",
+ "Bereiche (Scopes)": "Scopes",
+ "Bereiche:": "Scopes:",
+ "Beschriftung": "Label",
+ "Beschriftung des Knopfes": "Button label",
+ "Bestätigte E-Mail-Adresse verlangen (empfohlen).": "Require a verified email address (recommended).",
+ "Bitte die Issuer-Adresse mit https:// angeben, ohne Parameter (zum Beispiel https://auth.example.de/application/o/deskhub).": "Please enter the issuer address with https://, without parameters (for example https://auth.example.com/application/o/deskhub).",
+ "Bitte zuerst anmelden.": "Please sign in first.",
+ "Bitte zuerst eine gültige Issuer-Adresse speichern.": "Please save a valid issuer address first.",
+ "Client-Geheimnis (Client Secret)": "Client secret",
+ "Client-ID": "Client ID",
+ "Client-ID und Client-Geheimnis hier eintragen. Die": "Enter the client ID and client secret here. The",
+ "Client-Typ:": "Client type:",
+ "Danach führt „{}“ auf der Anmeldeseite genau in dieses Konto. Auch ohne Verknüpfung klappt die Anmeldung, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist; dabei wird sie dann automatisch verknüpft.": "Afterwards “{}” on the sign-in page leads exactly into this account. Even without a link, signing in works if the email address is the same in authentik and DeskHub; it is then linked automatically.",
+ "Danach führt „{}“ auf der Anmeldeseite genau in dieses Konto. Ohne Verknüpfung ist die Anmeldung mit authentik hier nicht möglich.": "Afterwards “{}” on the sign-in page leads exactly into this account. Without a link, signing in with authentik is not possible here.",
+ "Das mit Ihrem authentik-Konto verknüpfte DeskHub-Konto ist deaktiviert.": "The DeskHub account linked to your authentik account is deactivated.",
+ "Der Ablauf nutzt PKCE, einen Zustandswert und eine Nonce; Aussteller, Zielgruppe und Ablauf des ID-Tokens werden geprüft. Das Token kommt direkt von authentik über eine geprüfte HTTPS-Verbindung. Das Geheimnis liegt verschlüsselt in der Datenbank. Fällt authentik aus, melden sich Mitarbeiter weiter mit E-Mail und Passwort an. Jede Anmeldung steht im Admin-Protokoll.": "The flow uses PKCE, a state value and a nonce; issuer, audience and expiry of the ID token are checked. The token comes directly from authentik over a verified HTTPS connection. The secret is stored encrypted in the database. If authentik fails, staff keep signing in with email and password. Every sign-in is recorded in the admin log.",
+ "Der DeskHub-Server kennt die Kunden-Anmeldung (Version 1.0.52 oder neuer).": "The DeskHub server supports customer sign-in (version 1.0.52 or newer).",
+ "Der Issuer in der Antwort („{}“) passt nicht zur eingestellten Adresse.": "The issuer in the response (“{}”) does not match the configured address.",
+ "Der Knopf erscheint auf der Anmeldeseite des Kundenportals. Es zählt nur die in authentik bestätigte E-Mail-Adresse: Hat ein Kundenkonto dieselbe Adresse, meldet DeskHub es an.": "The button appears on the sign-in page of the customer portal. Only the email address confirmed in authentik counts: if a customer account has the same address, DeskHub signs it in.",
+ "Der Knopf erscheint dort noch nicht.": "The button does not appear there yet.",
+ "Der Knopf erscheint erst nach dem Einschalten.": "The button only appears after switching on.",
+ "Der Server ist zu alt: Bitte DeskHub 1.0.52 oder neuer einspielen (auch login.php, plugin_login.php und includes/plugins.php).": "The server is too old: please install DeskHub 1.0.52 or newer (including login.php, plugin_login.php and includes/plugins.php).",
+ "Die Anmeldeseite des Kundenportals (login.php) ist auf dem neuesten Stand.": "The sign-in page of the customer portal (login.php) is up to date.",
+ "Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „Mit authentik verknüpfen“ klicken.": "The sign-in has expired or was not started here. Please click “Link with authentik” again.",
+ "Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „{}“ klicken.": "The sign-in has expired or was not started here. Please click “{}” again.",
+ "Die Anmeldung mit authentik ist für Kunden nicht eingeschaltet.": "Sign-in with authentik is not switched on for customers.",
+ "Die Anmeldung mit authentik ist nicht eingerichtet.": "Sign-in with authentik is not set up.",
+ "Die Anmeldung mit authentik ist nicht eingerichtet. Ein Administrator kann sie unter „authentik“ einschalten.": "Sign-in with authentik is not set up. An administrator can switch it on under “authentik”.",
+ "Die Antwort stammt nicht vom eingestellten authentik (Aussteller).": "The response does not come from the configured authentik (issuer).",
+ "Die Antwort von authentik enthält keine Benutzerkennung.": "The response from authentik contains no user identifier.",
+ "Die Antwort von authentik gehört nicht zu dieser Anmeldung (Nonce).": "The response from authentik does not belong to this sign-in (nonce).",
+ "Die Antwort von authentik gilt für eine andere Anwendung.": "The response from authentik is for a different application.",
+ "Die Antwort von authentik ist unlesbar.": "The response from authentik is unreadable.",
+ "Die Antwort von authentik passt nicht zur gestarteten Anmeldung. Bitte noch einmal versuchen.": "The response from authentik does not match the sign-in that was started. Please try again.",
+ "Die Berechtigung „auth.customer“ fehlt: Unter Plugins das Plugin deaktivieren und wieder aktivieren und die Berechtigungen bestätigen.": "The permission “auth.customer” is missing: under Plugins, deactivate and reactivate the plugin and confirm the permissions.",
+ "Die Berechtigung „auth.customer“ ist freigegeben.": "The permission “auth.customer” is approved.",
+ "Die Bereiche (Scopes) enthalten ungültige Zeichen.": "The scopes contain invalid characters.",
+ "Die Bestätigung von authentik ist abgelaufen.": "The confirmation from authentik has expired.",
+ "Die Client-ID ist ungültig.": "The client ID is invalid.",
+ "Die Datei login.php im DeskHub-Hauptordner ist alt und zeigt keine Plugin-Knöpfe: Bitte die aktuelle login.php hochladen.": "The file login.php in the main DeskHub folder is outdated and shows no plugin buttons: please upload the current login.php.",
+ "Die E-Mail-Adresse ist in authentik nicht bestätigt, daher ordnet DeskHub das Konto nicht über die Adresse zu. Verknüpfen Sie Ihr Konto einmal unter „Mein authentik-Konto“ (nach der Anmeldung mit E-Mail und Passwort), danach geht die Anmeldung mit authentik.": "The email address is not verified in authentik, so DeskHub does not assign the account by address. Link your account once under “My authentik account” (after signing in with email and password); after that signing in with authentik works.",
+ "Die E-Mail-Adresse ist in authentik nicht bestätigt. Bitte erst in authentik bestätigen.": "The email address is not verified in authentik. Please verify it in authentik first.",
+ "Die Konfiguration von authentik enthält keinen gültigen Eintrag „{}“.": "The configuration from authentik has no valid entry “{}”.",
+ "Die Verbindung ist nicht vollständig (Issuer, Client-ID, Geheimnis, eingeschaltet).": "The connection is incomplete (issuer, client ID, secret, switched on).",
+ "Die Verbindung zu authentik ist eingerichtet und eingeschaltet.": "The connection to authentik is set up and switched on.",
+ "Die Verknüpfung ist gelöst.": "The link has been removed.",
+ "Die Verknüpfung ist gelöst. Mit authentik melden Sie sich erst wieder an, wenn Sie es neu verknüpfen (oder, falls erlaubt, über die E-Mail-Adresse).": "The link has been removed. You can only sign in with authentik again after linking it anew (or, if allowed, via the email address).",
+ "Die Verknüpfung wurde von einem anderen Konto gestartet. Bitte noch einmal starten.": "The link was started by a different account. Please start it again.",
+ "Dieses DeskHub-Konto ist mit einem anderen authentik-Konto verknüpft.": "This DeskHub account is linked to a different authentik account.",
+ "Dieses authentik-Konto ist bereits mit einem anderen DeskHub-Konto verknüpft.": "This authentik account is already linked to a different DeskHub account.",
+ "E-Mail in DeskHub": "Email in DeskHub",
+ "E-Mail in authentik": "Email in authentik",
+ "Eine": "Create an",
+ "Eingeschaltet, aber unvollständig.": "Switched on, but incomplete.",
+ "Einrichtung in authentik": "Setup in authentik",
+ "Für jeden Mitarbeiter in DeskHub ein Konto mit derselben E-Mail-Adresse wie in authentik haben.": "Every staff member needs an account in DeskHub with the same email address as in authentik.",
+ "Für {} gibt es in DeskHub kein aktives Mitarbeiterkonto. Bitte einen Administrator bitten, es mit derselben E-Mail-Adresse anzulegen, oder ein bestehendes Konto unter „Mein authentik-Konto“ verknüpfen.": "There is no active staff account in DeskHub for {}. Please ask an administrator to create it with the same email address, or link an existing account under “My authentik account”.",
+ "Für {} gibt es in DeskHub kein aktives Mitarbeiterkonto. Bitte einen Administrator bitten, es mit derselben E-Mail-Adresse anzulegen.": "There is no active staff account in DeskHub for {}. Please ask an administrator to create it with the same email address.",
+ "Gilt, wo die Adresse über das Konto entscheidet: bei Kunden und bei Mitarbeitern, die noch nicht verknüpft sind. Meldet authentik die Adresse als „nicht bestätigt“, kommen sie sonst nicht hinein. Wer schon verknüpft ist, meldet sich immer an. Nur ausschalten, wenn nur du in authentik Benutzer anlegst und Adressen ändern darfst.": "Applies where the address decides about the account: for customers and for staff who are not linked yet. If authentik reports the address as “not verified”, they cannot get in otherwise. Anyone who is already linked always signs in. Only switch off if you are the only one who creates users and changes addresses in authentik.",
+ "Ihr DeskHub-Konto gehört zum authentik-Konto": "Your DeskHub account belongs to the authentik account",
+ "Ihr authentik-Konto ({}) ist jetzt verknüpft. Die Anmeldung mit authentik führt künftig genau in dieses Konto.": "Your authentik account ({}) is now linked. Signing in with authentik now leads exactly into this account.",
+ "Ihr authentik-Konto gehört nicht zur Gruppe „{}“, die für DeskHub nötig ist.": "Your authentik account does not belong to the group “{}” that DeskHub requires.",
+ "Ihr authentik-Konto ist noch keinem DeskHub-Konto zugeordnet. Melden Sie sich mit E-Mail und Passwort an und verknüpfen Sie es dort unter „Mein authentik-Konto“.": "Your authentik account is not assigned to a DeskHub account yet. Sign in with email and password and link it there under “My authentik account”.",
+ "In authentik unter": "In authentik, under",
+ "Issuer-Adresse": "issuer address",
+ "Issuer-Adresse (OpenID-Konfiguration)": "Issuer address (OpenID configuration)",
+ "Issuer-Adresse, Client-ID und Geheimnis müssen eingetragen sein.": "The issuer address, client ID and secret must be entered.",
+ "Jeder Mitarbeiter hat sein DeskHub-Konto und verknüpft es einmal selbst unter": "Every staff member has their DeskHub account and links it once themselves under",
+ "Keine Berechtigung.": "No permission.",
+ "Kunden (Ticketersteller)": "Customers (ticket creators)",
+ "Kunden-Anmeldung": "Customer sign-in",
+ "Kunden-Anmeldung ist ausgeschaltet: Unten unter „Kunden“ den Haken setzen und speichern.": "Customer sign-in is switched off: tick the box under “Customers” below and save.",
+ "Kunden-Anmeldung ist eingeschaltet („Auch Kunden mit authentik anmelden“).": "Customer sign-in is switched on (“Also sign in customers with authentik”).",
+ "Kundenkonto automatisch anlegen,": "Create the customer account automatically",
+ "Lösen": "Remove",
+ "Mein authentik-Konto": "My authentik account",
+ "Mit authentik anmelden": "Sign in with authentik",
+ "Mit authentik verknüpfen": "Link with authentik",
+ "Mit einem anderen authentik-Konto neu verknüpfen": "Link again with a different authentik account",
+ "Mit „{}“ auf der Anmeldeseite kommen Sie damit direkt in dieses Konto, auch wenn sich die E-Mail-Adresse in authentik oder DeskHub später ändert. Ein anderes authentik-Konto kann dieses DeskHub-Konto nicht öffnen.": "With “{}” on the sign-in page you now go straight into this account, even if the email address in authentik or DeskHub changes later. No other authentik account can open this DeskHub account.",
+ "Mitarbeiter": "Staff member",
+ "Mitarbeiter und Kunden melden sich mit authentik an (OpenID Connect, mit PKCE): Knopf „Mit authentik anmelden“ auf der Anmeldeseite des Mitarbeiterbereichs und, wenn eingeschaltet, des Kundenportals. Mitarbeiter: Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein aktives, verknüpftes Mitarbeiterkonto (oder eines mit derselben E-Mail-Adresse) hat; sie verknüpfen ihr Konto selbst unter „Mein authentik-Konto“. Kunden (Ticketersteller): Die bestätigte E-Mail-Adresse muss passen, das Konto wird bei Bedarf automatisch angelegt. Optional nur Mitglieder bestimmter authentik-Gruppen. E-Mail und Passwort bleiben als Zugang erhalten.": "Staff and customers sign in with authentik (OpenID Connect, with PKCE): a “Sign in with authentik” button on the sign-in page of the staff area and, if switched on, of the customer portal. Staff: only someone who is confirmed in authentik and has an active, linked staff account (or one with the same email address) in DeskHub is signed in; they link their account themselves under “My authentik account”. Customers (ticket creators): the confirmed email address must match, and the account is created automatically if wanted. Optionally restricted to authentik groups. Email and password remain available as a way in.",
+ "Noch kein Mitarbeiter hat sein Konto verknüpft. Mitarbeiter tun das selbst unter „Mein authentik-Konto“; bei der ersten Anmeldung über die E-Mail-Adresse geschieht es automatisch.": "No staff member has linked their account yet. Staff do this themselves under “My authentik account”; it happens automatically on the first sign-in via the email address.",
+ "Noch nicht verknüpft.": "Not linked yet.",
+ "Nur Kunden aus dieser authentik-Gruppe (optional)": "Only customers from this authentik group (optional)",
+ "Nur Mitglieder dieser authentik-Gruppe (optional)": "Only members of this authentik group (optional)",
+ "Nur verknüpfte Konten anmelden.": "Only sign in linked accounts.",
+ "Ohne Haken klappt die Anmeldung auch über die E-Mail-Adresse (das Konto wird dabei gleich verknüpft). Mit Haken muss jeder Mitarbeiter sein Konto erst unter „Mein authentik-Konto“ verknüpfen.": "Without the tick, signing in also works via the email address (the account is linked at the same time). With the tick, every staff member must first link their account under “My authentik account”.",
+ "Ruft die Konfiguration von authentik ab (ohne Anmeldung). Vorher speichern.": "Fetches the configuration from authentik (without signing in). Save first.",
+ "Seit": "Since",
+ "Sicherheit:": "Security:",
+ "Umleitungs-URI (Redirect URI)": "Redirect URI",
+ "Ungültige Adresse.": "Invalid address.",
+ "Verbindung in Ordnung: authentik meldet den Aussteller „{}“ und die Anmelde-Adresse {}.": "Connection OK: authentik reports the issuer “{}” and the sign-in address {}.",
+ "Verbindung testen": "Test connection",
+ "Verbindung zu authentik": "Connection to authentik",
+ "Verknüpfen Sie Ihr DeskHub-Konto einmal mit Ihrem authentik-Konto. Sie melden sich dazu kurz bei authentik an und kommen hierher zurück.": "Link your DeskHub account once with your authentik account. You briefly sign in at authentik and come back here.",
+ "Verknüpfte Konten ({#})": "Linked accounts ({})",
+ "Verknüpfung dieses Mitarbeiters lösen?": "Remove this staff member's link?",
+ "Verknüpfung lösen": "Remove link",
+ "Verknüpfung wirklich lösen?": "Really remove the link?",
+ "Zustand": "Status",
+ "aktives Mitarbeiterkonto": "an active staff account",
+ "aktives Mitarbeiterkonto mit derselben E-Mail-Adresse": "an active staff account with the same email address",
+ "anlegen.": "create.",
+ "authentik antwortet nicht mit einer gültigen Konfiguration (HTTP {}). Stimmt die Issuer-Adresse?": "authentik does not respond with a valid configuration (HTTP {}). Is the issuer address correct?",
+ "authentik hat den Anmelde-Code nicht angenommen (HTTP {}). Stimmen Client-ID und Geheimnis?": "authentik did not accept the sign-in code (HTTP {}). Are the client ID and secret correct?",
+ "authentik hat die Anmeldung nicht bestätigt ({}).": "authentik did not confirm the sign-in ({}).",
+ "authentik hat keine E-Mail-Adresse geliefert. Im Provider den Bereich „email“ aktivieren.": "authentik did not provide an email address. Enable the scope “email” on the provider.",
+ "authentik ist nicht erreichbar: {}": "authentik is not reachable: {}",
+ "authentik-Anmeldung": "authentik sign-in",
+ "authentik-Anmeldung (OpenID Connect)": "authentik sign-in (OpenID Connect)",
+ "einen Provider vom Typ": "create a provider of type",
+ "genau so eintragen (Modus „Strict“):": "enter it exactly like this (mode “Strict”):",
+ "hat, das mit seinem authentik-Konto": "in DeskHub that is",
+ "hat. Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.": "in DeskHub is signed in. Accounts are not created automatically. Email and password keep working. DeskHub's second step (authenticator code) is skipped for this sign-in: authentik takes care of that.",
+ "ist (oder dieselbe E-Mail-Adresse hat). Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.": "to their authentik account (or has the same email address) is signed in. Accounts are not created automatically. Email and password keep working. DeskHub's second step (authenticator code) is skipped for this sign-in: authentik takes care of that.",
+ "liefert (in authentik: Bereichs-Zuordnung „groups“).": "provides (in authentik: scope mapping “groups”).",
+ "mindestens": "at least",
+ "mit diesem Provider anlegen und bestimmen, wer sie nutzen darf (authentik prüft das selbst).": "with this provider and decide who may use it (authentik checks this itself).",
+ "steht beim Provider unter „OpenID-Konfigurations-Aussteller“, sie sieht so aus:": "is shown on the provider under “OpenID Configuration Issuer”; it looks like this:",
+ "verknüpft": "linked",
+ "vertraulich (Confidential).": "confidential.",
+ "wenn es zu der E-Mail-Adresse noch keines gibt (ohne Passwort; wer eines möchte, nutzt „Passwort vergessen“). Ohne Haken kommen nur Kunden hinein, die schon ein Konto haben.": "if there is none for the email address yet (without a password; anyone who wants one uses “Forgot password”). Without the tick only customers who already have an account get in.",
+ "zum Beispiel deskhub-kunden": "for example deskhub-customers",
+ "zum Beispiel deskhub-support": "for example deskhub-support",
+ "✓ Der Knopf erscheint auf der Anmeldeseite des Kundenportals.": "✓ The button appears on the sign-in page of the customer portal.",
+ "✓ Eingeschaltet.": "✓ Switched on.",
+ "✓ Verknüpft.": "✓ Linked."
+}
diff --git a/Plugins/plugins/authentik/lib.php b/Plugins/plugins/authentik/lib.php
new file mode 100644
index 00000000..6e548f23
--- /dev/null
+++ b/Plugins/plugins/authentik/lib.php
@@ -0,0 +1,374 @@
+ plugin_setting(AUTHENTIK, 'enabled', '0') === '1',
+ 'issuer' => rtrim(trim((string) plugin_setting(AUTHENTIK, 'issuer', '')), '/'),
+ 'client_id' => trim((string) plugin_setting(AUTHENTIK, 'client_id', '')),
+ 'client_secret' => $secret !== '' ? decrypt_secret($secret) : '',
+ 'scopes' => trim((string) plugin_setting(AUTHENTIK, 'scopes', 'openid email profile')) ?: 'openid email profile',
+ 'group' => trim((string) plugin_setting(AUTHENTIK, 'group', '')),
+ 'customers' => plugin_setting(AUTHENTIK, 'customers', '0') === '1',
+ 'customer_create' => plugin_setting(AUTHENTIK, 'customer_create', '1') === '1',
+ 'customer_group' => trim((string) plugin_setting(AUTHENTIK, 'customer_group', '')),
+ 'require_verified' => plugin_setting(AUTHENTIK, 'require_verified', '1') === '1',
+ 'label' => $label !== '' ? mb_substr($label, 0, 60) : 'Mit authentik anmelden',
+ ];
+}
+
+/** Rückkehr-Adresse, die in authentik als „Redirect URI“ eingetragen wird. */
+function authentik_redirect_uri(): string
+{
+ return rtrim(base_url(), '/') . plugin_login_url(AUTHENTIK, 'callback');
+}
+
+/** Prüft eine Adresse des Anmeldedienstes: https (http nur für localhost zum Testen), ohne Zugangsdaten, Parameter und Anker. */
+function authentik_url_ok(string $url, bool $allowQuery = false): bool
+{
+ if ($url === '' || strlen($url) > 300 || preg_match('/[\x00-\x20]/', $url)) return false;
+ $p = parse_url($url);
+ if (!$p || empty($p['host']) || isset($p['user']) || isset($p['pass']) || isset($p['fragment'])) return false;
+ if (!$allowQuery && isset($p['query'])) return false;
+ $scheme = $p['scheme'] ?? '';
+ return $scheme === 'https' || ($scheme === 'http' && in_array($p['host'], ['localhost', '127.0.0.1', '[::1]'], true));
+}
+
+function authentik_ready(): bool
+{
+ $c = authentik_config();
+ return $c['enabled'] && authentik_url_ok($c['issuer']) && $c['client_id'] !== '' && $c['client_secret'] !== '';
+}
+
+/** Ist die Anmeldung für Kunden (Ticketersteller) eingeschaltet und eingerichtet? */
+function authentik_customer_ready(): bool
+{
+ return authentik_ready() && authentik_config()['customers'];
+}
+
+/**
+ * HTTP-Aufruf zum Anmeldedienst mit cURL: Zertifikat wird geprüft, keine Weiterleitungen, begrenzte Antwortgröße und Zeit.
+ * @return array{0:int,1:?string,2:?string} [Status, Antworttext, Fehler]
+ */
+function authentik_http(string $method, string $url, array $opts = []): array
+{
+ if (!function_exists('curl_init')) return [0, null, 'Auf diesem Server fehlt die PHP-Erweiterung cURL.'];
+ if (!authentik_url_ok($url, true)) return [0, null, 'Ungültige Adresse.'];
+ if (isset($GLOBALS['authentik_http']) && is_callable($GLOBALS['authentik_http'])) return ($GLOBALS['authentik_http'])($method, $url, $opts); // für Tests
+ $body = '';
+ $headers = array_merge(['Accept: application/json', 'User-Agent: DeskHub-authentik'], (array) ($opts['headers'] ?? []));
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 12, CURLOPT_CONNECTTIMEOUT => 6,
+ CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
+ CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int {
+ $body .= $data;
+ return strlen($body) > 262144 ? 0 : strlen($data);
+ },
+ ]);
+ if ($method === 'POST') {
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query((array) ($opts['form'] ?? [])));
+ }
+ $ok = curl_exec($ch);
+ $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ $err = $ok === false ? curl_error($ch) : null;
+ curl_close($ch);
+ return [$status, $err === null ? $body : null, $err];
+}
+
+/** Konfiguration des Anmeldedienstes (/.well-known/openid-configuration), eine Stunde lang gemerkt. @return array{ok:bool,error?:string,doc?:array} */
+function authentik_discovery(string $issuer, bool $refresh = false): array
+{
+ if (!$refresh) {
+ $cached = json_decode((string) plugin_setting(AUTHENTIK, 'discovery', ''), true);
+ if (is_array($cached) && ($cached['issuer'] ?? '') === $issuer && (int) ($cached['at'] ?? 0) > time() - 3600 && is_array($cached['doc'] ?? null)) return ['ok' => true, 'doc' => $cached['doc']];
+ }
+ [$status, $body, $err] = authentik_http('GET', $issuer . '/.well-known/openid-configuration');
+ if ($err !== null || $body === null) return ['ok' => false, 'error' => 'authentik ist nicht erreichbar: ' . ($err ?? 'keine Antwort')];
+ $doc = json_decode($body, true);
+ if ($status !== 200 || !is_array($doc)) return ['ok' => false, 'error' => 'authentik antwortet nicht mit einer gültigen Konfiguration (HTTP ' . $status . '). Stimmt die Issuer-Adresse?'];
+ if (rtrim((string) ($doc['issuer'] ?? ''), '/') !== $issuer) return ['ok' => false, 'error' => 'Der Issuer in der Antwort („' . mb_substr((string) ($doc['issuer'] ?? ''), 0, 120) . '“) passt nicht zur eingestellten Adresse.'];
+ foreach (['authorization_endpoint', 'token_endpoint'] as $k) {
+ if (!authentik_url_ok((string) ($doc[$k] ?? ''), true)) return ['ok' => false, 'error' => 'Die Konfiguration von authentik enthält keinen gültigen Eintrag „' . $k . '“.'];
+ }
+ $keep = array_intersect_key($doc, array_flip(['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint']));
+ plugin_set_setting(AUTHENTIK, 'discovery', json_encode(['issuer' => $issuer, 'at' => time(), 'doc' => $keep], JSON_UNESCAPED_SLASHES));
+ return ['ok' => true, 'doc' => $keep];
+}
+
+function authentik_b64url(string $bin): string
+{
+ return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
+}
+
+/** Ablauf starten: Zustand, Nonce und PKCE-Prüfwert in der Sitzung merken, dann weiter zu authentik. $mode: 'login' oder 'link' (Konto verknüpfen). */
+function authentik_begin(string $mode, ?int $staffId = null): array
+{
+ $back = $mode === 'link' ? ['back' => '/staff/plugin.php?p=authentik&page=konto'] : [];
+ if ($mode === 'customer' && !authentik_customer_ready()) return ['error' => 'Die Anmeldung mit authentik ist für Kunden nicht eingeschaltet.'];
+ if (!authentik_ready()) return ['error' => 'Die Anmeldung mit authentik ist nicht eingerichtet.'] + $back;
+ $cfg = authentik_config();
+ $d = authentik_discovery($cfg['issuer']);
+ if (!$d['ok']) return ['error' => $d['error']] + $back;
+ $verifier = authentik_b64url(random_bytes(48));
+ $state = bin2hex(random_bytes(16));
+ $nonce = bin2hex(random_bytes(16));
+ $_SESSION['authentik'] = ['state' => $state, 'nonce' => $nonce, 'verifier' => $verifier, 'at' => time(), 'mode' => $mode, 'staff_id' => $staffId];
+ $url = $d['doc']['authorization_endpoint'];
+ $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query([
+ 'response_type' => 'code', 'client_id' => $cfg['client_id'], 'redirect_uri' => authentik_redirect_uri(), 'scope' => $cfg['scopes'],
+ 'state' => $state, 'nonce' => $nonce, 'code_challenge' => authentik_b64url(hash('sha256', $verifier, true)), 'code_challenge_method' => 'S256',
+ ], '', '&', PHP_QUERY_RFC3986);
+ return ['redirect' => $url];
+}
+
+/** Start der Anmeldung (nicht angemeldet). */
+function authentik_login_start(array $req): array
+{
+ return authentik_begin('login');
+}
+
+/** Start der Anmeldung für Kunden (Kundenportal). */
+function authentik_customer_start(array $req): array
+{
+ return authentik_begin('customer');
+}
+
+/** Start der Verknüpfung (angemeldeter Mitarbeiter). */
+function authentik_link_start(array $req): array
+{
+ $staff = $req['staff'] ?? null;
+ if (!is_array($staff) || empty($staff['id'])) return ['error' => 'Bitte zuerst anmelden.'];
+ return authentik_begin('link', (int) $staff['id']);
+}
+
+/** Liest die Aussagen (Claims) eines ID-Tokens, das direkt vom Token-Endpunkt über TLS kam (dann ist keine Signaturprüfung nötig, OIDC Core 3.1.3.7). */
+function authentik_claims(string $jwt): ?array
+{
+ $parts = explode('.', $jwt);
+ if (count($parts) !== 3) return null;
+ $json = base64_decode(strtr($parts[1], '-_', '+/') . str_repeat('=', (4 - strlen($parts[1]) % 4) % 4), true);
+ $claims = $json === false ? null : json_decode($json, true);
+ return is_array($claims) ? $claims : null;
+}
+
+/**
+ * Rückkehr von authentik: Code gegen Token tauschen und alles prüfen (Zustand, Aussteller, Zielgruppe, Ablauf, Nonce, E-Mail, Gruppe).
+ * @return array{error:string}|array{claims:array,email:string,sub:string}
+ */
+function authentik_exchange(array $q, ?array $sess, string $mode): array
+{
+ $cfg = authentik_config();
+ if (!$sess || time() - (int) ($sess['at'] ?? 0) > 600 || ($sess['mode'] ?? 'login') !== $mode) return ['error' => 'Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „' . ($mode === 'link' ? 'Mit authentik verknüpfen' : $cfg['label']) . '“ klicken.'];
+ if (isset($q['error'])) return ['error' => 'authentik hat die Anmeldung nicht bestätigt (' . mb_substr(preg_replace('/[^A-Za-z0-9 _.\-]/', '', (string) $q['error']), 0, 60) . ').'];
+ $state = (string) ($q['state'] ?? '');
+ $code = (string) ($q['code'] ?? '');
+ if ($state === '' || !hash_equals((string) $sess['state'], $state) || $code === '' || strlen($code) > 2000) return ['error' => 'Die Antwort von authentik passt nicht zur gestarteten Anmeldung. Bitte noch einmal versuchen.'];
+
+ $d = authentik_discovery($cfg['issuer']);
+ if (!$d['ok']) return ['error' => $d['error']];
+ [$status, $body, $err] = authentik_http('POST', $d['doc']['token_endpoint'], ['form' => [
+ 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => authentik_redirect_uri(),
+ 'client_id' => $cfg['client_id'], 'client_secret' => $cfg['client_secret'], 'code_verifier' => (string) $sess['verifier'],
+ ]]);
+ if ($err !== null || $body === null) return ['error' => 'authentik ist nicht erreichbar: ' . ($err ?? 'keine Antwort')];
+ $tok = json_decode($body, true);
+ if ($status !== 200 || !is_array($tok) || empty($tok['id_token'])) return ['error' => 'authentik hat den Anmelde-Code nicht angenommen (HTTP ' . $status . '). Stimmen Client-ID und Geheimnis?'];
+ $claims = authentik_claims((string) $tok['id_token']);
+ if (!$claims) return ['error' => 'Die Antwort von authentik ist unlesbar.'];
+
+ // Prüfung nach OIDC Core: Aussteller, Zielgruppe, Ablauf, Nonce
+ if (rtrim((string) ($claims['iss'] ?? ''), '/') !== $cfg['issuer']) return ['error' => 'Die Antwort stammt nicht vom eingestellten authentik (Aussteller).'];
+ $aud = $claims['aud'] ?? [];
+ $aud = is_array($aud) ? $aud : [$aud];
+ if (!in_array($cfg['client_id'], array_map('strval', $aud), true) || (isset($claims['azp']) && (string) $claims['azp'] !== $cfg['client_id'])) return ['error' => 'Die Antwort von authentik gilt für eine andere Anwendung.'];
+ if ((int) ($claims['exp'] ?? 0) < time() - 60) return ['error' => 'Die Bestätigung von authentik ist abgelaufen.'];
+ if (!hash_equals((string) $sess['nonce'], (string) ($claims['nonce'] ?? ''))) return ['error' => 'Die Antwort von authentik gehört nicht zu dieser Anmeldung (Nonce).'];
+ $sub = (string) ($claims['sub'] ?? '');
+ if ($sub === '' || strlen($sub) > 255) return ['error' => 'Die Antwort von authentik enthält keine Benutzerkennung.'];
+
+ $userinfo = null;
+ $loadUserinfo = function () use (&$userinfo, $tok, $d, $sub) {
+ if ($userinfo !== null) return $userinfo;
+ $userinfo = [];
+ if (!empty($tok['access_token']) && !empty($d['doc']['userinfo_endpoint'])) {
+ [$s2, $b2, $e2] = authentik_http('GET', $d['doc']['userinfo_endpoint'], ['headers' => ['Authorization: Bearer ' . $tok['access_token']]]);
+ $ui = $e2 === null && $b2 !== null && $s2 === 200 ? json_decode($b2, true) : null;
+ if (is_array($ui) && (string) ($ui['sub'] ?? '') === $sub) $userinfo = $ui; // nur der Benutzer dieser Anmeldung zählt
+ }
+ return $userinfo;
+ };
+
+ $email = strtolower(trim((string) ($claims['email'] ?? '')));
+ if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ $claims = $loadUserinfo() + $claims; // manche Einstellungen liefern die Adresse nur über „userinfo“
+ $email = strtolower(trim((string) ($claims['email'] ?? '')));
+ }
+ if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) return ['error' => 'authentik hat keine E-Mail-Adresse geliefert. Im Provider den Bereich „email“ aktivieren.'];
+ // Ob authentik die Adresse als bestätigt meldet. Verlangt wird das nur dort, wo die Adresse über das Konto entscheidet (siehe authentik_unverified_error()).
+ $verified = !array_key_exists('email_verified', $claims) || $claims['email_verified'] !== false;
+
+ // Optional: nur Mitglieder einer Gruppe (für Kunden eine eigene)
+ $needGroup = $mode === 'customer' ? $cfg['customer_group'] : $cfg['group'];
+ if ($needGroup !== '') {
+ $groups = $claims['groups'] ?? null;
+ if (!is_array($groups)) $groups = $loadUserinfo()['groups'] ?? null;
+ $names = array_map(fn($g) => is_array($g) ? (string) ($g['name'] ?? '') : (string) $g, is_array($groups) ? $groups : []);
+ if (!in_array($needGroup, $names, true)) {
+ audit_log(null, 'authentik: ' . mb_substr($email, 0, 120) . ' abgelehnt (nicht in der Gruppe ' . mb_substr($needGroup, 0, 60) . ')');
+ return ['error' => 'Ihr authentik-Konto gehört nicht zur Gruppe „' . $needGroup . '“, die für DeskHub nötig ist.'];
+ }
+ }
+ return ['claims' => $claims, 'email' => $email, 'sub' => $sub, 'verified' => $verified];
+}
+
+/**
+ * Fehlermeldung, wenn authentik die E-Mail-Adresse als „nicht bestätigt“ meldet und die Adresse über das Konto entscheiden würde
+ * (Mitarbeiter ohne Verknüpfung, Kunden). Null = in Ordnung (bestätigt oder Prüfung ausgeschaltet).
+ */
+function authentik_unverified_error(array $x, string $who): ?string
+{
+ if ($x['verified'] || !authentik_config()['require_verified']) return null;
+ return $who === 'customer'
+ ? 'Die E-Mail-Adresse ist in authentik nicht bestätigt. Bitte erst in authentik bestätigen.'
+ : 'Die E-Mail-Adresse ist in authentik nicht bestätigt, daher ordnet DeskHub das Konto nicht über die Adresse zu. Verknüpfen Sie Ihr Konto einmal unter „Mein authentik-Konto“ (nach der Anmeldung mit E-Mail und Passwort), danach geht die Anmeldung mit authentik.';
+}
+
+// ── Verknüpfung DeskHub-Konto ⇄ authentik-Konto (in den Einstellungen gespeichert) ──
+
+/** Kurzer Schlüssel für eine authentik-Benutzerkennung (gilt nur für diesen Issuer). */
+function authentik_link_hash(string $sub): string
+{
+ return substr(hash('sha256', authentik_config()['issuer'] . '|' . $sub), 0, 40);
+}
+
+/** Zu dieser authentik-Kennung verknüpfter Mitarbeiter (ID) oder null. */
+function authentik_link_staff_for(string $sub): ?int
+{
+ $id = (int) plugin_setting(AUTHENTIK, 'link_s_' . authentik_link_hash($sub), '0');
+ return $id > 0 ? $id : null;
+}
+
+/** Verknüpfung eines Mitarbeiters: ['h' => Schlüssel, 'email' => …, 'at' => …] oder null. */
+function authentik_link_of(int $staffId): ?array
+{
+ $d = json_decode((string) plugin_setting(AUTHENTIK, 'link_u_' . $staffId, ''), true);
+ return is_array($d) && !empty($d['h']) ? $d : null;
+}
+
+function authentik_link_set(int $staffId, string $sub, string $email): void
+{
+ authentik_link_remove($staffId);
+ $h = authentik_link_hash($sub);
+ plugin_set_setting(AUTHENTIK, 'link_s_' . $h, (string) $staffId);
+ plugin_set_setting(AUTHENTIK, 'link_u_' . $staffId, json_encode(['h' => $h, 'email' => mb_substr($email, 0, 190), 'at' => date('Y-m-d H:i:s')], JSON_UNESCAPED_UNICODE));
+}
+
+function authentik_link_remove(int $staffId): void
+{
+ $old = authentik_link_of($staffId);
+ $keys = ['link_u_' . $staffId];
+ if ($old) $keys[] = 'link_s_' . $old['h'];
+ foreach ($keys as $k) {
+ plugin_set_setting(AUTHENTIK, $k, ''); // erst leeren (der Einstellungs-Zwischenspeicher der Anfrage), dann die Zeile löschen
+ get_db()->prepare('DELETE FROM settings WHERE setting_key = ?')->execute(['plugin.' . AUTHENTIK . '.' . $k]);
+ }
+}
+
+/** Alle Verknüpfungen mit Name und E-Mail des Mitarbeiters (für die Verwaltung). @return list
+ ✓ Eingeschaltet. Auf der Anmeldeseite des Mitarbeiterbereichs erscheint der Knopf „= e($cfg['label']) ?>“. + Eingeschaltet, aber unvollständig. Issuer-Adresse, Client-ID und Geheimnis müssen eingetragen sein. + Ausgeschaltet. Der Knopf erscheint erst nach dem Einschalten. +
+Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein aktives Mitarbeiterkonto hat, das mit seinem authentik-Konto verknüpft ist (oder dieselbe E-Mail-Adresse hat). Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.
+ +✓ Der Knopf erscheint auf der Anmeldeseite des Kundenportals.Der Knopf erscheint dort noch nicht.
+Noch kein Mitarbeiter hat sein Konto verknüpft. Mitarbeiter tun das selbst unter „Mein authentik-Konto“; bei der ersten Anmeldung über die E-Mail-Adresse geschieht es automatisch.
+ +| Mitarbeiter | E-Mail in DeskHub | E-Mail in authentik | Seit | |
|---|---|---|---|---|
| = e($l['name']) ?> | = e($l['staff_email']) ?> | = e($l['email']) ?> | = e(format_dt($l['at'])) ?> | +
= e($redirect) ?>openid, email und profile. Für die Gruppenprüfung zusätzlich den Bereich, der groups liefert (in authentik: Bereichs-Zuordnung „groups“).https://auth.example.de/application/o/<Anwendungs-Kürzel>/.Sicherheit: Der Ablauf nutzt PKCE, einen Zustandswert und eine Nonce; Aussteller, Zielgruppe und Ablauf des ID-Tokens werden geprüft. Das Token kommt direkt von authentik über eine geprüfte HTTPS-Verbindung. Das Geheimnis liegt verschlüsselt in der Datenbank. Fällt authentik aus, melden sich Mitarbeiter weiter mit E-Mail und Passwort an. Jede Anmeldung steht im Admin-Protokoll.
+Die Anmeldung mit authentik ist nicht eingerichtet. Ein Administrator kann sie unter „authentik“ einschalten.
+ +✓ Verknüpft. Ihr DeskHub-Konto gehört zum authentik-Konto = e((string) ($link['email'] ?? '')) ?> (seit = e(format_dt((string) ($link['at'] ?? ''))) ?>).
+Mit „= e(authentik_config()['label']) ?>“ auf der Anmeldeseite kommen Sie damit direkt in dieses Konto, auch wenn sich die E-Mail-Adresse in authentik oder DeskHub später ändert. Ein anderes authentik-Konto kann dieses DeskHub-Konto nicht öffnen.
+ + +Noch nicht verknüpft. Verknüpfen Sie Ihr DeskHub-Konto einmal mit Ihrem authentik-Konto. Sie melden sich dazu kurz bei authentik an und kommen hierher zurück.
+Danach führt „= e(authentik_config()['label']) ?>“ auf der Anmeldeseite genau in dieses Konto. = $onlyLinked ? 'Ohne Verknüpfung ist die Anmeldung mit authentik hier nicht möglich.' : 'Auch ohne Verknüpfung klappt die Anmeldung, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist; dabei wird sie dann automatisch verknüpft.' ?>
+ Mit authentik verknüpfen + +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.
+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. Solange die Abwesenheit dauert, sieht und bearbeitet die Vertretung auch die Abteilungen der abwesenden Person, selbst wenn sie dort sonst keinen Zugang hat (Server ab 1.0.55).
+Keine Abwesenheiten eingetragen.
| Wer | Vertretung | Zeitraum | Stand | Tickets | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Wer | Vertretung | Zeitraum | Stand | Tickets | Die Vertretung sieht | |||||||||||||||||||||
| = $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)) : '–') ?> |
diff --git a/Plugins/plugins/vertretung/plugin.json b/Plugins/plugins/vertretung/plugin.json
index abc968f6..ad6ad5a5 100644
--- a/Plugins/plugins/vertretung/plugin.json
+++ b/Plugins/plugins/vertretung/plugin.json
@@ -1,9 +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.",
+ "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.0",
+ "requires": "1.0.55",
"permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
}
diff --git a/Plugins/plugins/virenscan-1.2.1.zip b/Plugins/plugins/virenscan-1.2.1.zip
new file mode 100644
index 00000000..271d1693
Binary files /dev/null and b/Plugins/plugins/virenscan-1.2.1.zip differ
diff --git a/Plugins/plugins/virustotal-1.0.0.zip b/Plugins/plugins/virustotal-1.0.0.zip
new file mode 100644
index 00000000..c2461da7
Binary files /dev/null and b/Plugins/plugins/virustotal-1.0.0.zip differ
diff --git a/Plugins/plugins/virustotal/README.md b/Plugins/plugins/virustotal/README.md
new file mode 100644
index 00000000..b9a0b970
--- /dev/null
+++ b/Plugins/plugins/virustotal/README.md
@@ -0,0 +1,45 @@
+# VirusTotal-Anhangsprüfung für DeskHub
+
+Jeder Anhang eines Tickets wird **zurückgehalten, bis VirusTotal ihn geprüft hat**. Erst danach ist er für Mitarbeiter herunterladbar.
+
+Der Unterschied zum Plugin **Anhänge-Virenscan**: Der Virenscan prüft sofort lokal und fragt VirusTotal (optional) erst **nachträglich**; die Datei ist in der Zwischenzeit schon verfügbar. Dieses Plugin sperrt zuerst und gibt erst nach der Antwort von VirusTotal frei. Beide laufen nebeneinander (der Virenscan prüft eine Datei erst, wenn sie hier freigegeben ist).
+
+## Ablauf
+
+1. Neue Anhänge (Ticket anlegen, Antwort, E-Mail-Abruf) werden sofort **zurückgehalten**: Die Datei wandert in den Ordner `uploads/vt-pruefung/` (nicht von außen erreichbar), der Anhang heißt `[IN PRÜFUNG] …` und lässt sich nicht herunterladen. Im Ticket steht ein interner Hinweis.
+2. Der Hintergrunddienst fragt bei VirusTotal den **SHA-256-Fingerabdruck** der Datei ab (nicht die Datei).
+3. Ergebnis:
+ - **Bekannt und sauber** (weniger als *Schwelle* Scanner melden etwas, Standard 3): freigegeben, alles wie vorher.
+ - **Schadsoftware** (mindestens *Schwelle* Scanner): bleibt gesperrt (`[GESPERRT] …`), Tag „Virus-Verdacht“, Hinweis im Ticket, Mail an den Administrator.
+ - **Unbekannt:** Standard ist, die Datei **zurückzuhalten**, bis ein Administrator sie prüft und freigibt oder löscht. Einstellbar: freigeben, oder (nur wenn ausdrücklich eingeschaltet) **zur Analyse hochladen**.
+ - **VirusTotal nicht erreichbar:** mehrere Versuche mit wachsendem Abstand, danach zurückhalten (oder freigeben, einstellbar).
+
+## Einrichtung
+
+1. Plugin hochladen und aktivieren (Berechtigungen `hooks.tickets`, `hooks.cron`, `ui.menu`, `db.migrations`).
+2. Auf virustotal.com ein (kostenloses) Konto anlegen und im Profil den **API Key** kopieren (64 Zeichen).
+3. Im Adminportal unter **VirusTotal** den Schlüssel eintragen, speichern und **Schlüssel testen**.
+
+**Ohne Schlüssel werden keine Anhänge zurückgehalten.** Anhänge, die vor dem Einschalten da waren, bleiben unberührt.
+
+## Datenschutz (wichtig)
+
+- Standard: **Keine Datei verlässt den Server.** Es wird nur der Fingerabdruck abgefragt.
+- „Unbekannte Dateien hochladen“ (aus, bis zu 32 MB) sendet die **Datei** an VirusTotal. Dort wird sie gespeichert und steht Sicherheitsfirmen und zahlenden Kunden zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten (Ausweise, Bilder, Dokumente). Nur einschalten, wenn das für eure Nutzer in Ordnung ist und in der Datenschutzerklärung steht.
+
+## Limits und Verhalten
+
+- Kostenloser Schlüssel: **4 Abfragen pro Minute, 500 pro Tag.** Das Plugin hält sich daran (4 pro Lauf, höchstens 480 pro Tag). Bei vielen Anhängen entsteht eine Warteschlange; ist das Tageslimit erreicht, stehen die Dateien bis zum nächsten Tag an. Ein bezahlter Schlüssel hebt die Grenzen auf; die Zahl im Plugin bleibt konservativ.
+- Die Abfragen laufen im Hintergrunddienst von DeskHub (jede Minute, wenn der echte Cronjob eingerichtet ist, sonst alle 5 Minuten bzw. bei Seitenaufrufen). **Mit Cronjob** (Einstellungen → Cron-Jobs → „Jetzt installieren“) sind Anhänge nach etwa einer Minute frei.
+- Bis zum Ergebnis erscheint der Anhang im Ticket als `[IN PRÜFUNG]` und lässt sich nicht herunterladen (auch nicht vom Kunden, falls er seine Anhänge sieht).
+- Der **Virenscan-Schutz bleibt wichtig**: VirusTotal kennt neue, gezielte Schadsoftware oft nicht. Unbekannt heißt nicht sauber; darum ist „zurückhalten“ der Standard.
+
+## Grenzen
+
+- Geprüft werden Dateien bis 32 MB per Upload; die Abfrage des Fingerabdrucks funktioniert für jede Größe.
+- Es wird nur der Fingerabdruck bewertet (kein Entpacken von Archiven, kein Lesen des Inhalts); dafür gibt es den Virenscan.
+- Bereits freigegebene Dateien werden nicht erneut geprüft.
+
+## Tests
+
+`tests/plugin_virustotal.php` im DeskHub-Projekt (VirusTotal wird dort durch eine Funktion ersetzt; es gibt keine Verbindung zu VirusTotal).
diff --git a/Plugins/plugins/virustotal/lang/en.json b/Plugins/plugins/virustotal/lang/en.json
new file mode 100644
index 00000000..a0bc0868
--- /dev/null
+++ b/Plugins/plugins/virustotal/lang/en.json
@@ -0,0 +1,100 @@
+{
+ "_info": "English translations of the VirusTotal plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number. Further languages: lang/.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/Plugins/plugins/virustotal/lib.php b/Plugins/plugins/virustotal/lib.php
new file mode 100644
index 00000000..5fbddecd
--- /dev/null
+++ b/Plugins/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', ' |