Files
deskhub/plugin-webseite/inc/lib.php
T
2026-10-16 18:42:18 +00:00

396 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* 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).
*/
const SITE_MAX_PACKAGE_BYTES = 10 * 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_THEME_EXTENSIONS = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2'];
/** Muss mit plugin_known_permissions() in DeskHub (includes/plugins.php) übereinstimmen. */
function site_known_permissions(): array
{
return [
'hooks.tickets' => 'Auf Ticket-Ereignisse reagieren (erstellt, Antwort, Status, Zuweisung)',
'hooks.cron' => 'Regelmäßig im Hintergrund laufen (etwa alle 5 Minuten)',
'ui.menu' => 'Eigene Menüpunkte und Seiten im Mitarbeiterbereich',
'ui.sidebar' => 'Bausteine in der Seitenleiste der Ticketansicht',
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
];
}
/**
* 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<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\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 "<?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) . ",
];
";
}