From 53f8bf0577cf07b5d4f56a6aef72627c10f7cd6e Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sun, 1 Nov 2026 11:17:07 +0000 Subject: [PATCH] Upload via GUI (32 Dateien) --- plugin-webseite/README.md | 2 + plugin-webseite/admin.php | 36 +- plugin-webseite/app-release.php | 5 +- plugin-webseite/assets/admin.js | 9 + plugin-webseite/assets/style.css | 5 + plugin-webseite/inc/lib.php | 1053 ++++++++++++++++-------------- plugin-webseite/preview.php | 7 +- 7 files changed, 626 insertions(+), 491 deletions(-) diff --git a/plugin-webseite/README.md b/plugin-webseite/README.md index 723274f0..c5f34fdf 100644 --- a/plugin-webseite/README.md +++ b/plugin-webseite/README.md @@ -7,6 +7,8 @@ Eigenständige Webseite, von der DeskHub-Installationen Plugins und Kundenportal - Verwaltung mit allen Paketen, Versionen, Signaturstatus und Download-Zähler (keine öffentliche Übersichtsseite: Die Startseite führt zur Verwaltung) - Katalog für DeskHub: `catalog.php?type=plugins` und `catalog.php?type=themes` - Verwaltung (`admin.php`): Pakete hochladen, Versionen entfernen, Vorschaubilder +- **Vorschaubild nur einmal:** Das Bild eines Plugins oder Themes bleibt bei jeder neuen Version erhalten, auch wenn du die alte Version vorher entfernst. Beim Eintrag in der Verwaltung lässt es sich ersetzen oder entfernen. Liegt im ZIP eine Datei `vorschau.png`, wird sie beim ersten Veröffentlichen automatisch verwendet. +- **WordPress-Plugin und Chrome-Erweiterung** (Tab „Apps“): ZIP hochladen, die Versionsnummer wird aus dem Plugin-Kopf (`Version:`) bzw. der `manifest.json` gelesen (eine eingetippte Nummer hat Vorrang). Abruf der neuesten Version: `app-release.php?platform=wordpress` oder `platform=chrome` (mit `&download=1` als Datei). - Prüft jedes Paket beim Hochladen (Dateitypen, Pfade, `plugin.json`, Berechtigungen) und **signiert es automatisch** mit eurem privaten Schlüssel - Veröffentlichte Versionen sind unveränderlich: Für Änderungen gibt es eine neue Version diff --git a/plugin-webseite/admin.php b/plugin-webseite/admin.php index 28e614d0..9e071585 100644 --- a/plugin-webseite/admin.php +++ b/plugin-webseite/admin.php @@ -84,7 +84,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$tooBig) { $error = 'App-Upload fehlgeschlagen (Code ' . ($upload['error'] ?? '?') . '). Prüft upload_max_filesize, post_max_size und die Servergrenze.'; } else { $res = site_publish_app((string) ($_POST['platform'] ?? ''), (string) $upload['tmp_name'], trim((string) ($_POST['version'] ?? '')), (string) ($_POST['notes'] ?? '')); - if ($res['ok']) $message = 'App-Version veröffentlicht.'; + if ($res['ok']) $message = 'App-Version ' . $res['version'] . ' veröffentlicht.'; else $error = $res['error']; } } elseif ($action === 'delete_app') { @@ -107,6 +107,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$tooBig) { } else { $error = 'Ungültige Installation.'; } + } elseif ($action === 'set_preview' || $action === 'remove_preview') { + $img = $_FILES['preview'] ?? []; + if ($action === 'set_preview' && ($img['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + $error = 'Bitte ein Bild auswählen (PNG, JPG oder WebP, höchstens 1 MB).'; + } else { + $res = site_set_preview((int) ($_POST['id'] ?? 0), $action === 'set_preview' ? (string) $img['tmp_name'] : null, $action === 'set_preview' ? (string) $img['name'] : null); + if ($res['ok']) $message = $action === 'set_preview' ? 'Vorschaubild geändert. Es bleibt bei weiteren Versionen erhalten.' : 'Vorschaubild entfernt.'; + else $error = $res['error']; + } } elseif ($action === 'feature' || $action === 'unfeature') { $message = site_set_featured((int) ($_POST['id'] ?? 0), $action === 'feature') ? ($action === 'feature' ? 'Als „Empfohlen“ markiert. DeskHub zeigt es nach der nächsten Katalog-Aktualisierung (spätestens in wenigen Minuten).' : 'Empfehlung entfernt.') : null; $error = $message ? null : 'Eintrag nicht gefunden.'; @@ -144,7 +153,7 @@ site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $l null, 'windows' => null, 'minecraft' => null]; + $latestApp = array_fill_keys(array_keys(site_app_platforms()), null); foreach ($appReleases as $r) if (array_key_exists($r['platform'], $latestApp) && $latestApp[$r['platform']] === null) $latestApp[$r['platform']] = $r; $pushSa = push_service_account(); $pushRows = push_db()->query('SELECT * FROM push_installs ORDER BY COALESCE(last_used_at, created_at) DESC LIMIT 200')->fetchAll(); @@ -200,7 +209,8 @@ site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $l - + +

Bei einem Update von Plugin oder Theme bleibt das vorhandene Bild erhalten: einfach weglassen. Ändern oder entfernen kannst du es unten bei dem Eintrag.

@@ -218,6 +228,12 @@ site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $l 🌐 () (int) $it['id']], !empty($it['featured']) ? 'Empfehlung entfernen' : 'Als Empfehlung markieren', 'ghost sm') ?> +
+ Vorschaubild von <?= site_h($it['name']) ?>kein Bild +
+
+ (int) $it['id']], 'Bild entfernen', 'link danger', 'Vorschaubild wirklich entfernen?'); ?> +
@@ -232,24 +248,24 @@ site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $l -

Apps

Windows-, Android-App und Minecraft-Plugin veröffentlichen. Die Apps zeigen ihren Nutzern automatisch „Update verfügbar“, sobald hier eine höhere Version liegt.

+

Apps

Windows-, Android-App, Minecraft-Plugin, WordPress-Plugin und Chrome-Erweiterung veröffentlichen. Die Apps zeigen ihren Nutzern automatisch „Update verfügbar“, sobald hier eine höhere Version liegt.

Neue App-Version hochladen

Windows-Setups dürfen bis 300 MB groß sein (PHP-Grenzen upload_max_filesize / post_max_size beachten).

- - + +
- +
-
- 'Windows', 'android' => 'Android', 'minecraft' => 'Minecraft'] as $pf => $pl): $mine = array_values(array_filter($appReleases, fn($r) => $r['platform'] === $pf)); $cur = $latestApp[$pf]; ?> +
+ $p['label'], site_app_platforms()) as $pf => $pl): $mine = array_values(array_filter($appReleases, fn($r) => $r['platform'] === $pf)); $cur = $latestApp[$pf]; ?>
-

'Setup (EXE)', 'android' => 'Installationsdatei (APK)', 'minecraft' => 'Plugin (JAR)'][$pf] ?>

+

aktuell ' . site_h($cur['version']) . '' : 'noch nichts veröffentlicht' ?>
Noch keine Version veröffentlicht.
Downloads
diff --git a/plugin-webseite/app-release.php b/plugin-webseite/app-release.php index 300e32f0..9dbf18b2 100644 --- a/plugin-webseite/app-release.php +++ b/plugin-webseite/app-release.php @@ -29,9 +29,10 @@ if (($_GET['download'] ?? '') !== '1') { exit; } -$filename = ['android' => 'DeskHub.apk', 'windows' => 'DeskHub-Setup.exe', 'minecraft' => 'DeskHub-Minecraft.jar'][$platform]; +$kind = site_app_platforms()[$platform]; +$filename = $kind['file']; while (ob_get_level() > 0) @ob_end_clean(); -header('Content-Type: ' . ['android' => 'application/vnd.android.package-archive', 'windows' => 'application/vnd.microsoft.portable-executable', 'minecraft' => 'application/java-archive'][$platform]); +header('Content-Type: ' . $kind['mime']); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . filesize($path)); header('X-Content-Type-Options: nosniff'); diff --git a/plugin-webseite/assets/admin.js b/plugin-webseite/assets/admin.js index 41955623..b26fb279 100644 --- a/plugin-webseite/assets/admin.js +++ b/plugin-webseite/assets/admin.js @@ -7,3 +7,12 @@ function toggleTheme() { document.querySelectorAll('.theme-only').forEach(function (el) { el.style.display = type.value === 'theme' ? '' : 'none'; }); } if (type) { type.addEventListener('change', toggleTheme); toggleTheme(); } + +// Bei ZIP-Paketen (WordPress, Chrome) kann die Versionsnummer leer bleiben: sie wird aus dem Paket gelesen +var platform = document.getElementById('platform'); +function toggleVersion() { + var zip = platform.options[platform.selectedIndex].dataset.zip === '1'; + document.getElementById('appversion').required = !zip; + document.getElementById('versionhint').hidden = !zip; +} +if (platform) { platform.addEventListener('change', toggleVersion); toggleVersion(); } diff --git a/plugin-webseite/assets/style.css b/plugin-webseite/assets/style.css index 6bc833f8..75fea89e 100644 --- a/plugin-webseite/assets/style.css +++ b/plugin-webseite/assets/style.css @@ -132,3 +132,8 @@ details.card p { overflow-wrap: anywhere; } .cols.two td.actions { padding-right: 12px; } .cols.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } @media (max-width: 1100px) { .cols.three { grid-template-columns: 1fr; } } + +.previewrow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid var(--line); } +.thumb-sm { width: 96px; height: 60px; object-fit: cover; border-radius: 8px; background: var(--line); } +.thumb-sm.none { display: inline-flex; align-items: center; justify-content: center; font-size: 12px; border: 1px dashed var(--line); opacity: .7; } +.previewrow input[type=file] { max-width: 230px; } diff --git a/plugin-webseite/inc/lib.php b/plugin-webseite/inc/lib.php index 719f52bf..52028dce 100644 --- a/plugin-webseite/inc/lib.php +++ b/plugin-webseite/inc/lib.php @@ -1,478 +1,575 @@ - '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\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)"); - $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; -} - -/** Latest published app release per platform: the HIGHEST version, not the one uploaded last. */ -function site_app_release(string $platform): ?array -{ - if (!in_array($platform, ['android', 'windows', 'minecraft'], true)) 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 = ['android' => ['apk', "PK"], 'windows' => ['exe', "MZ"], 'minecraft' => ['jar', "PK"]]; - if (!isset($types[$platform])) return ['ok' => false, 'error' => 'Unbekannte Plattform.']; - if (!preg_match('/^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version)) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 3.4.0 eingeben.']; - if (!is_uploaded_file($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 !== $types[$platform][1]) return ['ok' => false, 'error' => 'Dateityp passt nicht zur gewählten Plattform.']; - $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)) . '.' . $types[$platform][0]; - if (!move_uploaded_file($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]; -} - -/** 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."]; - } - - $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), 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]; -} - -/** 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 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 " " . 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', + '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\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)"); + $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 " " . 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) . ", +]; +"; +} diff --git a/plugin-webseite/preview.php b/plugin-webseite/preview.php index 9b34d063..aa4ac08a 100644 --- a/plugin-webseite/preview.php +++ b/plugin-webseite/preview.php @@ -2,7 +2,12 @@ declare(strict_types=1); /** Liefert Vorschaubilder aus dem geschützten Speicher aus. */ require_once __DIR__ . '/inc/lib.php'; -site_require_access(); +// DeskHub kommt mit dem Zugangsschlüssel; die Verwaltung (angemeldeter Administrator) sieht die Bilder in ihrer Übersicht +if (!site_access_ok()) { + session_name('plugin_site_admin'); + session_start(['read_and_close' => true]); + if (empty($_SESSION['admin'])) site_require_access(); +} $file = basename((string) ($_GET['file'] ?? '')); $path = site_storage() . '/previews/' . $file;