895 lines
48 KiB
PHP
895 lines
48 KiB
PHP
<?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';
|
||
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 && 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.
|
||
*/
|
||
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;
|
||
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;
|
||
}
|
||
|
||
/** 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 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 {
|
||
// 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) . '&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>';
|
||
}
|