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) . ", +]; +"; +}