/ 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//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//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=&endpoint= (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=&e=. 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=&e=. 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' => ] 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 ':'. */ 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 '' . htmlspecialchars($label) . ''; }; $html = '
' . $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 . '
'; } // ─── 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 */ /** * Installiert alle Plugin-Updates aus dem Katalog (jedes einzeln, mit Prüfsumme und Signatur; ein Fehler stoppt die anderen nicht). * @return array{updated:list,failed:array} 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//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." 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 ''; } return ''; } // ── Anmeldung von Kunden über Plugins (Berechtigung auth.customer) ────────────────────────────────────────────────────────────── /** * Registriert eine Anmelde-Adresse für Kunden: GET /plugin_login.php?p=&e= (ö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'); }