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