Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:18 +00:00
parent 56deade877
commit 6cced3beda
50 changed files with 11148 additions and 0 deletions
+852
View File
@@ -0,0 +1,852 @@
<?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',
'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' => [], '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') return 'hooks.cron';
return null;
}
/** Erforderliche Berechtigung für einen Filter. */
function plugin_filter_permission(string $hook): ?string
{
if ($hook === 'ticket.view.sidebar') return 'ui.sidebar';
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
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). */
function plugin_register_menu(string $label, string $page, ?string $permission = 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];
}
/**
* 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.
*/
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();
if ($fails >= PLUGIN_FAIL_LIMIT) {
$pdo->prepare('UPDATE plugins SET is_active = 0 WHERE slug = ?')->execute([$slug]);
error_log("plugin[{$slug}]: nach {$fails} Fehlern automatisch deaktiviert");
}
} 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 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'];
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;
});
}
}
/** 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;
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
// ─── 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 Zwischenspeicher (1 Stunde), damit die Plugin-Seite nicht bei jedem Aufruf den Server abfragen muss.
* $refresh = true erzwingt einen neuen Abruf. Fehler werden nicht zwischengespeichert.
*
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int}
*/
function plugin_catalog_cached(bool $refresh = false): array
{
$url = plugin_catalog_url();
$key = hash('sha256', $url);
if (!$refresh) {
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
if (is_array($cached) && ($cached['key'] ?? '') === $key && time() - (int) ($cached['at'] ?? 0) < 3600) {
return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at']];
}
}
$res = plugin_catalog_fetch();
if ($res['ok']) {
set_setting('plugin_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'plugins' => $res['plugins']], JSON_UNESCAPED_UNICODE));
$res['cached_at'] = time();
}
return $res;
}
/**
* Hält die Kataloge im Hintergrund frisch (wird von der Automatisierung alle 30 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'] ?? '')),
];
}
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'] ?? '')))));
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 {
$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]);
$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>';
}