Upload via GUI (335 Dateien)
This commit is contained in:
@@ -32,6 +32,7 @@ function plugin_known_permissions(): array
|
||||
'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',
|
||||
];
|
||||
}
|
||||
@@ -39,7 +40,7 @@ function plugin_known_permissions(): array
|
||||
/** 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];
|
||||
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
|
||||
return $state;
|
||||
}
|
||||
|
||||
@@ -118,14 +119,14 @@ function plugin_permission_allowed(?string $permission): bool
|
||||
function plugin_hook_permission(string $hook): ?string
|
||||
{
|
||||
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
|
||||
if ($hook === 'cron.run') return 'hooks.cron';
|
||||
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') return 'ui.sidebar';
|
||||
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;
|
||||
@@ -158,13 +159,29 @@ function plugin_register_menu(string $label, string $page, ?string $permission =
|
||||
$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];
|
||||
$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;
|
||||
@@ -216,10 +233,21 @@ function plugin_record_failure(string $slug, string $message): void
|
||||
$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) {
|
||||
$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.
|
||||
}
|
||||
@@ -261,7 +289,7 @@ function plugins_boot(): void
|
||||
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();
|
||||
$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)
|
||||
}
|
||||
@@ -298,6 +326,7 @@ function plugins_boot(): void
|
||||
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;
|
||||
@@ -305,6 +334,38 @@ function plugins_boot(): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
@@ -317,6 +378,22 @@ function plugin_menu_items(?array $staff): array
|
||||
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
|
||||
{
|
||||
@@ -467,31 +544,38 @@ function plugin_catalog_url(): string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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}
|
||||
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int, stale?:bool}
|
||||
*/
|
||||
function plugin_catalog_cached(bool $refresh = false): array
|
||||
function plugin_catalog_cached(bool $refresh = false, int $maxAge = 300): 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']];
|
||||
}
|
||||
$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 alle 30 Minuten aufgerufen), damit die Seiten sofort
|
||||
* 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
|
||||
@@ -642,9 +726,11 @@ function plugin_catalog_fetch(): array
|
||||
'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'] ?? '')),
|
||||
'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];
|
||||
}
|
||||
|
||||
@@ -784,6 +870,11 @@ function plugin_install_zip(PDO $pdo, string $zipPath, ?string $expectedSha256 =
|
||||
->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]);
|
||||
|
||||
Reference in New Issue
Block a user