Files
deskhub/plugin-webseite/inc/lib.php
T
2026-11-01 11:17:07 +00:00

576 lines
33 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_APP_BYTES = 300 * 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',
'api.public' => 'Öffentliche Webhook-Adresse (zum Beispiel für Discord oder Telegram); das Plugin prüft die Echtheit der Anfragen selbst',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
];
}
/**
* 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' => '', 'fcm_service_account_file' => '', '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', 'PLUGIN_SITE_FCM_FILE' => 'fcm_service_account_file'] 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)");
$pdo->exec("CREATE TABLE IF NOT EXISTS app_releases (
id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, version TEXT NOT NULL, file TEXT NOT NULL,
sha256 TEXT NOT NULL, size INTEGER NOT NULL, notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL,
UNIQUE(platform, version))");
// Empfehlung (nachträglich ergänzt, damit bestehende Datenbanken ohne Handarbeit weiterlaufen)
$cols = array_column($pdo->query('PRAGMA table_info(items)')->fetchAll(), 'name');
if (!in_array('featured', $cols, true)) $pdo->exec('ALTER TABLE items ADD COLUMN featured INTEGER NOT NULL DEFAULT 0');
if (!in_array('language', $cols, true)) $pdo->exec('ALTER TABLE items ADD COLUMN language TEXT NULL');
return $pdo;
}
/** Plattformen der Apps-Seite: Dateiendung, Dateikopf, Name beim Herunterladen. "zip" = Paket mit Angaben zur Version (wird automatisch gelesen). */
function site_app_platforms(): array
{
return [
'windows' => ['label' => 'Windows', 'what' => 'Setup (EXE)', 'ext' => 'exe', 'magic' => 'MZ', 'file' => 'DeskHub-Setup.exe', 'mime' => 'application/vnd.microsoft.portable-executable'],
'android' => ['label' => 'Android', 'what' => 'Installationsdatei (APK)', 'ext' => 'apk', 'magic' => 'PK', 'file' => 'DeskHub.apk', 'mime' => 'application/vnd.android.package-archive'],
'minecraft' => ['label' => 'Minecraft', 'what' => 'Plugin (JAR)', 'ext' => 'jar', 'magic' => 'PK', 'file' => 'DeskHub-Minecraft.jar', 'mime' => 'application/java-archive'],
'wordpress' => ['label' => 'WordPress', 'what' => 'Plugin (ZIP)', 'ext' => 'zip', 'magic' => 'PK', 'file' => 'deskhub-tickets-wordpress.zip', 'mime' => 'application/zip'],
'chrome' => ['label' => 'Chrome', 'what' => 'Erweiterung (ZIP)', 'ext' => 'zip', 'magic' => 'PK', 'file' => 'deskhub-chrome-erweiterung.zip', 'mime' => 'application/zip'],
];
}
/** Hochgeladene Datei (nur in den Tests mit PLUGIN_SITE_TESTING=1 auch eine lokale Datei). */
function site_is_upload(string $tmp): bool
{
return is_uploaded_file($tmp) || (getenv('PLUGIN_SITE_TESTING') === '1' && is_file($tmp));
}
function site_move_upload(string $tmp, string $dest): bool
{
return is_uploaded_file($tmp) ? move_uploaded_file($tmp, $dest) : (getenv('PLUGIN_SITE_TESTING') === '1' && copy($tmp, $dest));
}
/** Liest die Versionsnummer aus einem WordPress-Plugin-ZIP (Kopf der Hauptdatei) oder einer Chrome-Erweiterung (manifest.json); null, wenn es keines von beiden ist. */
function site_zip_app_version(string $platform, string $zipPath): ?string
{
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true || $zip->numFiles > 2000) return null;
$found = null;
for ($i = 0; $i < $zip->numFiles && $found === null; $i++) {
$name = str_replace(chr(92), '/', (string) $zip->getNameIndex($i));
if (substr_count($name, '/') > 1) continue;
if ($platform === 'chrome' && preg_match('#^(?:[^/]+/)?manifest\.json$#', $name)) {
$data = json_decode((string) $zip->getFromIndex($i), true);
if (is_array($data) && isset($data['manifest_version'], $data['version'])) $found = (string) $data['version'];
} elseif ($platform === 'wordpress' && preg_match('#\.php$#i', $name)) {
$head = (string) $zip->getFromIndex($i, 8192);
if (preg_match('/^[ \/*#@]*Plugin Name:/mi', $head) && preg_match('/^[ \/*#@]*Version:\s*([0-9][0-9A-Za-z.+-]*)/mi', $head, $m)) $found = $m[1];
}
}
$zip->close();
if ($found !== null && preg_match('/^\d+\.\d+$/', $found)) $found .= '.0';
return $found;
}
/** Latest published app release per platform: the HIGHEST version, not the one uploaded last. */
function site_app_release(string $platform): ?array
{
if (!isset(site_app_platforms()[$platform])) return null;
$stmt = site_db()->prepare('SELECT * FROM app_releases WHERE platform = ?');
$stmt->execute([$platform]);
$rows = $stmt->fetchAll();
if (!$rows) return null;
usort($rows, fn($a, $b) => version_compare($b['version'], $a['version']) ?: ((int) $b['id'] <=> (int) $a['id']));
return $rows[0];
}
function site_app_releases(): array
{
$rows = site_db()->query('SELECT * FROM app_releases')->fetchAll();
usort($rows, fn($a, $b) => strcmp($a['platform'], $b['platform']) ?: (version_compare($b['version'], $a['version']) ?: ((int) $b['id'] <=> (int) $a['id'])));
return $rows;
}
function site_publish_app(string $platform, string $tmp, string $version, string $notes): array
{
$types = site_app_platforms();
if (!isset($types[$platform])) return ['ok' => false, 'error' => 'Unbekannte Plattform.'];
$kind = $types[$platform];
if (!site_is_upload($tmp)) return ['ok' => false, 'error' => 'Die hochgeladene Datei wurde nicht gefunden.'];
$size = (int) filesize($tmp);
if ($size < 1024 || $size > SITE_MAX_APP_BYTES) return ['ok' => false, 'error' => 'Die Datei muss zwischen 1 KB und 300 MB groß sein.'];
$head = (string) file_get_contents($tmp, false, null, 0, 2);
if ($head !== $kind['magic']) return ['ok' => false, 'error' => 'Dateityp passt nicht zur gewählten Plattform.'];
if (in_array($platform, ['wordpress', 'chrome'], true)) {
// Version steht im Paket (Plugin-Kopf bzw. manifest.json); eine eingetippte Nummer hat Vorrang
$inZip = site_zip_app_version($platform, $tmp);
if ($inZip === null) return ['ok' => false, 'error' => $platform === 'wordpress' ? 'Das ist kein WordPress-Plugin: In einer PHP-Datei fehlt der Kopf mit "Plugin Name:" und "Version:".' : 'Das ist keine Chrome-Erweiterung: manifest.json mit "version" fehlt.'];
if ($version === '') $version = $inZip;
}
if (!preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version)) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 3.4.0 eingeben.'];
$dir = site_storage() . '/apps/' . $platform;
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return ['ok' => false, 'error' => 'App-Speicher konnte nicht angelegt werden.'];
$file = bin2hex(random_bytes(24)) . '.' . $kind['ext'];
if (!site_move_upload($tmp, $dir . '/' . $file)) return ['ok' => false, 'error' => 'App-Datei konnte nicht gespeichert werden.'];
$sha = hash_file('sha256', $dir . '/' . $file);
try {
$stmt = site_db()->prepare('INSERT INTO app_releases(platform,version,file,sha256,size,notes,created_at) VALUES(?,?,?,?,?,?,?)');
$stmt->execute([$platform, $version, $file, $sha, $size, substr(trim($notes), 0, 2000), date('Y-m-d H:i:s')]);
} catch (PDOException $e) {
@unlink($dir . '/' . $file);
return ['ok' => false, 'error' => str_contains(strtolower($e->getMessage()), 'unique') ? 'Diese Version ist für diese Plattform bereits vorhanden.' : 'Release konnte nicht gespeichert werden.'];
}
return ['ok' => true, 'version' => $version];
}
/** Plugin bzw. Theme als „Empfohlen“ markieren oder die Markierung entfernen. */
function site_set_featured(int $itemId, bool $featured): bool
{
$stmt = site_db()->prepare('UPDATE items SET featured = ? WHERE id = ?');
$stmt->execute([$featured ? 1 : 0, $itemId]);
return $stmt->rowCount() > 0;
}
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'] ?? []))));
// Neue Berechtigungen, die DeskHub später einführt, dürfen die Webseite nicht blockieren: Form "bereich.name" genügt, den Inhalt prüft DeskHub beim Installieren
foreach ($perms as $p) if (!isset(site_known_permissions()[$p]) && !preg_match('/^[a-z]{2,12}\.[a-z_]{2,30}$/', $p)) return ['ok' => false, 'error' => "plugin.json: unbekannte Berechtigung \"{$p}\"."];
// Sprachpaket: "language": {"code":"en","name":"English"} (die Flagge und die Texte liegen im Paket, DeskHub liest sie dort)
$lang = null;
if (is_array($data['language'] ?? null)) {
$code = strtolower((string) ($data['language']['code'] ?? ''));
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code)) return ['ok' => false, 'error' => 'plugin.json: language.code fehlt oder ist ungültig (zum Beispiel en oder pt-br).'];
$lang = ['code' => $code, 'name' => mb_substr(trim((string) ($data['language']['name'] ?? $code)), 0, 40) ?: $code];
}
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, 'language' => $lang]];
}
// 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."];
}
// Kein Bild hochgeladen und noch keines vorhanden: ein Bild im Paket (vorschau.png / preview.png) verwenden
$fromZip = null;
if (($previewPath === null || !is_file($previewPath)) && !($item && !empty($item['preview']))) {
$fromZip = site_zip_preview($zipPath);
if ($fromZip !== null) { $previewPath = $fromZip['path']; $previewName = $fromZip['name']; }
}
$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));
if ($fromZip !== null) @unlink($fromZip['path']);
$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), language = ?, updated_at = ? WHERE id = ?')
->execute([$meta['name'], $meta['description'], $meta['author'], $preview, !empty($meta['language']) ? json_encode($meta['language'], JSON_UNESCAPED_UNICODE) : null, $now, $item['id']]);
$itemId = (int) $item['id'];
} else {
$pdo->prepare('INSERT INTO items (type, slug, name, description, author, preview, language, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$type, $meta['slug'], $meta['name'], $meta['description'], $meta['author'], $preview, !empty($meta['language']) ? json_encode($meta['language'], JSON_UNESCAPED_UNICODE) : null, $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];
}
/** Sucht im Paket ein Bild namens vorschau.* oder preview.* (im Hauptordner oder im einzigen Unterordner) und legt es als Temp-Datei ab. */
function site_zip_preview(string $zipPath): ?array
{
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) return null;
$found = null;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = str_replace(chr(92), '/', (string) $zip->getNameIndex($i));
if (preg_match('#^(?:[^/]+/)?(?:vorschau|preview)\.(png|jpe?g|webp)$#i', $name, $m)) {
$stat = $zip->statIndex($i);
if ($stat && $stat['size'] > 0 && $stat['size'] <= SITE_MAX_PREVIEW_BYTES) { $found = [$i, strtolower($m[1])]; break; }
}
}
if ($found === null) { $zip->close(); return null; }
$data = $zip->getFromIndex($found[0]);
$zip->close();
$tmp = tempnam(sys_get_temp_dir(), 'dhprev');
if ($data === false || $tmp === false || file_put_contents($tmp, $data) === false) return null;
if (!@getimagesize($tmp)) { @unlink($tmp); return null; }
return ['path' => $tmp, 'name' => 'vorschau.' . $found[1]];
}
/** Ändert oder entfernt das Vorschaubild eines Plugins/Themes, ohne eine neue Version zu veröffentlichen. */
function site_set_preview(int $itemId, ?string $tmp, ?string $name): array
{
$pdo = site_db();
$row = $pdo->prepare('SELECT type, slug, preview FROM items WHERE id = ?');
$row->execute([$itemId]);
$row = $row->fetch();
if (!$row) return ['ok' => false, 'error' => 'Nicht gefunden.'];
$new = null;
if ($tmp !== null) {
$ext = strtolower(pathinfo((string) $name, PATHINFO_EXTENSION));
if (!in_array($ext, ['png', 'jpg', 'jpeg', 'webp'], true) || !is_file($tmp) || !@getimagesize($tmp) || filesize($tmp) > SITE_MAX_PREVIEW_BYTES) {
return ['ok' => false, 'error' => 'Das Vorschaubild muss PNG, JPG oder WebP sein und darf höchstens 1 MB groß sein.'];
}
$new = $row['type'] . '-' . $row['slug'] . '-' . substr(hash_file('sha256', $tmp), 0, 8) . '.' . ($ext === 'jpeg' ? 'jpg' : $ext);
$dest = site_storage() . '/previews/' . $new;
if (!(is_uploaded_file($tmp) ? move_uploaded_file($tmp, $dest) : copy($tmp, $dest))) return ['ok' => false, 'error' => 'Das Bild konnte nicht gespeichert werden.'];
}
$pdo->prepare('UPDATE items SET preview = ?, updated_at = ? WHERE id = ?')->execute([$new, date('Y-m-d H:i:s'), $itemId]);
if (!empty($row['preview']) && $row['preview'] !== $new) site_remove_file(site_storage() . '/previews/' . basename((string) $row['preview']));
return ['ok' => true];
}
/** 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]);
// Der Eintrag samt Vorschaubild bleibt auch ohne Version erhalten (er ist dann unsichtbar): Wer die alte Version entfernt und eine neue hochlädt, muss das Bild nicht noch einmal hochladen.
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 featured DESC, 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 (!empty($item['featured'])) $entry['featured'] = true;
if ($type === 'plugin' && !empty($item['language']) && is_array($l = json_decode((string) $item['language'], true))) $entry['language'] = ['code' => (string) $l['code'], 'name' => (string) $l['name']];
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) . ",
];
";
}