Files
deskhub/DeskHub-Webseite/includes/plugins.php
T
2026-11-05 21:21:44 +00:00

1112 lines
62 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Plugin-System.
*
* Ein Plugin ist ein Ordner plugins/<slug>/ mit plugin.json und einer Startdatei (Standard: plugin.php).
* Die Startdatei registriert beim Laden ihre Erweiterungen:
*
* plugin_add_hook('ticket.created', function (array $e) { ... }); // auf Ereignisse reagieren
* plugin_add_filter('ticket.view.sidebar', fn(string $html, array $c) => $html . '...'); // Inhalte ergänzen
* plugin_register_menu('Mein Plugin', 'meine_seite'); // Menüpunkt -> plugins/<slug>/pages/meine_seite.php
*
* SICHERHEIT: Plugins sind PHP-Code und laufen mit allen Rechten des Systems. Es gibt KEINE Sandbox.
* - Berechtigungen (plugin.json "permissions") begrenzen, welche ERWEITERUNGSPUNKTE ein Plugin registrieren darf
* (Ticket-Ereignisse, Cron, Menü, Seitenleiste, API, eigene Tabellen). Der Admin bestätigt sie bei der Aktivierung.
* Sie verhindern NICHT, dass beliebiger PHP-Code im Plugin Dateien oder die Datenbank anfasst.
* - Signaturen (RSA-SHA256, Herausgeber-Schlüssel) belegen die Herkunft eines Pakets; das ist der eigentliche Schutz.
* - Fehler-Isolation und automatische Abschaltung verhindern, dass ein defektes Plugin das System lahmlegt.
*/
const PLUGIN_FAIL_LIMIT = 3; // so viele Fehler seit der letzten Aktivierung, dann wird das Plugin automatisch deaktiviert
const PLUGIN_MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
const PLUGIN_ALLOWED_EXTENSIONS = ['php', 'json', 'sql', 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'md', 'txt', 'html'];
/** Bekannte Berechtigungen (Erweiterungspunkte), die ein Plugin in plugin.json anfordern kann. */
function plugin_known_permissions(): array
{
return [
'hooks.tickets' => 'Auf Ticket-Ereignisse reagieren (erstellt, Antwort, Status, Zuweisung)',
'hooks.cron' => 'Regelmäßig im Hintergrund laufen (etwa alle 5 Minuten)',
'ui.menu' => 'Eigene Menüpunkte und Seiten im Mitarbeiterbereich',
'ui.sidebar' => 'Bausteine in der Seitenleiste der Ticketansicht',
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
'api.public' => 'Öffentliche Webhook-Adresse (zum Beispiel für Discord oder Telegram); das Plugin prüft die Echtheit der Anfragen selbst',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
'auth.customer' => 'Kunden über einen externen Dienst anmelden und Kundenkonten anlegen (zum Beispiel authentik): Knopf auf der Anmeldeseite des Kundenportals. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf und ob Konten entstehen.',
'auth.login' => 'Mitarbeiter über einen externen Dienst anmelden (zum Beispiel authentik): Knopf auf der Anmeldeseite und eigene Anmelde-Adresse. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf.',
];
}
/** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */
function &plugin_state(): array
{
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'logins' => [], 'login_loggedin' => [], 'customer_logins' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
return $state;
}
function plugins_dir(): string
{
return rtrim((string) (getenv('DESKHUB_PLUGINS_DIR') ?: dirname(__DIR__) . '/plugins'), '/\\');
}
function plugin_valid_slug(string $slug): bool
{
return (bool) preg_match('/^[a-z0-9][a-z0-9_-]{1,40}$/', $slug);
}
/** Notausschalter: Datei plugins/.disabled anlegen oder in config.php 'plugins' => ['enabled' => false]. */
function plugins_globally_disabled(): bool
{
return is_file(plugins_dir() . '/.disabled') || (config()['plugins']['enabled'] ?? true) === false;
}
/** Liest und prüft plugin.json. Gibt [manifest|null, Fehlertext|null] zurück. */
function plugin_read_manifest(string $dir): array
{
$file = $dir . '/plugin.json';
if (!is_file($file)) return [null, 'plugin.json fehlt.'];
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) return [null, 'plugin.json ist kein gültiges JSON.'];
foreach (['name', 'version'] as $required) {
if (!isset($data[$required]) || !is_string($data[$required]) || trim($data[$required]) === '') return [null, "plugin.json: Feld \"{$required}\" fehlt."];
}
if (!preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $data['version'])) return [null, 'plugin.json: ungültige Versionsnummer.'];
$entry = (string) ($data['entry'] ?? 'plugin.php');
if (!preg_match('/^[A-Za-z0-9_\-]+\.php$/', $entry)) return [null, 'plugin.json: ungültige Startdatei.'];
if (!is_file($dir . '/' . $entry)) return [null, "Startdatei {$entry} fehlt."];
$requires = (string) ($data['requires'] ?? '');
if ($requires !== '' && defined('DESKHUB_VERSION') && version_compare(DESKHUB_VERSION, $requires, '<')) {
return [null, "Benötigt DeskHub {$requires} oder neuer (installiert: " . DESKHUB_VERSION . ').'];
}
$permissions = array_values(array_unique(array_map('strval', (array) ($data['permissions'] ?? []))));
foreach ($permissions as $perm) {
if (!isset(plugin_known_permissions()[$perm])) return [null, "plugin.json: unbekannte Berechtigung \"{$perm}\". Erlaubt: " . implode(', ', array_keys(plugin_known_permissions())) . '.'];
}
sort($permissions);
return [[
'name' => mb_substr(trim($data['name']), 0, 120),
'version' => $data['version'],
'description' => mb_substr((string) ($data['description'] ?? ''), 0, 500),
'author' => mb_substr((string) ($data['author'] ?? ''), 0, 120),
'requires' => $requires,
'entry' => $entry,
'permissions' => $permissions,
], null];
}
// ─── Registrierung (wird von Plugins aufgerufen) ─────────────────────────────
/**
* Darf das gerade ladende Plugin diesen Erweiterungspunkt nutzen? Ohne laufendes Plugin (Kerncode) immer ja.
* Verweigerte Registrierungen werden am Plugin vermerkt (Fehlertext im Adminportal), zählen aber nicht als Absturz.
*/
function plugin_permission_allowed(?string $permission): bool
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || $permission === null) return true;
if (in_array($permission, $state['perms'][$slug] ?? [], true)) return true;
$msg = "Nicht deklarierte Berechtigung \"{$permission}\": Die Registrierung wurde abgelehnt. Bitte in plugin.json ergänzen.";
error_log("plugin[{$slug}]: {$msg}");
try {
get_db()->prepare('UPDATE plugins SET last_error = ? WHERE slug = ?')->execute([$msg, $slug]);
} catch (Throwable $e) {
}
return false;
}
/** Erforderliche Berechtigung für das Abonnieren eines Ereignisses. */
function plugin_hook_permission(string $hook): ?string
{
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'cron.run' || $hook === 'cron.minute') return 'hooks.cron';
return null;
}
/** Erforderliche Berechtigung für einen Filter. */
function plugin_filter_permission(string $hook): ?string
{
if ($hook === 'ticket.view.sidebar' || $hook === 'staff.page.top') return 'ui.sidebar';
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'staff.login.buttons') return 'auth.login'; // Knöpfe auf der Mitarbeiter-Anmeldeseite
if ($hook === 'customer.login.buttons') return 'auth.customer'; // Knöpfe auf der Kunden-Anmeldeseite
if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen
return null;
}
/** Reagiert auf ein Ereignis (Rückgabewert wird ignoriert). */
function plugin_add_hook(string $hook, callable $fn, int $priority = 10): void
{
if (!plugin_permission_allowed(plugin_hook_permission($hook))) return;
$state = &plugin_state();
$state['hooks'][$hook][$priority][] = ['fn' => $fn, 'plugin' => $state['current']];
}
/** Verändert einen Wert (Rückgabewert ersetzt den Wert). */
function plugin_add_filter(string $hook, callable $fn, int $priority = 10): void
{
if (!plugin_permission_allowed(plugin_filter_permission($hook))) return;
$state = &plugin_state();
$state['filters'][$hook][$priority][] = ['fn' => $fn, 'plugin' => $state['current']];
}
/**
* Menüpunkt im Mitarbeiterbereich. $page ist der Dateiname unter plugins/<slug>/pages/ (ohne .php).
* Mit $belongsTo (zum Beispiel 'backup', siehe plugin_parent_pages()) erscheint kein eigener Eintrag in der Seitenleiste: Die Seite wird
* stattdessen als Reiter innerhalb dieser Seite angeboten (zum Beispiel unter „Backup“). Dafür ist keine zusätzliche Berechtigung nötig (ui.menu genügt).
*/
function plugin_register_menu(string $label, string $page, ?string $permission = null, ?string $belongsTo = null): void
{
if (!plugin_permission_allowed('ui.menu')) return;
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $page)) return;
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission, 'belongs_to' => ($belongsTo !== null && ($belongsTo === 'admin' || isset(plugin_parent_pages()[$belongsTo]))) ? $belongsTo : null];
}
/**
* Eigener API-Endpunkt: GET/POST /api/staff/v1/plugin.php?p=<slug>&endpoint=<name> (Bearer-Token wie die übrige Mitarbeiter-API).
* Der Handler erhält ($staff, $request) und gibt ein Array zurück, das als JSON ausgeliefert wird.
*/
/**
* Registriert eine öffentliche Webhook-Adresse: POST /api/public/v1/plugin_webhook.php?p=<slug>&e=<name>. Gedacht für Dienste, die selbst
* Anfragen schicken (Discord, Telegram, Zahlungsanbieter). Die Adresse ist ohne Anmeldung erreichbar; das Plugin MUSS die Echtheit der Anfrage
* selbst prüfen (Signatur oder geheimes Token) und ungültige Anfragen mit Status 401 beantworten, bevor es etwas tut.
* Der Handler erhält ['method', 'headers' (Namen klein), 'body' (roher Text, höchstens 256 KB), 'query', 'ip'] und gibt
* ['status' => 200, 'json' => [...]] oder ['status' => 200, 'body' => 'Text', 'headers' => ['Content-Type' => '...']] zurück.
*/
function plugin_register_webhook(string $endpoint, callable $handler): void
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
if (!plugin_permission_allowed('api.public')) return;
$state['webhooks'][$slug][$endpoint] = $handler;
}
/**
* Registriert eine Anmelde-Adresse für Mitarbeiter: GET /staff/plugin_login.php?p=<slug>&e=<name>. Gedacht für externe Anmeldedienste (OpenID Connect, SAML …).
* Der Handler erhält ['query' => $_GET, 'ip' => …] (die Sitzung $_SESSION steht zur Verfügung) und gibt eines davon zurück:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
* ['staff_id' => 12] dieser Mitarbeiter ist geprüft: DeskHub meldet ihn an (nur aktive Konten, mit Eintrag im Admin-Protokoll)
* ['error' => 'Text'] zurück zur Anmeldeseite mit dieser Meldung
* Den Link zum Starten baut plugin_login_url(). Wer anmelden darf, entscheidet allein das Plugin (Berechtigung auth.login).
*
* Mit $whenLoggedIn = true läuft die Adresse auch für bereits angemeldete Mitarbeiter (zum Beispiel, um ein Konto beim Anmeldedienst zu verknüpfen).
* Der Handler bekommt dann zusätzlich ['staff' => <angemeldeter Mitarbeiter>] und darf nur zurückgeben:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst
* ['local' => '/staff/…', 'notice' => 'Text'] zurück in den Mitarbeiterbereich, mit Hinweis
* ['error' => 'Text', 'back' => '/staff/…'] zurück mit Fehlermeldung
* Ein anderer Mitarbeiter kann so nie angemeldet werden (staff_id wird dann abgelehnt).
*/
function plugin_register_login(string $endpoint, callable $handler, bool $whenLoggedIn = false): void
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
if (!plugin_permission_allowed('auth.login')) return;
$state['logins'][$slug][$endpoint] = $handler;
$state['login_loggedin'][$slug][$endpoint] = $whenLoggedIn;
}
/** Adresse (ohne Domain) einer mit plugin_register_login() angemeldeten Anmelde-Adresse. */
function plugin_login_url(string $slug, string $endpoint): string
{
return '/staff/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
}
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
{
if (!plugin_permission_allowed('api.endpoints')) return;
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $endpoint)) return;
$state['api'][$slug][$endpoint] = ['fn' => $handler, 'permission' => $permission];
}
function plugin_setting(string $slug, string $key, ?string $default = null): ?string
{
return get_setting("plugin.{$slug}.{$key}", $default);
}
function plugin_set_setting(string $slug, string $key, ?string $value): void
{
set_setting("plugin.{$slug}.{$key}", $value);
}
/** Slug des Plugins, dessen Code gerade ausgeführt wird (für Einstellungen u. Ä. innerhalb des Plugins). */
function plugin_current_slug(): ?string
{
return plugin_state()['current'];
}
// ─── Ausführung mit Fehler-Isolation ─────────────────────────────────────────
/** Führt Plugin-Code aus; Fehler werden dem Plugin zugeordnet und legen nie die aufrufende Seite lahm. */
function plugin_guard(?string $slug, callable $fn, $fallback = null)
{
$state = &plugin_state();
$previous = $state['current'];
$state['current'] = $slug;
try {
return $fn();
} catch (Throwable $e) {
if ($slug !== null) plugin_record_failure($slug, $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')');
else error_log('plugin: ' . $e->getMessage());
return $fallback;
} finally {
$state['current'] = $previous;
}
}
function plugin_record_failure(string $slug, string $message): void
{
error_log("plugin[{$slug}]: {$message}");
try {
$pdo = get_db();
$pdo->prepare('UPDATE plugins SET fail_count = fail_count + 1, last_error = ? WHERE slug = ?')->execute([mb_substr($message, 0, 500), $slug]);
$fails = (int) $pdo->query('SELECT fail_count FROM plugins WHERE slug = ' . $pdo->quote($slug))->fetchColumn();
$disabled = $fails >= PLUGIN_FAIL_LIMIT;
if ($disabled) {
$pdo->prepare('UPDATE plugins SET is_active = 0 WHERE slug = ?')->execute([$slug]);
error_log("plugin[{$slug}]: nach {$fails} Fehlern automatisch deaktiviert");
}
// Mail an den Administrator (gleiche Drosselung wie bei schweren Fehlern: dieselbe Meldung höchstens einmal pro Stunde)
if (function_exists('error_alert_notice')) {
$name = (string) ($pdo->query('SELECT name FROM plugins WHERE slug = ' . $pdo->quote($slug))->fetchColumn() ?: $slug);
error_alert_notice(
$disabled ? "Plugin „{$name}“ wurde wegen Fehlern deaktiviert" : "Plugin „{$name}“ meldet einen Fehler",
$disabled ? "Das Plugin „{$name}“ ({$slug}) hat {$fails} Fehler verursacht und wurde automatisch deaktiviert. Die Webseite selbst läuft weiter. Die Funktionen des Plugins stehen erst wieder zur Verfügung, wenn Sie es unter Plugins erneut aktivieren." : "Das Plugin „{$name}“ ({$slug}) hat einen Fehler gemeldet (Fehler {$fails} von " . PLUGIN_FAIL_LIMIT . '). Nach ' . PLUGIN_FAIL_LIMIT . ' Fehlern wird es automatisch deaktiviert.',
'plugins/' . $slug . ($disabled ? ' (deaktiviert)' : ''),
$message
);
}
} catch (Throwable $e) {
// Die Fehlerbehandlung selbst darf nie einen weiteren Fehler auslösen.
}
}
/** Löst ein Ereignis aus. Alle registrierten Plugin-Funktionen laufen isoliert. */
function plugin_hook(string $hook, array $payload = []): void
{
$state = &plugin_state();
if (empty($state['hooks'][$hook])) return;
$byPriority = $state['hooks'][$hook];
ksort($byPriority);
foreach ($byPriority as $list) {
foreach ($list as $entry) plugin_guard($entry['plugin'], fn() => ($entry['fn'])($payload));
}
}
/** Reicht einen Wert durch alle Filter. Bei Fehlern bleibt der bisherige Wert erhalten. */
function plugin_filter(string $hook, $value, array $context = [])
{
$state = &plugin_state();
if (empty($state['filters'][$hook])) return $value;
$byPriority = $state['filters'][$hook];
ksort($byPriority);
foreach ($byPriority as $list) {
foreach ($list as $entry) $value = plugin_guard($entry['plugin'], fn() => ($entry['fn'])($value, $context), $value);
}
return $value;
}
// ─── Laden ───────────────────────────────────────────────────────────────────
/** Lädt alle aktiven Plugins (einmal pro Request). */
function plugins_boot(): void
{
$state = &plugin_state();
if ($state['booted']) return;
$state['booted'] = true;
if (plugins_globally_disabled()) return;
try {
$rows = get_db()->query('SELECT slug, approved_permissions, signature_ok, package_hash FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll();
} catch (Throwable $e) {
return; // Tabelle fehlt (Datenbank noch nicht aktualisiert)
}
if (!$rows) return;
// Harte Fehler (Fatal Error) lassen sich nicht abfangen: Sie dem Plugin zuordnen und es abschalten.
if (!$state['shutdown']) {
$state['shutdown'] = true;
register_shutdown_function(function () {
$err = error_get_last();
$current = plugin_state()['current'];
if ($current !== null && $err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
try {
get_db()->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute([mb_substr('Schwerer Fehler: ' . $err['message'], 0, 500), $current]);
} catch (Throwable $e) {
}
}
});
}
foreach ($rows as $row) {
$slug = (string) $row['slug'];
if (!plugin_valid_slug($slug)) continue;
$dir = plugins_dir() . '/' . $slug;
[$manifest, $error] = plugin_read_manifest($dir);
if (!$manifest) { plugin_record_failure($slug, (string) $error); continue; }
// Nur laden, wenn der Admin alle angeforderten Berechtigungen bestätigt hat (z. B. nach einem Update mit neuen Rechten)
$approved = array_filter(explode(',', (string) ($row['approved_permissions'] ?? '')));
$missing = array_diff($manifest['permissions'], $approved);
if ($missing) {
get_db()->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute(['Neue Berechtigungen müssen bestätigt werden: ' . implode(', ', $missing) . '. Das Plugin wurde deaktiviert.', $slug]);
continue;
}
$state['perms'][$slug] = $manifest['permissions'];
if (in_array('db.migrations', $manifest['permissions'], true)) plugin_ensure_migrated($slug, (string) ($row['package_hash'] ?? ''));
plugin_guard($slug, function () use ($dir, $manifest, $slug, &$state) {
require $dir . '/' . $manifest['entry']; // einmal pro Request (plugins_boot läuft nur einmal)
$state['loaded'][$slug] = $manifest;
});
}
}
/**
* Spielt neue Datenbank-Änderungen eines Plugins ein, wenn sich sein Paket seit dem letzten Mal geändert hat (zum Beispiel nach einem Update
* des aktiven Plugins; sonst liefen sie erst bei der nächsten Aktivierung). Läuft vor dem Laden des Plugins, höchstens einmal je Paketstand.
*/
function plugin_ensure_migrated(string $slug, string $packageHash): void
{
if ($packageHash === '') return;
$key = 'plugin_migrated.' . $slug;
try {
if (get_setting($key, '') === $packageHash) return;
$res = plugin_run_migrations(get_db(), $slug);
if ($res['errors']) {
plugin_record_failure($slug, 'Datenbank-Änderung fehlgeschlagen: ' . implode('; ', $res['errors']));
return;
}
set_setting($key, $packageHash);
} catch (Throwable $e) {
plugin_record_failure($slug, 'Datenbank-Änderung fehlgeschlagen: ' . $e->getMessage());
}
}
/** Meldung nach Installation oder Update eines Plugins. @param array $res Ergebnis von plugin_install_zip(), $row Zeile aus plugins_installed() */
function plugin_install_message(array $res, array $row): string
{
$sig = $res['signed'] ? 'Signatur geprüft' : 'nicht signiert';
if (!$res['updated']) return "{$res['name']} {$res['version']} installiert ({$sig}). Es ist noch nicht aktiv.";
$msg = "{$res['name']} auf Version {$res['version']} aktualisiert ({$sig}).";
if ($res['needs_reapproval']) return $msg . ' Die neue Version fordert zusätzliche Berechtigungen an (' . implode(', ', $res['needs_reapproval']) . '). Das Plugin ist nicht aktiv: Bitte prüfen und beim Aktivieren bestätigen.';
if (!empty($row['is_active'])) return $msg . ' Das Plugin läuft weiter, neue Datenbank-Änderungen werden beim nächsten Aufruf automatisch eingespielt.';
return $msg . ' Es ist weiterhin nicht aktiv.';
}
/** Menüpunkte der geladenen Plugins, gefiltert nach Rechten des Mitarbeiters. */
function plugin_menu_items(?array $staff): array
{
$items = [];
foreach (plugin_state()['menus'] as $m) {
if ($m['permission'] !== null && (!$staff || !has_permission($staff, $m['permission']))) continue;
if (!empty($m['belongs_to'])) continue; // erscheint als Reiter in der übergeordneten Seite, nicht in der Seitenleiste
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
/**
* Menüpunkte im Adminportal (Gruppe „Plugins“): Plugins registrieren sie mit plugin_register_menu(…, 'admin'). Nur für echte Administratoren;
* ist zusätzlich eine Berechtigung angegeben, muss sie auch erfüllt sein.
*/
function plugin_admin_menu_items(?array $staff): array
{
$items = [];
if (!$staff || ($staff['role'] ?? '') !== 'admin') return $items;
foreach (plugin_state()['menus'] as $m) {
if (($m['belongs_to'] ?? null) !== 'admin') continue;
if ($m['permission'] !== null && !has_permission($staff, $m['permission'])) continue;
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
/** Seiten, unter denen Plugins Reiter anbieten dürfen: Schlüssel => [Bezeichnung des ersten Reiters, Adresse]. */
function plugin_parent_pages(): array
{
return ['backup' => ['Backups', '/staff/backup.php']];
}
/** Plugin-Seiten (mit Rechteprüfung), die als Reiter zu einer übergeordneten Seite gehören. */
function plugin_subpages(string $parent, ?array $staff): array
{
$items = [];
foreach (plugin_state()['menus'] as $m) {
if (($m['belongs_to'] ?? null) !== $parent) continue;
if ($m['permission'] !== null && (!$staff || !has_permission($staff, $m['permission']))) continue;
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
/** Reiterleiste einer übergeordneten Seite (leer, wenn kein Plugin Reiter anbietet). $active: 'parent' oder '<plugin>:<seite>'. */
function plugin_subpage_tabs(string $parent, ?array $staff, string $active = 'parent'): string
{
$parents = plugin_parent_pages();
$subs = plugin_subpages($parent, $staff);
if (!$subs || !isset($parents[$parent])) return '';
$tab = function (string $label, string $url, bool $on): string {
return '<a href="' . htmlspecialchars($url, ENT_QUOTES) . '" style="padding:8px 16px;border-radius:8px 8px 0 0;text-decoration:none;font-size:.9rem;'
. ($on ? 'background:var(--card-bg,#fff);border:1px solid var(--border);border-bottom-color:var(--card-bg,#fff);font-weight:600;color:inherit;position:relative;top:1px' : 'color:var(--muted);border:1px solid transparent') . '">' . htmlspecialchars($label) . '</a>';
};
$html = '<div class="plugin-tabs" style="display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--border);margin:0 0 18px">' . $tab($parents[$parent][0], $parents[$parent][1], $active === 'parent');
foreach ($subs as $s) $html .= $tab($s['label'], $s['url'], $active === $s['plugin'] . ':' . $s['page']);
return $html . '</div>';
}
// ─── Verwaltung (Installieren, Aktivieren, Entfernen) ────────────────────────
function plugins_installed(PDO $pdo): array
{
$rows = [];
try {
foreach ($pdo->query('SELECT * FROM plugins ORDER BY name') as $r) $rows[$r['slug']] = $r;
} catch (Throwable $e) {
return [];
}
return $rows;
}
// ─── Signaturen ──────────────────────────────────────────────────────────────
/**
* Offizielle Vorgaben dieser DeskHub-Version (includes/catalog_defaults.php, Teil des Programmcodes).
* Es gibt KEINE Einstellung, Umgebungsvariable oder Oberfläche, die sie überschreibt. Nur Tests dürfen sie ersetzen, und nur
* wenn die Umgebungsvariable DESKHUB_TESTING=1 gesetzt ist (auf einem Server nie der Fall).
*/
function catalog_defaults(): array
{
static $file = null;
$testing = getenv('DESKHUB_TESTING') === '1';
$override = $testing ? getenv('DESKHUB_CATALOG_DEFAULTS') : false;
$d = ($testing ? ($GLOBALS['deskhub_catalog_defaults'] ?? null) : null)
?? ($override && is_file($override) ? (array) require $override : ($file ??= (array) require __DIR__ . '/catalog_defaults.php'));
return ['plugins_url' => trim((string) ($d['plugins_url'] ?? '')), 'themes_url' => trim((string) ($d['themes_url'] ?? '')), 'publisher_key' => trim((string) ($d['publisher_key'] ?? '')),
'access_key' => trim((string) ($d['access_key'] ?? ''))];
}
/**
* Zusätzliche Header für einen Abruf beim offiziellen Katalog-Server: der feste Zugangsschlüssel. Er wird NUR an diesen Server
* geschickt (gleicher Host wie die offiziellen Adressen, nur https), nie an andere Adressen.
*/
function catalog_access_headers(string $url): array
{
$d = catalog_defaults();
if ($d['access_key'] === '' || !str_starts_with($url, 'https://')) return [];
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
foreach ([$d['plugins_url'], $d['themes_url']] as $official) {
if ($official !== '' && $host !== '' && $host === strtolower((string) parse_url($official, PHP_URL_HOST))) return ['X-DeskHub-Key: ' . $d['access_key']];
}
return [];
}
/** Hat der Herausgeber dieser Version einen offiziellen, geprüften Katalog eingetragen? */
function plugin_official_configured(): bool
{
$d = catalog_defaults();
return $d['plugins_url'] !== '' && $d['publisher_key'] !== '';
}
/**
* "Katalog fest": gilt immer, sobald ein offizieller Katalog eingetragen ist. Katalog-Adresse und Herausgeber-Schlüssel sind dann
* fest vorgegeben, und aus dem Katalog lässt sich nur installieren, was der Herausgeber signiert hat. Der Admin kann das nicht
* ändern. Eigene Plugins (manueller Upload, nicht signiert) bleiben ausdrücklich erlaubt, sie gelten aber als "nicht geprüft".
*/
function plugin_strict_mode(): bool
{
return plugin_official_configured();
}
/** Öffentlicher Herausgeber-Schlüssel (PEM). Im strengen Modus immer der offizielle. Leer = Signaturen können nicht geprüft werden. */
function plugin_publisher_key(): string
{
if (plugin_strict_mode()) return catalog_defaults()['publisher_key'];
$own = trim((string) get_setting('plugin_publisher_key', ''));
return $own !== '' ? $own : catalog_defaults()['publisher_key'];
}
/**
* Muss das Paket signiert sein? Aus dem Katalog: immer, sobald der offizielle Katalog fest eingebaut ist. Eigene Uploads dürfen
* unsigniert sein, es sei denn, ohne offiziellen Katalog hat der Admin "Nur signierte Plugins" eingeschaltet.
*/
function plugin_signatures_required(string $source = 'upload'): bool
{
$setting = get_setting('plugin_require_signature', '0') === '1';
if ($source === 'catalog') return plugin_strict_mode() || $setting;
return !plugin_strict_mode() && $setting;
}
/** Die signierte Nachricht bindet Name, Version und Inhalt (SHA-256) des Pakets aneinander. */
function plugin_signature_message(string $slug, string $version, string $sha256): string
{
return "deskhub-plugin\n{$slug}\n{$version}\n" . strtolower($sha256);
}
/** Prüft eine Base64-Signatur (RSA, SHA-256) mit einem öffentlichen PEM-Schlüssel. */
function plugin_verify_signature(string $message, string $signatureBase64, string $publicKeyPem): bool
{
if (!function_exists('openssl_verify')) return false;
$sig = base64_decode(trim($signatureBase64), true);
$key = @openssl_pkey_get_public($publicKeyPem);
if ($sig === false || $sig === '' || $key === false) return false;
return openssl_verify($message, $sig, $key, OPENSSL_ALGO_SHA256) === 1;
}
/** Ist das ein brauchbarer öffentlicher RSA-Schlüssel (PEM)? */
function plugin_public_key_valid(string $pem): bool
{
$key = @openssl_pkey_get_public($pem);
if ($key === false) return false;
$details = openssl_pkey_get_details($key);
return ($details['type'] ?? null) === OPENSSL_KEYTYPE_RSA && ($details['bits'] ?? 0) >= 2048;
}
// ─── Katalog (Plugin-Webseite) ───────────────────────────────────────────────
function plugin_catalog_url(): string
{
if (plugin_strict_mode()) return catalog_defaults()['plugins_url'];
$own = trim((string) get_setting('plugin_catalog_url', ''));
return $own !== '' ? $own : catalog_defaults()['plugins_url'];
}
/**
* Katalog mit kurzem Zwischenspeicher (Standard 5 Minuten), damit die Plugin-Seite nicht bei jedem Aufruf den Server abfragen muss und trotzdem
* (fast) immer aktuell ist. $refresh = true erzwingt einen neuen Abruf, $maxAge legt fest, wie alt der Zwischenspeicher höchstens sein darf.
* Ist der Katalog-Server gerade nicht erreichbar, zeigt die Seite den letzten Stand und fragt zwei Minuten lang nicht erneut, statt bei jedem
* Aufruf zu warten.
*
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int, stale?:bool}
*/
function plugin_catalog_cached(bool $refresh = false, int $maxAge = 300): array
{
$url = plugin_catalog_url();
$key = hash('sha256', $url);
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
$usable = is_array($cached) && ($cached['key'] ?? '') === $key;
if (!$refresh && $usable) {
$age = time() - (int) ($cached['at'] ?? 0);
if ($age < $maxAge) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at']];
if (time() - (int) get_setting('plugin_catalog_fail_at', '0') < 120) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at'], 'stale' => true];
}
$res = plugin_catalog_fetch();
if ($res['ok']) {
set_setting('plugin_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'plugins' => $res['plugins']], JSON_UNESCAPED_UNICODE));
set_setting('plugin_catalog_fail_at', '0');
$res['cached_at'] = time();
return $res;
}
set_setting('plugin_catalog_fail_at', (string) time());
if (!$refresh && $usable) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at'], 'stale' => true];
return $res;
}
/**
* Hält die Kataloge im Hintergrund frisch (wird von der Automatisierung etwa alle 5 Minuten aufgerufen), damit die Seiten sofort
* anzeigen und nie auf den Katalog-Server warten müssen. Fehler (Server nicht erreichbar) lassen den alten Stand stehen.
*/
function catalog_refresh_background(): void
{
if (!plugin_official_configured()) return;
try {
if (plugin_catalog_url() !== '') plugin_catalog_cached(true);
if (function_exists('theme_catalog_cached') && theme_catalog_url() !== '') theme_catalog_cached(true);
} catch (Throwable $e) {
error_log('catalog_refresh_background: ' . $e->getMessage());
}
}
/**
* Installierte Plugins, für die der (zwischengespeicherte) Katalog eine neuere Version kennt. Fragt nie den Server ab,
* ist also in jeder Seite billig.
*
* @return array<int, array{slug:string, name:string, installed:string, available:string}>
*/
/**
* Installiert alle Plugin-Updates aus dem Katalog (jedes einzeln, mit Prüfsumme und Signatur; ein Fehler stoppt die anderen nicht).
* @return array{updated:list<string>,failed:array<string,string>} Namen mit Version bzw. Slug => Fehlertext
*/
function plugin_catalog_update_all(PDO $pdo): array
{
$out = ['updated' => [], 'failed' => []];
foreach (plugin_updates_available($pdo) as $u) {
$r = plugin_catalog_install($pdo, $u['slug']);
if (!empty($r['ok'])) $out['updated'][] = $u['name'] . ' ' . $r['version'];
else $out['failed'][$u['slug']] = (string) ($r['error'] ?? 'Unbekannter Fehler');
}
return $out;
}
function plugin_updates_available(PDO $pdo): array
{
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
if (!is_array($cached) || empty($cached['plugins'])) return [];
$installed = plugins_installed($pdo);
$out = [];
foreach ($cached['plugins'] as $c) {
$have = $installed[$c['slug']] ?? null;
if ($have && version_compare($c['version'], $have['version'], '>')) $out[] = ['slug' => $c['slug'], 'name' => $c['name'], 'installed' => $have['version'], 'available' => $c['version']];
}
return $out;
}
/** Vorschau-Adresse aus dem Katalog: nur https und nur auf demselben Server wie der Katalog, sonst leer. */
function catalog_preview_url(string $catalogUrl, string $previewUrl): string
{
if ($previewUrl === '' || !str_starts_with($previewUrl, 'https://') || strlen($previewUrl) > 500) return '';
return strcasecmp((string) parse_url($previewUrl, PHP_URL_HOST), (string) parse_url($catalogUrl, PHP_URL_HOST)) === 0 ? $previewUrl : '';
}
/**
* Vorschaubild eines Katalogeintrags (Plugin oder Theme). DeskHub lässt im Browser nur eigene Bilder zu, deshalb wird das Bild
* hier geladen, geprüft (PNG/JPEG/WebP, höchstens 1 MB) und einen Tag lang zwischengespeichert.
*
* @return array{mime:string, data:string}|null
*/
function catalog_preview_image(string $kind, string $slug): ?array
{
if (!plugin_valid_slug($slug) || !in_array($kind, ['plugin', 'theme'], true)) return null;
// Die Adresse kommt aus dem (zwischengespeicherten) Katalog; der Zwischenspeicher des Bildes hängt an dieser Adresse,
// ein neues Bild wird also sofort erkannt. Fehlschläge werden nur kurz gemerkt (5 Minuten), Erfolge einen Tag.
$catalog = $kind === 'theme' ? theme_catalog_cached() : plugin_catalog_cached();
$list = $catalog['ok'] ? ($catalog[$kind === 'theme' ? 'themes' : 'plugins'] ?? []) : [];
$url = '';
foreach ($list as $e) if ($e['slug'] === $slug) { $url = (string) ($e['preview'] ?? ''); break; }
if ($url === '') return null;
$cacheFile = sys_get_temp_dir() . '/deskhub_preview_' . hash('sha256', $url) . '.bin';
if (is_file($cacheFile)) {
$c = json_decode((string) file_get_contents($cacheFile), true);
$ttl = is_array($c) && !empty($c['data']) ? 86400 : 300;
if (is_array($c) && time() - filemtime($cacheFile) < $ttl) return !empty($c['data']) ? ['mime' => $c['mime'], 'data' => base64_decode($c['data'])] : null;
}
$result = null;
if (plugin_url_check($url) === null) {
[, $body, $error] = plugin_http_get($url, 1048576, 8);
$info = $error === null && $body !== null ? @getimagesizefromstring($body) : false;
$types = [IMAGETYPE_PNG => 'image/png', IMAGETYPE_JPEG => 'image/jpeg', IMAGETYPE_WEBP => 'image/webp'];
if ($info && isset($types[$info[2]])) $result = ['mime' => $types[$info[2]], 'data' => $body];
}
@file_put_contents($cacheFile, json_encode($result ? ['mime' => $result['mime'], 'data' => base64_encode($result['data'])] : ['data' => '']), LOCK_EX);
return $result;
}
/** Prüft eine Katalog-/Download-Adresse: nur https, kein internes Ziel. Gibt einen Fehlertext oder null zurück. */
function plugin_url_check(string $url): ?string
{
$parts = parse_url($url);
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return 'Bitte eine https://-Adresse angeben.';
$host = $parts['host'];
// Tests ersetzen den Abruf (deskhub_http_get) und brauchen keine Namensauflösung; IP-Adressen werden immer geprüft
if (!filter_var($host, FILTER_VALIDATE_IP) && isset($GLOBALS['deskhub_http_get'])) return null;
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return 'Die Adresse zeigt auf ein internes oder nicht auflösbares Ziel.';
}
return null;
}
/**
* Lädt eine Adresse (nur https, keine Weiterleitungen, Größenlimit, Zertifikatsprüfung).
* Tests können $GLOBALS['deskhub_http_get'] = fn(string $url, int $maxBytes): array setzen.
*
* @return array{0:int,1:?string,2:?string} [HTTP-Status, Inhalt, Fehlertext]
*/
function plugin_http_get(string $url, int $maxBytes, int $timeout = 10): array
{
$extraHeaders = catalog_access_headers($url);
if (isset($GLOBALS['deskhub_http_get']) && is_callable($GLOBALS['deskhub_http_get'])) return ($GLOBALS['deskhub_http_get'])($url, $maxBytes, $extraHeaders);
if (!function_exists('curl_init')) return [0, null, 'Die PHP-Erweiterung curl fehlt.'];
$body = '';
$tooBig = false;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_USERAGENT => 'DeskHub-Plugins/1', CURLOPT_HTTPHEADER => array_merge(['Accept: application/json, application/zip, */*'], $extraHeaders),
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$body, &$tooBig, $maxBytes): int {
$body .= $chunk;
if (strlen($body) > $maxBytes) { $tooBig = true; return 0; }
return strlen($chunk);
},
]);
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($tooBig) return [$status, null, 'Die Antwort ist größer als erlaubt.'];
if ($err !== '') return [$status, null, $err];
return [$status, $body, $status >= 200 && $status < 300 ? null : "HTTP {$status}"];
}
/**
* Lädt den Katalog. Erwartetes JSON: {"plugins":[{slug,name,version,description,author,requires,permissions[],downloadUrl,sha256,signature}]}
*
* @return array{ok:bool, error?:string, plugins?:array}
*/
function plugin_catalog_fetch(): array
{
$url = plugin_catalog_url();
if ($url === '') return ['ok' => false, 'error' => 'Es ist keine Katalog-Adresse hinterlegt.'];
if ($err = plugin_url_check($url)) return ['ok' => false, 'error' => $err];
[$status, $body, $error] = plugin_http_get($url, 1048576);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Der Katalog konnte nicht geladen werden: ' . ($error ?? 'unbekannter Fehler')];
$data = json_decode($body, true);
if (!is_array($data) || !isset($data['plugins']) || !is_array($data['plugins'])) return ['ok' => false, 'error' => 'Der Katalog hat ein ungültiges Format.'];
$known = plugin_known_permissions();
$plugins = [];
foreach ($data['plugins'] as $e) {
if (!is_array($e)) continue;
$slug = (string) ($e['slug'] ?? '');
$version = (string) ($e['version'] ?? '');
$sha = strtolower((string) ($e['sha256'] ?? ''));
$dl = (string) ($e['downloadUrl'] ?? '');
$perms = array_values(array_unique(array_map('strval', (array) ($e['permissions'] ?? []))));
if (!plugin_valid_slug($slug) || !preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $version) || !preg_match('/^[a-f0-9]{64}$/', $sha)) continue;
if (!str_starts_with($dl, 'https://') || array_diff($perms, array_keys($known))) continue;
$plugins[] = [
'slug' => $slug, 'name' => mb_substr(trim((string) ($e['name'] ?? $slug)), 0, 120), 'version' => $version,
'description' => mb_substr((string) ($e['description'] ?? ''), 0, 500), 'author' => mb_substr((string) ($e['author'] ?? ''), 0, 120),
'requires' => (string) ($e['requires'] ?? ''), 'permissions' => $perms, 'downloadUrl' => $dl, 'sha256' => $sha,
'signature' => trim((string) ($e['signature'] ?? '')), 'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')), 'featured' => !empty($e['featured']), 'language' => (is_array($e['language'] ?? null) && preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', (string) ($e['language']['code'] ?? ''))) ? ['code' => (string) $e['language']['code'], 'name' => mb_substr((string) ($e['language']['name'] ?? ''), 0, 40)] : null,
];
}
// Empfohlene zuerst, sonst die Reihenfolge des Katalogs
usort($plugins, fn($a, $b) => (int) $b['featured'] <=> (int) $a['featured']); // stabil: sonst bleibt die Reihenfolge des Katalogs
return ['ok' => true, 'plugins' => $plugins];
}
/** Installiert ein Plugin aus dem Katalog: Download, Prüfsumme, Signatur, Installation. Aktiviert wird nicht. */
function plugin_catalog_install(PDO $pdo, string $slug): array
{
$catalog = plugin_catalog_fetch();
if (!$catalog['ok']) return $catalog;
$entry = null;
foreach ($catalog['plugins'] as $e) if ($e['slug'] === $slug) { $entry = $e; break; }
if (!$entry) return ['ok' => false, 'error' => 'Dieses Plugin steht nicht im Katalog.'];
$have = plugins_installed($pdo)[$slug] ?? null;
if ($have && version_compare($entry['version'], $have['version'], '<=')) return ['ok' => false, 'error' => 'Dieses Plugin ist bereits in der aktuellen Version installiert.'];
if ($err = plugin_url_check($entry['downloadUrl'])) return ['ok' => false, 'error' => 'Download-Adresse nicht erlaubt: ' . $err];
if ($entry['requires'] !== '' && defined('DESKHUB_VERSION') && version_compare(DESKHUB_VERSION, $entry['requires'], '<')) {
return ['ok' => false, 'error' => "Benötigt DeskHub {$entry['requires']} oder neuer (installiert: " . DESKHUB_VERSION . ').'];
}
[$status, $body, $error] = plugin_http_get($entry['downloadUrl'], PLUGIN_MAX_PACKAGE_BYTES, 60);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Download fehlgeschlagen: ' . ($error ?? 'unbekannter Fehler')];
$tmp = tempnam(sys_get_temp_dir(), 'dhp_');
file_put_contents($tmp, $body);
try {
return plugin_install_zip($pdo, $tmp, $entry['sha256'], $entry['signature'], 'catalog', ['slug' => $entry['slug'], 'version' => $entry['version']]);
} finally {
@unlink($tmp);
}
}
/** Prüft den Dateinamen im Paket. */
function plugin_package_path_ok(string $rel): bool
{
$rel = str_replace(chr(92), '/', $rel);
if ($rel === '' || $rel[0] === '/' || str_contains($rel, "\0") || preg_match('#(^|/)\.\.(/|$)#', $rel) || preg_match('#^[A-Za-z]:#', $rel)) return false;
foreach (explode('/', $rel) as $part) {
if ($part === '' ) continue;
if ($part[0] === '.' ) return false; // keine versteckten Dateien (.htaccess usw.)
}
$ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION));
return in_array($ext, PLUGIN_ALLOWED_EXTENSIONS, true);
}
/**
* Installiert (oder aktualisiert) ein Plugin aus einer ZIP-Datei. Das Plugin wird NICHT automatisch aktiviert.
*
* Mit Signatur (Base64, RSA-SHA256 über plugin_signature_message) und hinterlegtem Herausgeber-Schlüssel wird die Herkunft
* geprüft; ist "Signatur erforderlich" eingestellt, werden unsignierte Pakete abgelehnt. $expect = ['slug'=>…,'version'=>…]
* lässt das Paket scheitern, wenn sein Inhalt nicht zu dem passt, was der Katalog versprochen hat.
*
* @return array{ok:bool, error?:string, slug?:string, name?:string, version?:string, updated?:bool, hash?:string, signed?:bool, needs_reapproval?:array}
*/
function plugin_install_zip(PDO $pdo, string $zipPath, ?string $expectedSha256 = null, ?string $signature = null, string $source = 'upload', ?array $expect = null): array
{
if (!class_exists('ZipArchive')) return ['ok' => false, 'error' => 'Die PHP-Erweiterung zip fehlt.'];
$size = (int) @filesize($zipPath);
if ($size <= 0 || $size > PLUGIN_MAX_PACKAGE_BYTES) return ['ok' => false, 'error' => 'Das Paket ist leer oder größer als ' . (PLUGIN_MAX_PACKAGE_BYTES / 1048576) . ' MB.'];
$hash = hash_file('sha256', $zipPath);
if ($expectedSha256 !== null && $expectedSha256 !== '' && !hash_equals(strtolower(trim($expectedSha256)), $hash)) {
return ['ok' => false, 'error' => "Die Prüfsumme (SHA-256) stimmt nicht überein. Erhalten: {$hash}"];
}
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) return ['ok' => false, 'error' => 'ZIP-Datei konnte nicht geöffnet werden.'];
// Gemeinsames Wurzelverzeichnis erkennen (plugin.json liegt entweder im Wurzelordner oder in einem Unterordner)
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) $names[] = str_replace(chr(92), '/', (string) $zip->getNameIndex($i));
$prefix = '';
if (!in_array('plugin.json', $names, true)) {
foreach ($names as $n) {
if (preg_match('#^([^/]+/)plugin\.json$#', $n, $m)) { $prefix = $m[1]; break; }
}
}
if ($prefix === '' && !in_array('plugin.json', $names, true)) { $zip->close(); return ['ok' => false, 'error' => 'plugin.json nicht im Paket gefunden.']; }
$files = [];
$total = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $names[$i];
if (substr($name, -1) === '/') continue;
if ($prefix !== '' && strpos($name, $prefix) !== 0) continue;
$rel = substr($name, strlen($prefix));
if (!plugin_package_path_ok($rel)) { $zip->close(); return ['ok' => false, 'error' => "Unzulässige Datei im Paket: {$rel}"]; }
$stat = $zip->statIndex($i);
$total += (int) ($stat['size'] ?? 0);
if ($total > PLUGIN_MAX_PACKAGE_BYTES * 4) { $zip->close(); return ['ok' => false, 'error' => 'Das entpackte Paket ist zu groß.']; }
$files[$i] = $rel;
}
// In ein temporäres Verzeichnis entpacken und dort prüfen, bevor etwas Installiertes verändert wird
$tmp = plugins_dir() . '/.tmp_' . bin2hex(random_bytes(6));
if (!@mkdir($tmp, 0755, true)) { $zip->close(); return ['ok' => false, 'error' => 'Das Plugin-Verzeichnis ist nicht beschreibbar.']; }
try {
foreach ($files as $i => $rel) {
$dest = $tmp . '/' . $rel;
if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0755, true);
file_put_contents($dest, (string) $zip->getFromIndex($i));
}
$zip->close();
[$manifest, $error] = plugin_read_manifest($tmp);
if (!$manifest) return ['ok' => false, 'error' => (string) $error];
$raw = json_decode((string) file_get_contents($tmp . '/plugin.json'), true);
$slug = (string) ($raw['slug'] ?? '');
if (!plugin_valid_slug($slug)) return ['ok' => false, 'error' => 'plugin.json: "slug" fehlt oder ist ungültig (a–z, 0–9, _ und -; 2–41 Zeichen).'];
if (in_array($slug, ['core', 'system', 'admin', 'staff', 'api'], true)) return ['ok' => false, 'error' => 'Dieser Plugin-Name ist reserviert.'];
if ($expect && (($expect['slug'] ?? $slug) !== $slug || ($expect['version'] ?? $manifest['version']) !== $manifest['version'])) {
return ['ok' => false, 'error' => 'Das Paket passt nicht zur Katalog-Angabe (Name oder Version weichen ab).'];
}
// Herkunft prüfen
$signed = false;
$signature = trim((string) $signature);
if ($signature !== '') {
if (plugin_publisher_key() === '') return ['ok' => false, 'error' => 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.'];
if (!plugin_verify_signature(plugin_signature_message($slug, $manifest['version'], $hash), $signature, plugin_publisher_key())) {
return ['ok' => false, 'error' => 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber.'];
}
$signed = true;
} elseif (plugin_signatures_required($source)) {
return ['ok' => false, 'error' => 'Plugins müssen signiert sein (Einstellung „Signatur erforderlich“). Diesem Paket fehlt die Signatur.'];
}
$target = plugins_dir() . '/' . $slug;
$existing = plugins_installed($pdo)[$slug] ?? null;
$backup = null;
if (is_dir($target)) {
$backup = plugins_dir() . '/.old_' . $slug . '_' . bin2hex(random_bytes(3));
if (!@rename($target, $backup)) return ['ok' => false, 'error' => 'Das vorhandene Plugin konnte nicht ersetzt werden.'];
}
if (!@rename($tmp, $target)) {
if ($backup) @rename($backup, $target);
return ['ok' => false, 'error' => 'Das Plugin konnte nicht installiert werden.'];
}
if ($backup) plugin_rmdir($backup);
$now = db_now();
$needsReapproval = [];
if ($existing) {
$pdo->prepare('UPDATE plugins SET name = ?, version = ?, package_hash = ?, updated_at = ?, fail_count = 0, last_error = NULL, signature_ok = ?, source = ? WHERE slug = ?')
->execute([$manifest['name'], $manifest['version'], $hash, $now, $signed ? 1 : 0, $source, $slug]);
// Fordert die neue Version Berechtigungen an, die nie bestätigt wurden, wird ein aktives Plugin sofort angehalten
$needsReapproval = array_values(array_diff($manifest['permissions'], array_filter(explode(',', (string) ($existing['approved_permissions'] ?? '')))));
// Signierte Plugins aus dem Katalog des Herausgebers brauchen keine zusätzliche Bestätigung (wie bei der Aktivierung)
if ($needsReapproval && $source === 'catalog' && $signed) {
if ($existing['is_active']) $pdo->prepare('UPDATE plugins SET approved_permissions = ? WHERE slug = ?')->execute([implode(',', $manifest['permissions']), $slug]);
$needsReapproval = [];
}
if ($needsReapproval && $existing['is_active']) {
$pdo->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute(['Die neue Version fordert zusätzliche Berechtigungen an (' . implode(', ', $needsReapproval) . '). Bitte prüfen und erneut aktivieren.', $slug]);
}
} else {
$pdo->prepare('INSERT INTO plugins (slug, name, version, is_active, package_hash, installed_at, updated_at, signature_ok, source) VALUES (?,?,?,0,?,?,?,?,?)')
->execute([$slug, $manifest['name'], $manifest['version'], $hash, $now, $now, $signed ? 1 : 0, $source]);
}
$tmp = null;
return ['ok' => true, 'slug' => $slug, 'name' => $manifest['name'], 'version' => $manifest['version'], 'updated' => (bool) $existing, 'hash' => $hash, 'signed' => $signed, 'needs_reapproval' => $needsReapproval];
} finally {
if ($tmp !== null && is_dir($tmp)) plugin_rmdir($tmp);
}
}
function plugin_rmdir(string $dir): void
{
if (!is_dir($dir)) return;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $f) {
$f->isDir() && !$f->isLink() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
}
@rmdir($dir);
}
/** Datenbank-Änderungen eines Plugins (plugins/<slug>/migrations/*.sql) einspielen; Protokoll in schema_migrations. */
function plugin_run_migrations(PDO $pdo, string $slug): array
{
$applied = 0;
$errors = [];
$files = glob(plugins_dir() . '/' . $slug . '/migrations/*.sql') ?: [];
natsort($files);
$done = $pdo->query('SELECT migration FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
foreach ($files as $file) {
// Dialekt-spezifische Dateien: *.sqlite.sql / *.mysql.sql laufen nur auf der passenden Datenbank
if (preg_match('/\.(sqlite|mysql)\.sql$/', $file, $dm) && ($dm[1] === 'sqlite') !== ($driver === 'sqlite')) continue;
$key = 'plugin_' . $slug . '_' . basename($file);
if (in_array($key, $done, true)) continue;
$sql = preg_replace('/^\s*--.*$/m', '', (string) file_get_contents($file));
try {
// SQLite kann Tabellen-Änderungen in einer Transaktion zurückrollen. MySQL beendet bei CREATE/ALTER TABLE jede offene
// Transaktion von selbst ("There is no active transaction"), dort läuft die Datei deshalb ohne Transaktion.
$useTransaction = $driver === 'sqlite';
if ($useTransaction) $pdo->beginTransaction();
foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) $pdo->exec($stmt);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute([$key]);
if ($useTransaction && $pdo->inTransaction()) $pdo->commit();
$applied++;
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$errors[] = basename($file) . ': ' . $e->getMessage();
break;
}
}
return ['applied' => $applied, 'errors' => $errors];
}
/**
* Schaltet ein Plugin ein/aus. Beim Aktivieren muss der Admin die angeforderten Berechtigungen bestätigen
* ($approvedPermissions muss alle enthalten); danach werden die Datenbank-Änderungen des Plugins eingespielt.
*/
function plugin_set_active(PDO $pdo, string $slug, bool $active, ?array $approvedPermissions = null): array
{
$row = plugins_installed($pdo)[$slug] ?? null;
if (!$row) return ['ok' => false, 'error' => 'Plugin nicht gefunden.'];
$declared = [];
if ($active) {
[$manifest, $error] = plugin_read_manifest(plugins_dir() . '/' . $slug);
if (!$manifest) return ['ok' => false, 'error' => (string) $error];
$declared = $manifest['permissions'];
if ($declared && ($approvedPermissions === null || array_diff($declared, $approvedPermissions))) {
return ['ok' => false, 'error' => 'Bitte die angeforderten Berechtigungen bestätigen.', 'needs_confirmation' => $declared];
}
$hasMigrations = (bool) glob(plugins_dir() . '/' . $slug . '/migrations/*.sql');
if ($hasMigrations && !in_array('db.migrations', $declared, true)) {
return ['ok' => false, 'error' => 'Das Plugin bringt Datenbank-Änderungen mit, fordert aber die Berechtigung "db.migrations" nicht an.'];
}
$mig = plugin_run_migrations($pdo, $slug);
if ($mig['errors']) return ['ok' => false, 'error' => 'Datenbank-Änderung des Plugins fehlgeschlagen: ' . implode('; ', $mig['errors'])];
}
if ($active) {
$pdo->prepare('UPDATE plugins SET is_active = 1, fail_count = 0, last_error = NULL, approved_permissions = ? WHERE slug = ?')->execute([implode(',', $declared), $slug]);
} else {
$pdo->prepare('UPDATE plugins SET is_active = 0, fail_count = 0, last_error = NULL WHERE slug = ?')->execute([$slug]);
}
return ['ok' => true];
}
/** Entfernt ein Plugin. $removeData löst vorher den Hook "plugin.uninstall.<slug>" aus, damit es seine Daten löschen kann. */
function plugin_uninstall(PDO $pdo, string $slug, bool $removeData = false): array
{
if (!plugin_valid_slug($slug) || !isset(plugins_installed($pdo)[$slug])) return ['ok' => false, 'error' => 'Plugin nicht gefunden.'];
if ($removeData) plugin_hook('plugin.uninstall.' . $slug, ['slug' => $slug]);
$pdo->prepare('DELETE FROM plugins WHERE slug = ?')->execute([$slug]);
$pdo->prepare("DELETE FROM settings WHERE setting_key LIKE ?")->execute(['plugin.' . $slug . '.%']);
plugin_rmdir(plugins_dir() . '/' . $slug);
return ['ok' => true];
}
/** Mini-Vorschau (96x54) für Katalogeinträge: das Bild aus dem Katalog oder ein Platzhalter mit dem Anfangsbuchstaben. */
function catalog_thumb(string $kind, array $c): string
{
$box = 'width:96px;height:54px;border-radius:6px;flex:none;background:var(--bg-soft,#eef2f5);border:1px solid var(--border,#e2e8f0);';
if (!empty($c['preview'])) {
return '<img src="/staff/catalog_preview.php?kind=' . e($kind) . '&amp;slug=' . e($c['slug']) . '" alt="" loading="lazy" width="96" height="54" style="' . $box . 'object-fit:cover">';
}
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $c['name'], 0, 1))) . '</div>';
}
// ── Anmeldung von Kunden über Plugins (Berechtigung auth.customer) ──────────────────────────────────────────────────────────────
/**
* Registriert eine Anmelde-Adresse für Kunden: GET /plugin_login.php?p=<slug>&e=<name> (öffentlich, Kundenportal).
* Der Handler erhält ['query' => $_GET, 'ip' => …] und gibt zurück:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
* ['customer' => ['email' => …, 'name' => …, 'create' => true|false, 'via' => 'Dienst']]
* diese Person ist vom Dienst bestätigt: DeskHub meldet das Kundenkonto mit dieser
* E-Mail-Adresse an; gibt es keines und 'create' ist true, wird es angelegt
* ['error' => 'Text'] zurück zur Kunden-Anmeldeseite mit dieser Meldung
* Die Rückkehr vom Dienst darf auch über eine mit plugin_register_login() angemeldete Adresse kommen, wenn das Plugin beide Berechtigungen hat.
* Das Plugin trägt die Verantwortung, dass die E-Mail-Adresse wirklich bestätigt ist.
*/
function plugin_register_customer_login(string $endpoint, callable $handler): void
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
if (!plugin_permission_allowed('auth.customer')) return;
$state['customer_logins'][$slug][$endpoint] = $handler;
}
/** Adresse (ohne Domain) einer mit plugin_register_customer_login() angemeldeten Kunden-Anmelde-Adresse. */
function plugin_customer_login_url(string $slug, string $endpoint): string
{
return '/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
}
/** Gilt die Weiterleitung zu einem Anmeldedienst? Nur https (oder http zu localhost zum Testen), keine Zeichen < Leerzeichen. */
function plugin_login_external_url_ok(string $url): bool
{
if ($url === '' || strlen($url) > 4000 || preg_match('/[\x00-\x20]/', $url)) return false;
$p = parse_url($url);
if (!$p || empty($p['host'])) return false;
$scheme = $p['scheme'] ?? '';
return $scheme === 'https' || ($scheme === 'http' && in_array($p['host'], ['localhost', '127.0.0.1', '[::1]'], true));
}
/**
* Meldet einen vom Plugin bestätigten Kunden an (und legt das Konto an, wenn erlaubt). Endet immer mit einer Weiterleitung.
* Ein Plugin ohne Berechtigung auth.customer kommt hier nicht durch.
* @param array{email?:string,name?:string,create?:bool,via?:string} $c
*/
function plugin_customer_login_finish(string $slug, array $c): never
{
$fail = function (string $message): never {
flash('error', $message);
redirect('/login.php');
};
if (!in_array('auth.customer', plugin_state()['perms'][$slug] ?? [], true)) $fail('Diese Anmeldung ist nicht erlaubt.');
$email = strtolower(trim((string) ($c['email'] ?? '')));
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 150) $fail('Die E-Mail-Adresse ist ungültig.');
if (is_banned($email)) $fail('Mit dieser E-Mail-Adresse ist keine Anmeldung möglich.');
$pdo = get_db();
$st = $pdo->prepare('SELECT * FROM users WHERE LOWER(email) = ?');
$st->execute([$email]);
$user = $st->fetch();
$created = false;
$via = mb_substr((string) ($c['via'] ?? $slug), 0, 60);
if (!$user) {
if (empty($c['create'])) $fail('Für diese E-Mail-Adresse gibt es kein Kundenkonto.');
$name = trim((string) preg_replace('/\s+/u', ' ', strip_tags((string) ($c['name'] ?? ''))));
if ($name === '') $name = ucfirst(explode('@', $email)[0]);
$name = mb_substr($name, 0, 120);
// Ein Passwort gibt es nicht: Wer eines möchte, nutzt „Passwort vergessen“
$pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)')
->execute([$name, $email, password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), db_now()]);
$user = ['id' => (int) $pdo->lastInsertId(), 'name' => $name, 'email' => $email];
audit_log(null, 'Kundenkonto über ' . $via . ' angelegt: ' . mb_substr($email, 0, 120));
$created = true;
}
customer_login($user);
if ($created) flash('success', 'Willkommen! Ihr Konto wurde erstellt.');
redirect('/home.php');
}