Files
deskhub/DeskHub-Webseite/includes/plugins.php
T
2026-10-24 20:14:54 +00:00

986 lines
54 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',
];
}
/** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */
function &plugin_state(): array
{
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], '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 === '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;
}
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}>
*/
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>';
}