diff --git a/plugin-webseite/inc/lib.php b/plugin-webseite/inc/lib.php
index 5f78c333..deff580d 100644
--- a/plugin-webseite/inc/lib.php
+++ b/plugin-webseite/inc/lib.php
@@ -1,395 +1,406 @@
- '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',
- ];
-}
-
-/**
- * Konfiguration aus config.php; Umgebungsvariablen (nur für Tests) überschreiben einzelne Werte.
- * Ist in der config.php 'locked' => true gesetzt (Docker-Image mit fest eingebauten Zugangsdaten), gilt ausschließlich diese
- * Datei: Weder Umgebungsvariablen noch eine andere Konfigurationsdatei können Zugang, Schlüssel oder Speicherort ändern.
- */
-function site_config(): array
-{
- static $cfg = null;
- if ($cfg === null) {
- $default = dirname(__DIR__) . '/config.php';
- $baked = is_file($default) ? (array) require $default : [];
- $locked = !empty($baked['locked']);
- $file = $locked ? $default : (getenv('PLUGIN_SITE_CONFIG') ?: $default);
- $cfg = is_file($file) ? (array) require $file : [];
- $cfg += ['site_name' => 'DeskHub Plugins und Themes', 'base_url' => '', 'admin_user' => 'admin', 'admin_password_hash' => '', 'signing_key_file' => '', 'storage_dir' => dirname(__DIR__) . '/storage', 'access_key' => '', 'locked' => false];
- if (!$locked) {
- foreach (['PLUGIN_SITE_STORAGE' => 'storage_dir', 'PLUGIN_SITE_BASE_URL' => 'base_url', 'PLUGIN_SITE_ADMIN_HASH' => 'admin_password_hash', 'PLUGIN_SITE_SIGNING_KEY' => 'signing_key_file', 'PLUGIN_SITE_ACCESS_KEY' => 'access_key'] as $env => $key) {
- if (getenv($env) !== false && getenv($env) !== '') $cfg[$key] = getenv($env);
- }
- }
- }
- return $cfg;
-}
-
-/**
- * Zugangsschutz für Katalog, Downloads, Vorschaubilder und den öffentlichen Schlüssel: DeskHub schickt den Schlüssel bei jedem
- * Abruf im Header "X-DeskHub-Key" mit. Ohne konfigurierten Schlüssel (access_key leer) ist alles offen (nur für Entwicklung).
- */
-function site_access_ok(): bool
-{
- $key = (string) site_config()['access_key'];
- if ($key === '') return true;
- return hash_equals($key, (string) ($_SERVER['HTTP_X_DESKHUB_KEY'] ?? ''));
-}
-
-/** Bricht mit 401 ab, wenn der Zugangsschlüssel fehlt oder falsch ist. */
-function site_require_access(): void
-{
- if (site_access_ok()) return;
- usleep(300000); // Raten bremsen
- http_response_code(401);
- header('Content-Type: text/plain; charset=utf-8');
- header('WWW-Authenticate: X-DeskHub-Key');
- exit('Zugriff nur mit gültigem Zugangsschlüssel.');
-}
-
-function site_storage(): string
-{
- $dir = rtrim((string) site_config()['storage_dir'], '/' . chr(92));
- foreach ([$dir, $dir . '/packages', $dir . '/previews'] as $d) if (!is_dir($d)) @mkdir($d, 0755, true);
- if (!is_file($dir . '/.htaccess')) @file_put_contents($dir . '/.htaccess', "Require all denied\n\nDeny from all\n\n");
- return $dir;
-}
-
-function site_db(): PDO
-{
- static $pdo = null;
- if ($pdo !== null) return $pdo;
- $pdo = new PDO('sqlite:' . site_storage() . '/katalog.sqlite');
- $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
- $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
- $pdo->exec('PRAGMA foreign_keys = ON');
- $pdo->exec("CREATE TABLE IF NOT EXISTS items (
- id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
- author TEXT NOT NULL DEFAULT '', preview TEXT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (type, slug))");
- $pdo->exec("CREATE TABLE IF NOT EXISTS versions (
- id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, version TEXT NOT NULL, file TEXT NOT NULL, sha256 TEXT NOT NULL,
- signature TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL, requires TEXT NOT NULL DEFAULT '', permissions TEXT NOT NULL DEFAULT '',
- downloads INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (item_id, version),
- FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE)");
- return $pdo;
-}
-
-function site_h(?string $s): string
-{
- return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
-}
-
-function site_valid_slug(string $slug): bool
-{
- return (bool) preg_match('/^[a-z0-9][a-z0-9_-]{1,40}$/', $slug);
-}
-
-function site_valid_version(string $v): bool
-{
- return (bool) preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $v);
-}
-
-function site_format_size(int $bytes): string
-{
- return $bytes >= 1048576 ? number_format($bytes / 1048576, 1, ',', '.') . ' MB' : number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
-}
-
-/** Absolute Adresse der Webseite (für Download-Links im Katalog). */
-function site_base_url(): string
-{
- $base = rtrim((string) site_config()['base_url'], '/');
- if ($base !== '') return $base;
- $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
- return ($https ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/' . chr(92));
-}
-
-// ─── Paketprüfung ────────────────────────────────────────────────────────────
-
-function site_path_ok(string $rel, array $allowedExtensions): 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 !== '' && $part[0] === '.') return false;
- return in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), $allowedExtensions, true);
-}
-
-/**
- * Prüft ein Paket und liest seine Angaben.
- *
- * @return array{ok:bool, error?:string, meta?:array}
- */
-function site_inspect_package(string $type, string $zipPath): array
-{
- if (!class_exists('ZipArchive')) return ['ok' => false, 'error' => 'Die PHP-Erweiterung zip fehlt.'];
- $size = (int) @filesize($zipPath);
- if ($size < 100 || $size > SITE_MAX_PACKAGE_BYTES) return ['ok' => false, 'error' => 'Das Paket ist leer oder größer als ' . (SITE_MAX_PACKAGE_BYTES / 1048576) . ' MB.'];
- $zip = new ZipArchive();
- if ($zip->open($zipPath) !== true) return ['ok' => false, 'error' => 'Die ZIP-Datei konnte nicht geöffnet werden.'];
- $names = [];
- for ($i = 0; $i < $zip->numFiles; $i++) $names[$i] = str_replace(chr(92), '/', (string) $zip->getNameIndex($i));
- $marker = $type === 'plugin' ? 'plugin.json' : 'theme.css';
- $prefix = '';
- if (!in_array($marker, $names, true)) {
- foreach ($names as $n) if (preg_match('#^([^/]+/)' . preg_quote($marker, '#') . '$#', $n, $m)) { $prefix = $m[1]; break; }
- if ($prefix === '') { $zip->close(); return ['ok' => false, 'error' => $marker . ' wurde im Paket nicht gefunden.']; }
- }
- $extensions = $type === 'plugin' ? SITE_PLUGIN_EXTENSIONS : SITE_THEME_EXTENSIONS;
- $total = 0;
- $count = 0;
- foreach ($names as $i => $name) {
- if (substr($name, -1) === '/') continue;
- if ($prefix !== '' && strpos($name, $prefix) !== 0) { $zip->close(); return ['ok' => false, 'error' => 'Alle Dateien müssen im selben Ordner liegen.']; }
- $rel = substr($name, strlen($prefix));
- if (!site_path_ok($rel, $extensions)) { $zip->close(); return ['ok' => false, 'error' => 'Unzulässige Datei im Paket: ' . $rel]; }
- $total += (int) ($zip->statIndex($i)['size'] ?? 0);
- if (++$count > 200 || $total > SITE_MAX_PACKAGE_BYTES * 4) { $zip->close(); return ['ok' => false, 'error' => 'Das Paket enthält zu viele oder zu große Dateien.']; }
- }
- $main = (string) $zip->getFromName($prefix . $marker);
- $zip->close();
-
- if ($type === 'plugin') {
- $data = json_decode($main, true);
- if (!is_array($data)) return ['ok' => false, 'error' => 'plugin.json ist kein gültiges JSON.'];
- $slug = (string) ($data['slug'] ?? '');
- $name = trim((string) ($data['name'] ?? ''));
- $version = (string) ($data['version'] ?? '');
- if (!site_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 ($name === '') return ['ok' => false, 'error' => 'plugin.json: "name" fehlt.'];
- if (!site_valid_version($version)) return ['ok' => false, 'error' => 'plugin.json: ungültige Versionsnummer.'];
- $perms = array_values(array_unique(array_map('strval', (array) ($data['permissions'] ?? []))));
- foreach ($perms as $p) if (!isset(site_known_permissions()[$p])) return ['ok' => false, 'error' => "plugin.json: unbekannte Berechtigung \"{$p}\"."];
- sort($perms);
- return ['ok' => true, 'meta' => ['slug' => $slug, 'name' => mb_substr($name, 0, 120), 'version' => $version, 'description' => mb_substr((string) ($data['description'] ?? ''), 0, 1000),
- 'author' => mb_substr((string) ($data['author'] ?? ''), 0, 120), 'requires' => (string) ($data['requires'] ?? ''), 'permissions' => $perms]];
- }
- // Theme: Angaben stehen im Kopf der theme.css (Author:, Version:)
- $head = substr($main, 0, 2000);
- preg_match('/Version:\s*([0-9][0-9A-Za-z.\-+]*)/i', $head, $v);
- preg_match('/Author:\s*([^\r\n*]+)/i', $head, $a);
- return ['ok' => true, 'meta' => ['slug' => '', 'name' => '', 'version' => $v[1] ?? '', 'description' => '', 'author' => trim($a[1] ?? ''), 'requires' => '', 'permissions' => []]];
-}
-
-// ─── Signieren ───────────────────────────────────────────────────────────────
-
-/** Exakt die Nachricht, die DeskHub prüft (siehe plugin_signature_message). Themes werden mit "theme-" getrennt. */
-function site_signature_message(string $type, string $slug, string $version, string $sha256): string
-{
- return "deskhub-plugin\n" . ($type === 'theme' ? 'theme-' : '') . "{$slug}\n{$version}\n" . strtolower($sha256);
-}
-
-function site_signing_key(): mixed
-{
- $file = (string) site_config()['signing_key_file'];
- if ($file === '' || !is_file($file)) return null;
- return openssl_pkey_get_private((string) file_get_contents($file)) ?: null;
-}
-
-function site_public_key(): string
-{
- $key = site_signing_key();
- return $key ? (string) (openssl_pkey_get_details($key)['key'] ?? '') : '';
-}
-
-function site_sign(string $type, string $slug, string $version, string $sha256): string
-{
- $key = site_signing_key();
- if (!$key) return '';
- openssl_sign(site_signature_message($type, $slug, $version, $sha256), $sig, $key, OPENSSL_ALGO_SHA256);
- return base64_encode($sig);
-}
-
-// ─── Veröffentlichen ─────────────────────────────────────────────────────────
-
-/**
- * Veröffentlicht eine Version. Für Themes sind $form['slug'], ['name'], ['version'] Pflicht (aus dem Formular).
- *
- * @return array{ok:bool, error?:string, slug?:string, version?:string, signed?:bool}
- */
-function site_publish(string $type, string $zipPath, array $form = [], ?string $previewPath = null, ?string $previewName = null): array
-{
- if (!in_array($type, ['plugin', 'theme'], true)) return ['ok' => false, 'error' => 'Unbekannter Typ.'];
- $res = site_inspect_package($type, $zipPath);
- if (!$res['ok']) return $res;
- $meta = $res['meta'];
- if ($type === 'theme') {
- $meta['slug'] = trim((string) ($form['slug'] ?? ''));
- $meta['name'] = trim((string) ($form['name'] ?? ''));
- $meta['version'] = trim((string) ($form['version'] ?? '')) ?: $meta['version'];
- $meta['description'] = mb_substr(trim((string) ($form['description'] ?? '')), 0, 1000);
- if (!empty($form['author'])) $meta['author'] = mb_substr(trim((string) $form['author']), 0, 120);
- if (!site_valid_slug($meta['slug'])) return ['ok' => false, 'error' => 'Bitte eine Kennung (Slug) angeben: a–z, 0–9, _ und -, 2–41 Zeichen.'];
- if ($meta['name'] === '') return ['ok' => false, 'error' => 'Bitte einen Namen angeben.'];
- if (!site_valid_version($meta['version'])) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 1.0.0 angeben (oder im Kopf der theme.css mit "Version:" eintragen).'];
- }
- $pdo = site_db();
- $sha = hash_file('sha256', $zipPath);
- $now = date('Y-m-d H:i:s');
-
- $item = $pdo->prepare('SELECT * FROM items WHERE type = ? AND slug = ?');
- $item->execute([$type, $meta['slug']]);
- $item = $item->fetch();
- if ($item) {
- $dup = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ? AND version = ?');
- $dup->execute([$item['id'], $meta['version']]);
- if ((int) $dup->fetchColumn() > 0) return ['ok' => false, 'error' => "Version {$meta['version']} existiert bereits. Bitte eine neue Versionsnummer verwenden, veröffentlichte Versionen bleiben unverändert."];
- }
-
- $preview = null;
- if ($previewPath !== null && is_file($previewPath)) {
- $ext = strtolower(pathinfo((string) $previewName, PATHINFO_EXTENSION));
- $info = @getimagesize($previewPath);
- if (!in_array($ext, ['png', 'jpg', 'jpeg', 'webp'], true) || !$info || filesize($previewPath) > SITE_MAX_PREVIEW_BYTES) {
- return ['ok' => false, 'error' => 'Das Vorschaubild muss PNG, JPG oder WebP sein und darf höchstens 1 MB groß sein.'];
- }
- $preview = $type . '-' . $meta['slug'] . '-' . substr($sha, 0, 8) . '.' . ($ext === 'jpeg' ? 'jpg' : $ext);
- }
-
- $file = $type . '/' . $meta['slug'] . '-' . $meta['version'] . '.zip';
- $dest = site_storage() . '/packages/' . $file;
- if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0755, true);
- if (!(is_uploaded_file($zipPath) ? move_uploaded_file($zipPath, $dest) : copy($zipPath, $dest))) return ['ok' => false, 'error' => 'Das Paket konnte nicht gespeichert werden.'];
- if ($preview !== null) (is_uploaded_file($previewPath) ? move_uploaded_file($previewPath, site_storage() . '/previews/' . $preview) : copy($previewPath, site_storage() . '/previews/' . $preview));
-
- $signature = site_sign($type, $meta['slug'], $meta['version'], $sha);
- $pdo->beginTransaction();
- try {
- if ($item) {
- $pdo->prepare('UPDATE items SET name = ?, description = ?, author = ?, preview = COALESCE(?, preview), updated_at = ? WHERE id = ?')
- ->execute([$meta['name'], $meta['description'], $meta['author'], $preview, $now, $item['id']]);
- $itemId = (int) $item['id'];
- } else {
- $pdo->prepare('INSERT INTO items (type, slug, name, description, author, preview, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)')
- ->execute([$type, $meta['slug'], $meta['name'], $meta['description'], $meta['author'], $preview, $now, $now]);
- $itemId = (int) $pdo->lastInsertId();
- }
- $pdo->prepare('INSERT INTO versions (item_id, version, file, sha256, signature, size, requires, permissions, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
- ->execute([$itemId, $meta['version'], $file, $sha, $signature, (int) filesize($dest), $meta['requires'], implode(',', $meta['permissions']), $now]);
- $pdo->commit();
- } catch (Throwable $e) {
- $pdo->rollBack();
- @unlink($dest);
- return ['ok' => false, 'error' => 'Speichern fehlgeschlagen: ' . $e->getMessage()];
- }
- return ['ok' => true, 'slug' => $meta['slug'], 'version' => $meta['version'], 'signed' => $signature !== '', 'sha256' => $sha];
-}
-
-function site_delete_version(int $versionId): bool
-{
- $pdo = site_db();
- $v = $pdo->prepare('SELECT * FROM versions WHERE id = ?');
- $v->execute([$versionId]);
- $v = $v->fetch();
- if (!$v) return false;
- @unlink(site_storage() . '/packages/' . $v['file']);
- $pdo->prepare('DELETE FROM versions WHERE id = ?')->execute([$versionId]);
- $left = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ?');
- $left->execute([$v['item_id']]);
- if ((int) $left->fetchColumn() === 0) {
- $item = $pdo->prepare('SELECT preview FROM items WHERE id = ?');
- $item->execute([$v['item_id']]);
- if ($p = $item->fetchColumn()) @unlink(site_storage() . '/previews/' . $p);
- $pdo->prepare('DELETE FROM items WHERE id = ?')->execute([$v['item_id']]);
- }
- return true;
-}
-
-// ─── Abfragen ────────────────────────────────────────────────────────────────
-
-/** Alle Einträge eines Typs mit ihrer neuesten Version. */
-function site_items(string $type, string $search = ''): array
-{
- $pdo = site_db();
- $stmt = $pdo->prepare('SELECT * FROM items WHERE type = ? ORDER BY name COLLATE NOCASE');
- $stmt->execute([$type]);
- $out = [];
- foreach ($stmt->fetchAll() as $item) {
- if ($search !== '' && stripos($item['name'] . ' ' . $item['description'] . ' ' . $item['author'] . ' ' . $item['slug'], $search) === false) continue;
- $versions = site_versions((int) $item['id']);
- if (!$versions) continue;
- $item['latest'] = $versions[0];
- $item['downloads'] = array_sum(array_column($versions, 'downloads'));
- $out[] = $item;
- }
- return $out;
-}
-
-/** Versionen eines Eintrags, neueste zuerst. */
-function site_versions(int $itemId): array
-{
- $stmt = site_db()->prepare('SELECT * FROM versions WHERE item_id = ?');
- $stmt->execute([$itemId]);
- $rows = $stmt->fetchAll();
- usort($rows, fn($a, $b) => version_compare($b['version'], $a['version']));
- return $rows;
-}
-
-function site_item(string $type, string $slug): ?array
-{
- $stmt = site_db()->prepare('SELECT * FROM items WHERE type = ? AND slug = ?');
- $stmt->execute([$type, $slug]);
- $item = $stmt->fetch();
- if (!$item) return null;
- $item['versions'] = site_versions((int) $item['id']);
- return $item['versions'] ? $item : null;
-}
-
-/** Katalog im Format, das DeskHub liest (neueste Version je Eintrag). */
-function site_catalog(string $type): array
-{
- $base = site_base_url();
- $list = [];
- foreach (site_items($type) as $item) {
- $v = $item['latest'];
- $entry = [
- 'slug' => $item['slug'], 'name' => $item['name'], 'version' => $v['version'], 'description' => $item['description'], 'author' => $item['author'],
- 'downloadUrl' => $base . '/download.php?type=' . $type . '&slug=' . rawurlencode($item['slug']) . '&version=' . rawurlencode($v['version']),
- 'sha256' => $v['sha256'], 'signature' => $v['signature'],
- ];
- if ($item['preview']) $entry['previewUrl'] = $base . '/preview.php?file=' . rawurlencode($item['preview']);
- if ($type === 'plugin') {
- $entry['requires'] = $v['requires'];
- $entry['permissions'] = array_values(array_filter(explode(',', (string) $v['permissions'])));
- }
- $list[] = $entry;
- }
- return [$type === 'plugin' ? 'plugins' : 'themes' => $list];
-}
-
-/** Inhalt der Datei includes/catalog_defaults.php für DeskHub-Releases (offizieller Katalog + öffentlicher Schlüssel). */
-function site_deskhub_defaults_snippet(): string
-{
- $base = site_base_url();
- return " " . var_export($base . '/catalog.php?type=plugins', true) . ",
- 'themes_url' => " . var_export($base . '/catalog.php?type=themes', true)
- . ",
- 'publisher_key' => " . var_export(site_public_key(), true) . ",
- 'access_key' => " . var_export((string) site_config()['access_key'], true) . ",
-];
-";
-}
+ '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',
+ ];
+}
+
+/**
+ * Konfiguration aus config.php; Umgebungsvariablen (nur für Tests) überschreiben einzelne Werte.
+ * Ist in der config.php 'locked' => true gesetzt (Docker-Image mit fest eingebauten Zugangsdaten), gilt ausschließlich diese
+ * Datei: Weder Umgebungsvariablen noch eine andere Konfigurationsdatei können Zugang, Schlüssel oder Speicherort ändern.
+ */
+function site_config(): array
+{
+ static $cfg = null;
+ if ($cfg === null) {
+ $default = dirname(__DIR__) . '/config.php';
+ $baked = is_file($default) ? (array) require $default : [];
+ $locked = !empty($baked['locked']);
+ $file = $locked ? $default : (getenv('PLUGIN_SITE_CONFIG') ?: $default);
+ $cfg = is_file($file) ? (array) require $file : [];
+ $cfg += ['site_name' => 'DeskHub Plugins und Themes', 'base_url' => '', 'admin_user' => 'admin', 'admin_password_hash' => '', 'signing_key_file' => '', 'storage_dir' => dirname(__DIR__) . '/storage', 'access_key' => '', 'locked' => false];
+ if (!$locked) {
+ foreach (['PLUGIN_SITE_STORAGE' => 'storage_dir', 'PLUGIN_SITE_BASE_URL' => 'base_url', 'PLUGIN_SITE_ADMIN_HASH' => 'admin_password_hash', 'PLUGIN_SITE_SIGNING_KEY' => 'signing_key_file', 'PLUGIN_SITE_ACCESS_KEY' => 'access_key'] as $env => $key) {
+ if (getenv($env) !== false && getenv($env) !== '') $cfg[$key] = getenv($env);
+ }
+ }
+ }
+ return $cfg;
+}
+
+/**
+ * Zugangsschutz für Katalog, Downloads, Vorschaubilder und den öffentlichen Schlüssel: DeskHub schickt den Schlüssel bei jedem
+ * Abruf im Header "X-DeskHub-Key" mit. Ohne konfigurierten Schlüssel (access_key leer) ist alles offen (nur für Entwicklung).
+ */
+function site_access_ok(): bool
+{
+ $key = (string) site_config()['access_key'];
+ if ($key === '') return true;
+ return hash_equals($key, (string) ($_SERVER['HTTP_X_DESKHUB_KEY'] ?? ''));
+}
+
+/** Bricht mit 401 ab, wenn der Zugangsschlüssel fehlt oder falsch ist. */
+function site_require_access(): void
+{
+ if (site_access_ok()) return;
+ usleep(300000); // Raten bremsen
+ http_response_code(401);
+ header('Content-Type: text/plain; charset=utf-8');
+ header('WWW-Authenticate: X-DeskHub-Key');
+ exit('Zugriff nur mit gültigem Zugangsschlüssel.');
+}
+
+function site_storage(): string
+{
+ $dir = rtrim((string) site_config()['storage_dir'], '/' . chr(92));
+ foreach ([$dir, $dir . '/packages', $dir . '/previews'] as $d) if (!is_dir($d)) @mkdir($d, 0755, true);
+ if (!is_file($dir . '/.htaccess')) @file_put_contents($dir . '/.htaccess', "Require all denied\n\nDeny from all\n\n");
+ return $dir;
+}
+
+function site_db(): PDO
+{
+ static $pdo = null;
+ if ($pdo !== null) return $pdo;
+ $pdo = new PDO('sqlite:' . site_storage() . '/katalog.sqlite');
+ $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ $pdo->exec('PRAGMA foreign_keys = ON');
+ $pdo->exec("CREATE TABLE IF NOT EXISTS items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
+ author TEXT NOT NULL DEFAULT '', preview TEXT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (type, slug))");
+ $pdo->exec("CREATE TABLE IF NOT EXISTS versions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, version TEXT NOT NULL, file TEXT NOT NULL, sha256 TEXT NOT NULL,
+ signature TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL, requires TEXT NOT NULL DEFAULT '', permissions TEXT NOT NULL DEFAULT '',
+ downloads INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (item_id, version),
+ FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE)");
+ return $pdo;
+}
+
+function site_h(?string $s): string
+{
+ return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}
+
+function site_valid_slug(string $slug): bool
+{
+ return (bool) preg_match('/^[a-z0-9][a-z0-9_-]{1,40}$/', $slug);
+}
+
+function site_valid_version(string $v): bool
+{
+ return (bool) preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $v);
+}
+
+function site_format_size(int $bytes): string
+{
+ return $bytes >= 1048576 ? number_format($bytes / 1048576, 1, ',', '.') . ' MB' : number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
+}
+
+/** Absolute Adresse der Webseite (für Download-Links im Katalog). */
+function site_base_url(): string
+{
+ $base = rtrim((string) site_config()['base_url'], '/');
+ if ($base !== '') return $base;
+ $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
+ return ($https ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/' . chr(92));
+}
+
+// ─── Paketprüfung ────────────────────────────────────────────────────────────
+
+function site_path_ok(string $rel, array $allowedExtensions): 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 !== '' && $part[0] === '.') return false;
+ return in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), $allowedExtensions, true);
+}
+
+/**
+ * Prüft ein Paket und liest seine Angaben.
+ *
+ * @return array{ok:bool, error?:string, meta?:array}
+ */
+function site_inspect_package(string $type, string $zipPath): array
+{
+ if (!class_exists('ZipArchive')) return ['ok' => false, 'error' => 'Die PHP-Erweiterung zip fehlt.'];
+ $size = (int) @filesize($zipPath);
+ if ($size < 100 || $size > SITE_MAX_PACKAGE_BYTES) return ['ok' => false, 'error' => 'Das Paket ist leer oder größer als ' . (SITE_MAX_PACKAGE_BYTES / 1048576) . ' MB.'];
+ $zip = new ZipArchive();
+ if ($zip->open($zipPath) !== true) return ['ok' => false, 'error' => 'Die ZIP-Datei konnte nicht geöffnet werden.'];
+ $names = [];
+ for ($i = 0; $i < $zip->numFiles; $i++) $names[$i] = str_replace(chr(92), '/', (string) $zip->getNameIndex($i));
+ $marker = $type === 'plugin' ? 'plugin.json' : 'theme.css';
+ $prefix = '';
+ if (!in_array($marker, $names, true)) {
+ foreach ($names as $n) if (preg_match('#^([^/]+/)' . preg_quote($marker, '#') . '$#', $n, $m)) { $prefix = $m[1]; break; }
+ if ($prefix === '') { $zip->close(); return ['ok' => false, 'error' => $marker . ' wurde im Paket nicht gefunden.']; }
+ }
+ $extensions = $type === 'plugin' ? SITE_PLUGIN_EXTENSIONS : SITE_THEME_EXTENSIONS;
+ $total = 0;
+ $count = 0;
+ foreach ($names as $i => $name) {
+ if (substr($name, -1) === '/') continue;
+ if ($prefix !== '' && strpos($name, $prefix) !== 0) { $zip->close(); return ['ok' => false, 'error' => 'Alle Dateien müssen im selben Ordner liegen.']; }
+ $rel = substr($name, strlen($prefix));
+ if (!site_path_ok($rel, $extensions)) { $zip->close(); return ['ok' => false, 'error' => 'Unzulässige Datei im Paket: ' . $rel]; }
+ $total += (int) ($zip->statIndex($i)['size'] ?? 0);
+ if (++$count > 200 || $total > SITE_MAX_PACKAGE_BYTES * 4) { $zip->close(); return ['ok' => false, 'error' => 'Das Paket enthält zu viele oder zu große Dateien.']; }
+ }
+ $main = (string) $zip->getFromName($prefix . $marker);
+ $zip->close();
+
+ if ($type === 'plugin') {
+ $data = json_decode($main, true);
+ if (!is_array($data)) return ['ok' => false, 'error' => 'plugin.json ist kein gültiges JSON.'];
+ $slug = (string) ($data['slug'] ?? '');
+ $name = trim((string) ($data['name'] ?? ''));
+ $version = (string) ($data['version'] ?? '');
+ if (!site_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 ($name === '') return ['ok' => false, 'error' => 'plugin.json: "name" fehlt.'];
+ if (!site_valid_version($version)) return ['ok' => false, 'error' => 'plugin.json: ungültige Versionsnummer.'];
+ $perms = array_values(array_unique(array_map('strval', (array) ($data['permissions'] ?? []))));
+ foreach ($perms as $p) if (!isset(site_known_permissions()[$p])) return ['ok' => false, 'error' => "plugin.json: unbekannte Berechtigung \"{$p}\"."];
+ sort($perms);
+ return ['ok' => true, 'meta' => ['slug' => $slug, 'name' => mb_substr($name, 0, 120), 'version' => $version, 'description' => mb_substr((string) ($data['description'] ?? ''), 0, 1000),
+ 'author' => mb_substr((string) ($data['author'] ?? ''), 0, 120), 'requires' => (string) ($data['requires'] ?? ''), 'permissions' => $perms]];
+ }
+ // Theme: Angaben stehen im Kopf der theme.css (Author:, Version:)
+ $head = substr($main, 0, 2000);
+ preg_match('/Version:\s*([0-9][0-9A-Za-z.\-+]*)/i', $head, $v);
+ preg_match('/Author:\s*([^\r\n*]+)/i', $head, $a);
+ return ['ok' => true, 'meta' => ['slug' => '', 'name' => '', 'version' => $v[1] ?? '', 'description' => '', 'author' => trim($a[1] ?? ''), 'requires' => '', 'permissions' => []]];
+}
+
+// ─── Signieren ───────────────────────────────────────────────────────────────
+
+/** Exakt die Nachricht, die DeskHub prüft (siehe plugin_signature_message). Themes werden mit "theme-" getrennt. */
+function site_signature_message(string $type, string $slug, string $version, string $sha256): string
+{
+ return "deskhub-plugin\n" . ($type === 'theme' ? 'theme-' : '') . "{$slug}\n{$version}\n" . strtolower($sha256);
+}
+
+function site_signing_key(): mixed
+{
+ $file = (string) site_config()['signing_key_file'];
+ if ($file === '' || !is_file($file)) return null;
+ return openssl_pkey_get_private((string) file_get_contents($file)) ?: null;
+}
+
+function site_public_key(): string
+{
+ $key = site_signing_key();
+ return $key ? (string) (openssl_pkey_get_details($key)['key'] ?? '') : '';
+}
+
+function site_sign(string $type, string $slug, string $version, string $sha256): string
+{
+ $key = site_signing_key();
+ if (!$key) return '';
+ openssl_sign(site_signature_message($type, $slug, $version, $sha256), $sig, $key, OPENSSL_ALGO_SHA256);
+ return base64_encode($sig);
+}
+
+// ─── Veröffentlichen ─────────────────────────────────────────────────────────
+
+/**
+ * Veröffentlicht eine Version. Für Themes sind $form['slug'], ['name'], ['version'] Pflicht (aus dem Formular).
+ *
+ * @return array{ok:bool, error?:string, slug?:string, version?:string, signed?:bool}
+ */
+function site_publish(string $type, string $zipPath, array $form = [], ?string $previewPath = null, ?string $previewName = null): array
+{
+ if (!in_array($type, ['plugin', 'theme'], true)) return ['ok' => false, 'error' => 'Unbekannter Typ.'];
+ $res = site_inspect_package($type, $zipPath);
+ if (!$res['ok']) return $res;
+ $meta = $res['meta'];
+ if ($type === 'theme') {
+ $meta['slug'] = trim((string) ($form['slug'] ?? ''));
+ $meta['name'] = trim((string) ($form['name'] ?? ''));
+ $meta['version'] = trim((string) ($form['version'] ?? '')) ?: $meta['version'];
+ $meta['description'] = mb_substr(trim((string) ($form['description'] ?? '')), 0, 1000);
+ if (!empty($form['author'])) $meta['author'] = mb_substr(trim((string) $form['author']), 0, 120);
+ if (!site_valid_slug($meta['slug'])) return ['ok' => false, 'error' => 'Bitte eine Kennung (Slug) angeben: a–z, 0–9, _ und -, 2–41 Zeichen.'];
+ if ($meta['name'] === '') return ['ok' => false, 'error' => 'Bitte einen Namen angeben.'];
+ if (!site_valid_version($meta['version'])) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 1.0.0 angeben (oder im Kopf der theme.css mit "Version:" eintragen).'];
+ }
+ $pdo = site_db();
+ $sha = hash_file('sha256', $zipPath);
+ $now = date('Y-m-d H:i:s');
+
+ $item = $pdo->prepare('SELECT * FROM items WHERE type = ? AND slug = ?');
+ $item->execute([$type, $meta['slug']]);
+ $item = $item->fetch();
+ if ($item) {
+ $dup = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ? AND version = ?');
+ $dup->execute([$item['id'], $meta['version']]);
+ if ((int) $dup->fetchColumn() > 0) return ['ok' => false, 'error' => "Version {$meta['version']} existiert bereits. Bitte eine neue Versionsnummer verwenden, veröffentlichte Versionen bleiben unverändert."];
+ }
+
+ $preview = null;
+ if ($previewPath !== null && is_file($previewPath)) {
+ $ext = strtolower(pathinfo((string) $previewName, PATHINFO_EXTENSION));
+ $info = @getimagesize($previewPath);
+ if (!in_array($ext, ['png', 'jpg', 'jpeg', 'webp'], true) || !$info || filesize($previewPath) > SITE_MAX_PREVIEW_BYTES) {
+ return ['ok' => false, 'error' => 'Das Vorschaubild muss PNG, JPG oder WebP sein und darf höchstens 1 MB groß sein.'];
+ }
+ $preview = $type . '-' . $meta['slug'] . '-' . substr($sha, 0, 8) . '.' . ($ext === 'jpeg' ? 'jpg' : $ext);
+ }
+
+ $file = $type . '/' . $meta['slug'] . '-' . $meta['version'] . '.zip';
+ $dest = site_storage() . '/packages/' . $file;
+ if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0755, true);
+ if (!(is_uploaded_file($zipPath) ? move_uploaded_file($zipPath, $dest) : copy($zipPath, $dest))) return ['ok' => false, 'error' => 'Das Paket konnte nicht gespeichert werden.'];
+ if ($preview !== null) (is_uploaded_file($previewPath) ? move_uploaded_file($previewPath, site_storage() . '/previews/' . $preview) : copy($previewPath, site_storage() . '/previews/' . $preview));
+
+ $signature = site_sign($type, $meta['slug'], $meta['version'], $sha);
+ $pdo->beginTransaction();
+ try {
+ if ($item) {
+ $pdo->prepare('UPDATE items SET name = ?, description = ?, author = ?, preview = COALESCE(?, preview), updated_at = ? WHERE id = ?')
+ ->execute([$meta['name'], $meta['description'], $meta['author'], $preview, $now, $item['id']]);
+ $itemId = (int) $item['id'];
+ } else {
+ $pdo->prepare('INSERT INTO items (type, slug, name, description, author, preview, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)')
+ ->execute([$type, $meta['slug'], $meta['name'], $meta['description'], $meta['author'], $preview, $now, $now]);
+ $itemId = (int) $pdo->lastInsertId();
+ }
+ $pdo->prepare('INSERT INTO versions (item_id, version, file, sha256, signature, size, requires, permissions, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
+ ->execute([$itemId, $meta['version'], $file, $sha, $signature, (int) filesize($dest), $meta['requires'], implode(',', $meta['permissions']), $now]);
+ $pdo->commit();
+ } catch (Throwable $e) {
+ $pdo->rollBack();
+ @unlink($dest);
+ return ['ok' => false, 'error' => 'Speichern fehlgeschlagen: ' . $e->getMessage()];
+ }
+ return ['ok' => true, 'slug' => $meta['slug'], 'version' => $meta['version'], 'signed' => $signature !== '', 'sha256' => $sha];
+}
+
+/** Löscht eine Datei und versucht es kurz erneut, falls sie noch von einem Virenscanner oder einem anderen Prozess gehalten wird. */
+function site_remove_file(string $path): bool
+{
+ for ($i = 0; $i < 10; $i++) {
+ if (!file_exists($path) || @unlink($path)) return true;
+ usleep(100000);
+ }
+ error_log('Plugin-Webseite: Datei konnte nicht gelöscht werden: ' . $path);
+ return false;
+}
+
+function site_delete_version(int $versionId): bool
+{
+ $pdo = site_db();
+ $v = $pdo->prepare('SELECT * FROM versions WHERE id = ?');
+ $v->execute([$versionId]);
+ $v = $v->fetch();
+ if (!$v) return false;
+ site_remove_file(site_storage() . '/packages/' . $v['file']);
+ $pdo->prepare('DELETE FROM versions WHERE id = ?')->execute([$versionId]);
+ $left = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ?');
+ $left->execute([$v['item_id']]);
+ if ((int) $left->fetchColumn() === 0) {
+ $item = $pdo->prepare('SELECT preview FROM items WHERE id = ?');
+ $item->execute([$v['item_id']]);
+ if ($p = $item->fetchColumn()) site_remove_file(site_storage() . '/previews/' . $p);
+ $pdo->prepare('DELETE FROM items WHERE id = ?')->execute([$v['item_id']]);
+ }
+ return true;
+}
+
+// ─── Abfragen ────────────────────────────────────────────────────────────────
+
+/** Alle Einträge eines Typs mit ihrer neuesten Version. */
+function site_items(string $type, string $search = ''): array
+{
+ $pdo = site_db();
+ $stmt = $pdo->prepare('SELECT * FROM items WHERE type = ? ORDER BY name COLLATE NOCASE');
+ $stmt->execute([$type]);
+ $out = [];
+ foreach ($stmt->fetchAll() as $item) {
+ if ($search !== '' && stripos($item['name'] . ' ' . $item['description'] . ' ' . $item['author'] . ' ' . $item['slug'], $search) === false) continue;
+ $versions = site_versions((int) $item['id']);
+ if (!$versions) continue;
+ $item['latest'] = $versions[0];
+ $item['downloads'] = array_sum(array_column($versions, 'downloads'));
+ $out[] = $item;
+ }
+ return $out;
+}
+
+/** Versionen eines Eintrags, neueste zuerst. */
+function site_versions(int $itemId): array
+{
+ $stmt = site_db()->prepare('SELECT * FROM versions WHERE item_id = ?');
+ $stmt->execute([$itemId]);
+ $rows = $stmt->fetchAll();
+ usort($rows, fn($a, $b) => version_compare($b['version'], $a['version']));
+ return $rows;
+}
+
+function site_item(string $type, string $slug): ?array
+{
+ $stmt = site_db()->prepare('SELECT * FROM items WHERE type = ? AND slug = ?');
+ $stmt->execute([$type, $slug]);
+ $item = $stmt->fetch();
+ if (!$item) return null;
+ $item['versions'] = site_versions((int) $item['id']);
+ return $item['versions'] ? $item : null;
+}
+
+/** Katalog im Format, das DeskHub liest (neueste Version je Eintrag). */
+function site_catalog(string $type): array
+{
+ $base = site_base_url();
+ $list = [];
+ foreach (site_items($type) as $item) {
+ $v = $item['latest'];
+ $entry = [
+ 'slug' => $item['slug'], 'name' => $item['name'], 'version' => $v['version'], 'description' => $item['description'], 'author' => $item['author'],
+ 'downloadUrl' => $base . '/download.php?type=' . $type . '&slug=' . rawurlencode($item['slug']) . '&version=' . rawurlencode($v['version']),
+ 'sha256' => $v['sha256'], 'signature' => $v['signature'],
+ ];
+ if ($item['preview']) $entry['previewUrl'] = $base . '/preview.php?file=' . rawurlencode($item['preview']);
+ if ($type === 'plugin') {
+ $entry['requires'] = $v['requires'];
+ $entry['permissions'] = array_values(array_filter(explode(',', (string) $v['permissions'])));
+ }
+ $list[] = $entry;
+ }
+ return [$type === 'plugin' ? 'plugins' : 'themes' => $list];
+}
+
+/** Inhalt der Datei includes/catalog_defaults.php für DeskHub-Releases (offizieller Katalog + öffentlicher Schlüssel). */
+function site_deskhub_defaults_snippet(): string
+{
+ $base = site_base_url();
+ return " " . var_export($base . '/catalog.php?type=plugins', true) . ",
+ 'themes_url' => " . var_export($base . '/catalog.php?type=themes', true)
+ . ",
+ 'publisher_key' => " . var_export(site_public_key(), true) . ",
+ 'access_key' => " . var_export((string) site_config()['access_key'], true) . ",
+];
+";
+}