Files
2026-11-05 21:21:48 +00:00

169 lines
8.6 KiB
PHP

<?php
declare(strict_types=1);
/**
* Sicherung des System-Updates.
*
* Ein Update ersetzt Programmcode. Würde das Konto beim Update-Server übernommen, könnten sonst Schadcode-Updates an
* alle Installationen gehen. Deshalb prüft DeskHub bei hinterlegtem Herausgeber-Schlüssel (includes/catalog_defaults.php)
* eine Signatur: Der Herausgeber signiert jedes Release-Paket mit seinem privaten Schlüssel (tools/release-sign.php),
* ohne passende Signatur wird nichts installiert. Der Schlüssel ist derselbe wie bei Plugins und Themes.
*
* Release-Dateien (Anhänge des Releases): deskhub-<Version>.zip und deskhub-<Version>.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<array<string,mixed>>
*/
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];
}