.zip und deskhub-.zip.sig */ /** Wird für Updates eine Herausgeber-Signatur verlangt? Ja, sobald ein Herausgeber-Schlüssel eingebaut ist. */ function update_signature_required(): bool { return function_exists('plugin_publisher_key') && plugin_publisher_key() !== ''; } /** Zu signierende Nachricht: bindet Signatur an Version und Dateiinhalt. */ function update_signature_message(string $tag, string $sha256): string { return "deskhub-update\n" . ltrim(trim($tag), 'vV') . "\n" . strtolower($sha256); } /** Prüft die Signatur eines heruntergeladenen Pakets. Gibt null bei Erfolg zurück, sonst eine Fehlermeldung. */ function update_verify_package(string $tag, string $zipPath, string $signature): ?string { $signature = trim($signature); if ($signature === '') return 'Dem Release fehlt die Signatur des Herausgebers.'; $key = plugin_publisher_key(); if ($key === '') return 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.'; $sha = hash_file('sha256', $zipPath); if ($sha === false) return 'Das Paket konnte nicht gelesen werden.'; if (!plugin_verify_signature(update_signature_message($tag, $sha), $signature, $key)) { return 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber. Es wird nicht installiert.'; } return null; } /** * Sucht in den Anhängen eines Releases (Gitea-API) das Paket und die Signatur. * * @return array{zip: ?string, sig: ?string} Download-Adressen oder null */ function update_find_assets(array $release, string $tag): array { $version = ltrim(trim($tag), 'vV'); $names = ['zip' => "deskhub-{$version}.zip", 'sig' => "deskhub-{$version}.zip.sig"]; $out = ['zip' => null, 'sig' => null]; foreach ((array) ($release['assets'] ?? []) as $asset) { $name = (string) ($asset['name'] ?? ''); $url = (string) ($asset['browser_download_url'] ?? ''); foreach ($names as $kind => $expected) { if ($name === $expected && str_starts_with($url, 'https://')) $out[$kind] = $url; } } return $out; } /** * Zugangstoken für den Update-Server (nur nötig, wenn das Repository privat ist; Gitea antwortet sonst mit "404"). * Quelle: config/config.php ('update' => ['token' => '…']) hat Vorrang, sonst das in den Einstellungen der Update-Seite * gespeicherte (verschlüsselt). Gibt [Token, Quelle] zurück, Quelle 'config', 'einstellungen' oder ''. * * @return array{0: string, 1: string} */ function update_token_info(): array { $fromConfig = trim((string) (config()['update']['token'] ?? '')); if ($fromConfig !== '') return [$fromConfig, 'config']; $stored = (string) get_setting('update_token', ''); $fromSettings = $stored !== '' ? trim(decrypt_secret($stored)) : ''; return $fromSettings !== '' ? [$fromSettings, 'einstellungen'] : ['', '']; } /** Kopfzeilen für Abrufe beim Update-Server; ein Zugangstoken ist nur nötig, wenn das Repository nicht öffentlich ist. */ function update_http_headers(): array { [$token] = update_token_info(); return $token !== '' ? ['Authorization: token ' . $token] : []; } /** * Gehört diese Datei aus dem Update-Paket nicht auf die Installation? Plugins, Uploads, Backups und App-Dateien gehören dem System, die Plugin-Webseite * und Entwickler-Ordner gehören nicht auf Kunden-Installationen. Ausnahme: Das Handbuch (docs/DeskHub-Handbuch.pdf) kommt mit jedem Update (Adminportal → Hilfe). */ function update_path_is_skipped(string $relNorm): bool { // Die Konfiguration gehört der Installation: nichts aus config/ wird je überschrieben oder neu angelegt (auch nicht bei anderer Groß-/Kleinschreibung) if (preg_match('#^(\./)*config(/|$)#i', $relNorm) || preg_match('#(^|/)(\.env|config\.php)$#i', $relNorm) && !str_contains($relNorm, '/')) return true; if ($relNorm === 'docs/DeskHub-Handbuch.pdf' || $relNorm === 'tools/fix-permissions.sh') return false; return (bool) (preg_match('#^(plugins|uploads|backups|app_downloads)/#', $relNorm) || preg_match('#^(plugin-webseite|tools|tests|docs|desktop|mobile|theme-packages|vorschaubilder|\.gitea|\.github)/#', $relNorm)); } // ─── Updates von der Plugin-Webseite ───────────────────────────────────────── /** * Adresse der Update-Schnittstelle auf der offiziellen Plugin-Webseite (app-release.php neben dem Katalog) oder '' ohne offiziellen Katalog. * Der Zugangsschlüssel wird nur an diesen Server geschickt (catalog_access_headers). */ function update_publisher_endpoint(array $query): string { $base = function_exists('catalog_defaults') ? (string) (catalog_defaults()['plugins_url'] ?? '') : ''; $parts = parse_url($base); if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return ''; $path = rtrim(str_replace('\\', '/', dirname((string) ($parts['path'] ?? '/'))), '/'); return 'https://' . $parts['host'] . (isset($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/app-release.php?' . http_build_query(['platform' => 'server'] + $query); } /** * Die auf der Plugin-Webseite veröffentlichten System-Updates, neueste zuerst. Leer, wenn die Seite nicht antwortet oder nichts * veröffentlicht ist (dann greift die Ausweichquelle). Jeder Eintrag hat dieselben Felder wie ein Gitea-Release (tag_name, published_at, body) * plus sha256, size, signature und source = 'publisher'. * * @return list> */ function update_publisher_releases(): array { static $cache = null; if ($cache !== null && !isset($GLOBALS['deskhub_http_get'])) return $cache; $url = update_publisher_endpoint(['list' => 1]); $out = []; if ($url !== '') { [$status, $body] = plugin_http_get($url, 1048576, 8); $data = $status === 200 && is_string($body) ? json_decode($body, true) : null; foreach ((array) ($data['releases'] ?? []) as $r) { $v = (string) ($r['version'] ?? ''); if (!is_array($r) || !preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $v) || !preg_match('/^[0-9a-f]{64}$/i', (string) ($r['sha256'] ?? ''))) continue; $out[] = ['tag_name' => $v, 'published_at' => (string) ($r['created_at'] ?? ''), 'body' => (string) ($r['notes'] ?? ''), 'sha256' => strtolower((string) $r['sha256']), 'size' => (int) ($r['size'] ?? 0), 'signature' => (string) ($r['signature'] ?? ''), 'source' => 'publisher']; } usort($out, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name'])); } return $cache = $out; } /** Ein Eintrag aus update_publisher_releases() zu einem Versions-Tag (mit oder ohne „v“) oder null. */ function update_publisher_find(string $tag): ?array { $v = ltrim(trim($tag), 'vV'); foreach (update_publisher_releases() as $r) if ($r['tag_name'] === $v) return $r; return null; } /** * Lädt das Paket einer Version von der Plugin-Webseite und prüft Prüfsumme und (bei eingebautem Herausgeber-Schlüssel) Signatur. * * @return array{0:?string,1:?string} [Pfad zur ZIP-Datei, Fehlertext]; bei Fehler ist die Datei schon gelöscht */ function update_publisher_fetch(array $release): array { $url = update_publisher_endpoint(['version' => $release['tag_name'], 'download' => 1]); if ($url === '') return [null, 'Die Plugin-Webseite ist nicht eingetragen.']; [$status, $body, $err] = plugin_http_get($url, 300 * 1048576, 180); if ($status !== 200 || !is_string($body)) return [null, 'Download von der Plugin-Webseite fehlgeschlagen' . ($err ? ': ' . $err : " (HTTP $status).")]; if (!hash_equals((string) $release['sha256'], hash('sha256', $body))) return [null, 'Die Prüfsumme des Pakets stimmt nicht. Es wird nicht installiert.']; $tmp = tempnam(sys_get_temp_dir(), 'dh_') . '.zip'; file_put_contents($tmp, $body); if (update_signature_required()) { $problem = update_verify_package((string) $release['tag_name'], $tmp, (string) $release['signature']); if ($problem !== null) { @unlink($tmp); return [null, $problem]; } } return [$tmp, null]; }