Upload via GUI (24 Dateien)

This commit is contained in:
2026-10-16 18:51:55 +00:00
parent b393148e3b
commit 50f98f21d7
+406 -395
View File
@@ -1,395 +1,406 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* Kern der Plugin- und Theme-Webseite: Konfiguration, Datenbank, Paketprüfung, Veröffentlichen, Signieren, Katalog. * Kern der Plugin- und Theme-Webseite: Konfiguration, Datenbank, Paketprüfung, Veröffentlichen, Signieren, Katalog.
* Die Webseite ist unabhängig von DeskHub und läuft mit PHP 8.1+ (SQLite, zip, openssl). * Die Webseite ist unabhängig von DeskHub und läuft mit PHP 8.1+ (SQLite, zip, openssl).
*/ */
const SITE_MAX_PACKAGE_BYTES = 10 * 1024 * 1024; const SITE_MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
const SITE_MAX_PREVIEW_BYTES = 1024 * 1024; const SITE_MAX_PREVIEW_BYTES = 1024 * 1024;
const SITE_PLUGIN_EXTENSIONS = ['php', 'json', 'sql', 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'md', 'txt', 'html']; const SITE_PLUGIN_EXTENSIONS = ['php', 'json', 'sql', 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'md', 'txt', 'html'];
const SITE_THEME_EXTENSIONS = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2']; const SITE_THEME_EXTENSIONS = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2'];
/** Muss mit plugin_known_permissions() in DeskHub (includes/plugins.php) übereinstimmen. */ /** Muss mit plugin_known_permissions() in DeskHub (includes/plugins.php) übereinstimmen. */
function site_known_permissions(): array function site_known_permissions(): array
{ {
return [ return [
'hooks.tickets' => 'Auf Ticket-Ereignisse reagieren (erstellt, Antwort, Status, Zuweisung)', 'hooks.tickets' => 'Auf Ticket-Ereignisse reagieren (erstellt, Antwort, Status, Zuweisung)',
'hooks.cron' => 'Regelmäßig im Hintergrund laufen (etwa alle 5 Minuten)', 'hooks.cron' => 'Regelmäßig im Hintergrund laufen (etwa alle 5 Minuten)',
'ui.menu' => 'Eigene Menüpunkte und Seiten im Mitarbeiterbereich', 'ui.menu' => 'Eigene Menüpunkte und Seiten im Mitarbeiterbereich',
'ui.sidebar' => 'Bausteine in der Seitenleiste der Ticketansicht', 'ui.sidebar' => 'Bausteine in der Seitenleiste der Ticketansicht',
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API', 'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen', 'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
]; ];
} }
/** /**
* Konfiguration aus config.php; Umgebungsvariablen (nur für Tests) überschreiben einzelne Werte. * 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 * 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. * Datei: Weder Umgebungsvariablen noch eine andere Konfigurationsdatei können Zugang, Schlüssel oder Speicherort ändern.
*/ */
function site_config(): array function site_config(): array
{ {
static $cfg = null; static $cfg = null;
if ($cfg === null) { if ($cfg === null) {
$default = dirname(__DIR__) . '/config.php'; $default = dirname(__DIR__) . '/config.php';
$baked = is_file($default) ? (array) require $default : []; $baked = is_file($default) ? (array) require $default : [];
$locked = !empty($baked['locked']); $locked = !empty($baked['locked']);
$file = $locked ? $default : (getenv('PLUGIN_SITE_CONFIG') ?: $default); $file = $locked ? $default : (getenv('PLUGIN_SITE_CONFIG') ?: $default);
$cfg = is_file($file) ? (array) require $file : []; $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]; $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) { 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) { 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); if (getenv($env) !== false && getenv($env) !== '') $cfg[$key] = getenv($env);
} }
} }
} }
return $cfg; return $cfg;
} }
/** /**
* Zugangsschutz für Katalog, Downloads, Vorschaubilder und den öffentlichen Schlüssel: DeskHub schickt den Schlüssel bei jedem * 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). * 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 function site_access_ok(): bool
{ {
$key = (string) site_config()['access_key']; $key = (string) site_config()['access_key'];
if ($key === '') return true; if ($key === '') return true;
return hash_equals($key, (string) ($_SERVER['HTTP_X_DESKHUB_KEY'] ?? '')); return hash_equals($key, (string) ($_SERVER['HTTP_X_DESKHUB_KEY'] ?? ''));
} }
/** Bricht mit 401 ab, wenn der Zugangsschlüssel fehlt oder falsch ist. */ /** Bricht mit 401 ab, wenn der Zugangsschlüssel fehlt oder falsch ist. */
function site_require_access(): void function site_require_access(): void
{ {
if (site_access_ok()) return; if (site_access_ok()) return;
usleep(300000); // Raten bremsen usleep(300000); // Raten bremsen
http_response_code(401); http_response_code(401);
header('Content-Type: text/plain; charset=utf-8'); header('Content-Type: text/plain; charset=utf-8');
header('WWW-Authenticate: X-DeskHub-Key'); header('WWW-Authenticate: X-DeskHub-Key');
exit('Zugriff nur mit gültigem Zugangsschlüssel.'); exit('Zugriff nur mit gültigem Zugangsschlüssel.');
} }
function site_storage(): string function site_storage(): string
{ {
$dir = rtrim((string) site_config()['storage_dir'], '/' . chr(92)); $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); 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<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\n"); if (!is_file($dir . '/.htaccess')) @file_put_contents($dir . '/.htaccess', "Require all denied\n<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\n");
return $dir; return $dir;
} }
function site_db(): PDO function site_db(): PDO
{ {
static $pdo = null; static $pdo = null;
if ($pdo !== null) return $pdo; if ($pdo !== null) return $pdo;
$pdo = new PDO('sqlite:' . site_storage() . '/katalog.sqlite'); $pdo = new PDO('sqlite:' . site_storage() . '/katalog.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec("CREATE TABLE IF NOT EXISTS items ( $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 '', 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))"); 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 ( $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, 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 '', 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), 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)"); FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE)");
return $pdo; return $pdo;
} }
function site_h(?string $s): string function site_h(?string $s): string
{ {
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
} }
function site_valid_slug(string $slug): bool function site_valid_slug(string $slug): bool
{ {
return (bool) preg_match('/^[a-z0-9][a-z0-9_-]{1,40}$/', $slug); return (bool) preg_match('/^[a-z0-9][a-z0-9_-]{1,40}$/', $slug);
} }
function site_valid_version(string $v): bool function site_valid_version(string $v): bool
{ {
return (bool) preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $v); return (bool) preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $v);
} }
function site_format_size(int $bytes): string function site_format_size(int $bytes): string
{ {
return $bytes >= 1048576 ? number_format($bytes / 1048576, 1, ',', '.') . ' MB' : number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB'; 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). */ /** Absolute Adresse der Webseite (für Download-Links im Katalog). */
function site_base_url(): string function site_base_url(): string
{ {
$base = rtrim((string) site_config()['base_url'], '/'); $base = rtrim((string) site_config()['base_url'], '/');
if ($base !== '') return $base; if ($base !== '') return $base;
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
return ($https ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/' . chr(92)); return ($https ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/' . chr(92));
} }
// ─── Paketprüfung ──────────────────────────────────────────────────────────── // ─── Paketprüfung ────────────────────────────────────────────────────────────
function site_path_ok(string $rel, array $allowedExtensions): bool function site_path_ok(string $rel, array $allowedExtensions): bool
{ {
$rel = str_replace(chr(92), '/', $rel); $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; 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; foreach (explode('/', $rel) as $part) if ($part !== '' && $part[0] === '.') return false;
return in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), $allowedExtensions, true); return in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), $allowedExtensions, true);
} }
/** /**
* Prüft ein Paket und liest seine Angaben. * Prüft ein Paket und liest seine Angaben.
* *
* @return array{ok:bool, error?:string, meta?:array} * @return array{ok:bool, error?:string, meta?:array}
*/ */
function site_inspect_package(string $type, string $zipPath): array function site_inspect_package(string $type, string $zipPath): array
{ {
if (!class_exists('ZipArchive')) return ['ok' => false, 'error' => 'Die PHP-Erweiterung zip fehlt.']; if (!class_exists('ZipArchive')) return ['ok' => false, 'error' => 'Die PHP-Erweiterung zip fehlt.'];
$size = (int) @filesize($zipPath); $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.']; 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(); $zip = new ZipArchive();
if ($zip->open($zipPath) !== true) return ['ok' => false, 'error' => 'Die ZIP-Datei konnte nicht geöffnet werden.']; if ($zip->open($zipPath) !== true) return ['ok' => false, 'error' => 'Die ZIP-Datei konnte nicht geöffnet werden.'];
$names = []; $names = [];
for ($i = 0; $i < $zip->numFiles; $i++) $names[$i] = str_replace(chr(92), '/', (string) $zip->getNameIndex($i)); for ($i = 0; $i < $zip->numFiles; $i++) $names[$i] = str_replace(chr(92), '/', (string) $zip->getNameIndex($i));
$marker = $type === 'plugin' ? 'plugin.json' : 'theme.css'; $marker = $type === 'plugin' ? 'plugin.json' : 'theme.css';
$prefix = ''; $prefix = '';
if (!in_array($marker, $names, true)) { if (!in_array($marker, $names, true)) {
foreach ($names as $n) if (preg_match('#^([^/]+/)' . preg_quote($marker, '#') . '$#', $n, $m)) { $prefix = $m[1]; break; } 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.']; } if ($prefix === '') { $zip->close(); return ['ok' => false, 'error' => $marker . ' wurde im Paket nicht gefunden.']; }
} }
$extensions = $type === 'plugin' ? SITE_PLUGIN_EXTENSIONS : SITE_THEME_EXTENSIONS; $extensions = $type === 'plugin' ? SITE_PLUGIN_EXTENSIONS : SITE_THEME_EXTENSIONS;
$total = 0; $total = 0;
$count = 0; $count = 0;
foreach ($names as $i => $name) { foreach ($names as $i => $name) {
if (substr($name, -1) === '/') continue; 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.']; } 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)); $rel = substr($name, strlen($prefix));
if (!site_path_ok($rel, $extensions)) { $zip->close(); return ['ok' => false, 'error' => 'Unzulässige Datei im Paket: ' . $rel]; } 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); $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.']; } 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); $main = (string) $zip->getFromName($prefix . $marker);
$zip->close(); $zip->close();
if ($type === 'plugin') { if ($type === 'plugin') {
$data = json_decode($main, true); $data = json_decode($main, true);
if (!is_array($data)) return ['ok' => false, 'error' => 'plugin.json ist kein gültiges JSON.']; if (!is_array($data)) return ['ok' => false, 'error' => 'plugin.json ist kein gültiges JSON.'];
$slug = (string) ($data['slug'] ?? ''); $slug = (string) ($data['slug'] ?? '');
$name = trim((string) ($data['name'] ?? '')); $name = trim((string) ($data['name'] ?? ''));
$version = (string) ($data['version'] ?? ''); $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 (!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 (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 ($name === '') return ['ok' => false, 'error' => 'plugin.json: "name" fehlt.'];
if (!site_valid_version($version)) return ['ok' => false, 'error' => 'plugin.json: ungültige Versionsnummer.']; 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'] ?? [])))); $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}\"."]; foreach ($perms as $p) if (!isset(site_known_permissions()[$p])) return ['ok' => false, 'error' => "plugin.json: unbekannte Berechtigung \"{$p}\"."];
sort($perms); sort($perms);
return ['ok' => true, 'meta' => ['slug' => $slug, 'name' => mb_substr($name, 0, 120), 'version' => $version, 'description' => mb_substr((string) ($data['description'] ?? ''), 0, 1000), 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]]; 'author' => mb_substr((string) ($data['author'] ?? ''), 0, 120), 'requires' => (string) ($data['requires'] ?? ''), 'permissions' => $perms]];
} }
// Theme: Angaben stehen im Kopf der theme.css (Author:, Version:) // Theme: Angaben stehen im Kopf der theme.css (Author:, Version:)
$head = substr($main, 0, 2000); $head = substr($main, 0, 2000);
preg_match('/Version:\s*([0-9][0-9A-Za-z.\-+]*)/i', $head, $v); preg_match('/Version:\s*([0-9][0-9A-Za-z.\-+]*)/i', $head, $v);
preg_match('/Author:\s*([^\r\n*]+)/i', $head, $a); 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' => []]]; return ['ok' => true, 'meta' => ['slug' => '', 'name' => '', 'version' => $v[1] ?? '', 'description' => '', 'author' => trim($a[1] ?? ''), 'requires' => '', 'permissions' => []]];
} }
// ─── Signieren ─────────────────────────────────────────────────────────────── // ─── Signieren ───────────────────────────────────────────────────────────────
/** Exakt die Nachricht, die DeskHub prüft (siehe plugin_signature_message). Themes werden mit "theme-" getrennt. */ /** 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 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); return "deskhub-plugin\n" . ($type === 'theme' ? 'theme-' : '') . "{$slug}\n{$version}\n" . strtolower($sha256);
} }
function site_signing_key(): mixed function site_signing_key(): mixed
{ {
$file = (string) site_config()['signing_key_file']; $file = (string) site_config()['signing_key_file'];
if ($file === '' || !is_file($file)) return null; if ($file === '' || !is_file($file)) return null;
return openssl_pkey_get_private((string) file_get_contents($file)) ?: null; return openssl_pkey_get_private((string) file_get_contents($file)) ?: null;
} }
function site_public_key(): string function site_public_key(): string
{ {
$key = site_signing_key(); $key = site_signing_key();
return $key ? (string) (openssl_pkey_get_details($key)['key'] ?? '') : ''; return $key ? (string) (openssl_pkey_get_details($key)['key'] ?? '') : '';
} }
function site_sign(string $type, string $slug, string $version, string $sha256): string function site_sign(string $type, string $slug, string $version, string $sha256): string
{ {
$key = site_signing_key(); $key = site_signing_key();
if (!$key) return ''; if (!$key) return '';
openssl_sign(site_signature_message($type, $slug, $version, $sha256), $sig, $key, OPENSSL_ALGO_SHA256); openssl_sign(site_signature_message($type, $slug, $version, $sha256), $sig, $key, OPENSSL_ALGO_SHA256);
return base64_encode($sig); return base64_encode($sig);
} }
// ─── Veröffentlichen ───────────────────────────────────────────────────────── // ─── Veröffentlichen ─────────────────────────────────────────────────────────
/** /**
* Veröffentlicht eine Version. Für Themes sind $form['slug'], ['name'], ['version'] Pflicht (aus dem Formular). * 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} * @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 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.']; if (!in_array($type, ['plugin', 'theme'], true)) return ['ok' => false, 'error' => 'Unbekannter Typ.'];
$res = site_inspect_package($type, $zipPath); $res = site_inspect_package($type, $zipPath);
if (!$res['ok']) return $res; if (!$res['ok']) return $res;
$meta = $res['meta']; $meta = $res['meta'];
if ($type === 'theme') { if ($type === 'theme') {
$meta['slug'] = trim((string) ($form['slug'] ?? '')); $meta['slug'] = trim((string) ($form['slug'] ?? ''));
$meta['name'] = trim((string) ($form['name'] ?? '')); $meta['name'] = trim((string) ($form['name'] ?? ''));
$meta['version'] = trim((string) ($form['version'] ?? '')) ?: $meta['version']; $meta['version'] = trim((string) ($form['version'] ?? '')) ?: $meta['version'];
$meta['description'] = mb_substr(trim((string) ($form['description'] ?? '')), 0, 1000); $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 (!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 (!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 ($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).']; 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(); $pdo = site_db();
$sha = hash_file('sha256', $zipPath); $sha = hash_file('sha256', $zipPath);
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$item = $pdo->prepare('SELECT * FROM items WHERE type = ? AND slug = ?'); $item = $pdo->prepare('SELECT * FROM items WHERE type = ? AND slug = ?');
$item->execute([$type, $meta['slug']]); $item->execute([$type, $meta['slug']]);
$item = $item->fetch(); $item = $item->fetch();
if ($item) { if ($item) {
$dup = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ? AND version = ?'); $dup = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ? AND version = ?');
$dup->execute([$item['id'], $meta['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."]; 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; $preview = null;
if ($previewPath !== null && is_file($previewPath)) { if ($previewPath !== null && is_file($previewPath)) {
$ext = strtolower(pathinfo((string) $previewName, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo((string) $previewName, PATHINFO_EXTENSION));
$info = @getimagesize($previewPath); $info = @getimagesize($previewPath);
if (!in_array($ext, ['png', 'jpg', 'jpeg', 'webp'], true) || !$info || filesize($previewPath) > SITE_MAX_PREVIEW_BYTES) { 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.']; 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); $preview = $type . '-' . $meta['slug'] . '-' . substr($sha, 0, 8) . '.' . ($ext === 'jpeg' ? 'jpg' : $ext);
} }
$file = $type . '/' . $meta['slug'] . '-' . $meta['version'] . '.zip'; $file = $type . '/' . $meta['slug'] . '-' . $meta['version'] . '.zip';
$dest = site_storage() . '/packages/' . $file; $dest = site_storage() . '/packages/' . $file;
if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0755, true); 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 (!(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)); 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); $signature = site_sign($type, $meta['slug'], $meta['version'], $sha);
$pdo->beginTransaction(); $pdo->beginTransaction();
try { try {
if ($item) { if ($item) {
$pdo->prepare('UPDATE items SET name = ?, description = ?, author = ?, preview = COALESCE(?, preview), updated_at = ? WHERE id = ?') $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']]); ->execute([$meta['name'], $meta['description'], $meta['author'], $preview, $now, $item['id']]);
$itemId = (int) $item['id']; $itemId = (int) $item['id'];
} else { } else {
$pdo->prepare('INSERT INTO items (type, slug, name, description, author, preview, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)') $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]); ->execute([$type, $meta['slug'], $meta['name'], $meta['description'], $meta['author'], $preview, $now, $now]);
$itemId = (int) $pdo->lastInsertId(); $itemId = (int) $pdo->lastInsertId();
} }
$pdo->prepare('INSERT INTO versions (item_id, version, file, sha256, signature, size, requires, permissions, created_at) VALUES (?,?,?,?,?,?,?,?,?)') $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]); ->execute([$itemId, $meta['version'], $file, $sha, $signature, (int) filesize($dest), $meta['requires'], implode(',', $meta['permissions']), $now]);
$pdo->commit(); $pdo->commit();
} catch (Throwable $e) { } catch (Throwable $e) {
$pdo->rollBack(); $pdo->rollBack();
@unlink($dest); @unlink($dest);
return ['ok' => false, 'error' => 'Speichern fehlgeschlagen: ' . $e->getMessage()]; return ['ok' => false, 'error' => 'Speichern fehlgeschlagen: ' . $e->getMessage()];
} }
return ['ok' => true, 'slug' => $meta['slug'], 'version' => $meta['version'], 'signed' => $signature !== '', 'sha256' => $sha]; return ['ok' => true, 'slug' => $meta['slug'], 'version' => $meta['version'], 'signed' => $signature !== '', 'sha256' => $sha];
} }
function site_delete_version(int $versionId): bool /** 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
$pdo = site_db(); {
$v = $pdo->prepare('SELECT * FROM versions WHERE id = ?'); for ($i = 0; $i < 10; $i++) {
$v->execute([$versionId]); if (!file_exists($path) || @unlink($path)) return true;
$v = $v->fetch(); usleep(100000);
if (!$v) return false; }
@unlink(site_storage() . '/packages/' . $v['file']); error_log('Plugin-Webseite: Datei konnte nicht gelöscht werden: ' . $path);
$pdo->prepare('DELETE FROM versions WHERE id = ?')->execute([$versionId]); return false;
$left = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ?'); }
$left->execute([$v['item_id']]);
if ((int) $left->fetchColumn() === 0) { function site_delete_version(int $versionId): bool
$item = $pdo->prepare('SELECT preview FROM items WHERE id = ?'); {
$item->execute([$v['item_id']]); $pdo = site_db();
if ($p = $item->fetchColumn()) @unlink(site_storage() . '/previews/' . $p); $v = $pdo->prepare('SELECT * FROM versions WHERE id = ?');
$pdo->prepare('DELETE FROM items WHERE id = ?')->execute([$v['item_id']]); $v->execute([$versionId]);
} $v = $v->fetch();
return true; if (!$v) return false;
} site_remove_file(site_storage() . '/packages/' . $v['file']);
$pdo->prepare('DELETE FROM versions WHERE id = ?')->execute([$versionId]);
// ─── Abfragen ──────────────────────────────────────────────────────────────── $left = $pdo->prepare('SELECT COUNT(*) FROM versions WHERE item_id = ?');
$left->execute([$v['item_id']]);
/** Alle Einträge eines Typs mit ihrer neuesten Version. */ if ((int) $left->fetchColumn() === 0) {
function site_items(string $type, string $search = ''): array $item = $pdo->prepare('SELECT preview FROM items WHERE id = ?');
{ $item->execute([$v['item_id']]);
$pdo = site_db(); if ($p = $item->fetchColumn()) site_remove_file(site_storage() . '/previews/' . $p);
$stmt = $pdo->prepare('SELECT * FROM items WHERE type = ? ORDER BY name COLLATE NOCASE'); $pdo->prepare('DELETE FROM items WHERE id = ?')->execute([$v['item_id']]);
$stmt->execute([$type]); }
$out = []; return true;
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']); // ─── Abfragen ────────────────────────────────────────────────────────────────
if (!$versions) continue;
$item['latest'] = $versions[0]; /** Alle Einträge eines Typs mit ihrer neuesten Version. */
$item['downloads'] = array_sum(array_column($versions, 'downloads')); function site_items(string $type, string $search = ''): array
$out[] = $item; {
} $pdo = site_db();
return $out; $stmt = $pdo->prepare('SELECT * FROM items WHERE type = ? ORDER BY name COLLATE NOCASE');
} $stmt->execute([$type]);
$out = [];
/** Versionen eines Eintrags, neueste zuerst. */ foreach ($stmt->fetchAll() as $item) {
function site_versions(int $itemId): array if ($search !== '' && stripos($item['name'] . ' ' . $item['description'] . ' ' . $item['author'] . ' ' . $item['slug'], $search) === false) continue;
{ $versions = site_versions((int) $item['id']);
$stmt = site_db()->prepare('SELECT * FROM versions WHERE item_id = ?'); if (!$versions) continue;
$stmt->execute([$itemId]); $item['latest'] = $versions[0];
$rows = $stmt->fetchAll(); $item['downloads'] = array_sum(array_column($versions, 'downloads'));
usort($rows, fn($a, $b) => version_compare($b['version'], $a['version'])); $out[] = $item;
return $rows; }
} return $out;
}
function site_item(string $type, string $slug): ?array
{ /** Versionen eines Eintrags, neueste zuerst. */
$stmt = site_db()->prepare('SELECT * FROM items WHERE type = ? AND slug = ?'); function site_versions(int $itemId): array
$stmt->execute([$type, $slug]); {
$item = $stmt->fetch(); $stmt = site_db()->prepare('SELECT * FROM versions WHERE item_id = ?');
if (!$item) return null; $stmt->execute([$itemId]);
$item['versions'] = site_versions((int) $item['id']); $rows = $stmt->fetchAll();
return $item['versions'] ? $item : null; usort($rows, fn($a, $b) => version_compare($b['version'], $a['version']));
} return $rows;
}
/** Katalog im Format, das DeskHub liest (neueste Version je Eintrag). */
function site_catalog(string $type): array function site_item(string $type, string $slug): ?array
{ {
$base = site_base_url(); $stmt = site_db()->prepare('SELECT * FROM items WHERE type = ? AND slug = ?');
$list = []; $stmt->execute([$type, $slug]);
foreach (site_items($type) as $item) { $item = $stmt->fetch();
$v = $item['latest']; if (!$item) return null;
$entry = [ $item['versions'] = site_versions((int) $item['id']);
'slug' => $item['slug'], 'name' => $item['name'], 'version' => $v['version'], 'description' => $item['description'], 'author' => $item['author'], return $item['versions'] ? $item : null;
'downloadUrl' => $base . '/download.php?type=' . $type . '&slug=' . rawurlencode($item['slug']) . '&version=' . rawurlencode($v['version']), }
'sha256' => $v['sha256'], 'signature' => $v['signature'],
]; /** Katalog im Format, das DeskHub liest (neueste Version je Eintrag). */
if ($item['preview']) $entry['previewUrl'] = $base . '/preview.php?file=' . rawurlencode($item['preview']); function site_catalog(string $type): array
if ($type === 'plugin') { {
$entry['requires'] = $v['requires']; $base = site_base_url();
$entry['permissions'] = array_values(array_filter(explode(',', (string) $v['permissions']))); $list = [];
} foreach (site_items($type) as $item) {
$list[] = $entry; $v = $item['latest'];
} $entry = [
return [$type === 'plugin' ? 'plugins' : 'themes' => $list]; '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'],
/** Inhalt der Datei includes/catalog_defaults.php für DeskHub-Releases (offizieller Katalog + öffentlicher Schlüssel). */ ];
function site_deskhub_defaults_snippet(): string if ($item['preview']) $entry['previewUrl'] = $base . '/preview.php?file=' . rawurlencode($item['preview']);
{ if ($type === 'plugin') {
$base = site_base_url(); $entry['requires'] = $v['requires'];
return "<?php $entry['permissions'] = array_values(array_filter(explode(',', (string) $v['permissions'])));
return [ }
'plugins_url' => " . var_export($base . '/catalog.php?type=plugins', true) . ", $list[] = $entry;
'themes_url' => " . var_export($base . '/catalog.php?type=themes', true) }
. ", return [$type === 'plugin' ? 'plugins' : 'themes' => $list];
'publisher_key' => " . var_export(site_public_key(), true) . ", }
'access_key' => " . var_export((string) site_config()['access_key'], true) . ",
]; /** 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 "<?php
return [
'plugins_url' => " . 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) . ",
];
";
}