Files
deskhub/DeskHub-Webseite/staff/update.php
T
2026-11-05 21:21:52 +00:00

675 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/backup.php';
$staff = require_admin();
$pdo = get_db();
$errors = [];
const UPDATE_REPO_API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/deskhub';
/** Lädt eine https-Adresse (Weiterleitungen nur auf https). Gibt [Status, Inhalt, Fehlertext] zurück. */
function update_download(string $url, array $headers, int $timeout): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => $headers,
]);
$data = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return [$code, is_string($data) ? $data : '', $err];
}
/** Hinweis für die Fehlersuche bei Schreibrechten: unter welchem Benutzer läuft PHP, wem gehört die Programmdatei. */
function update_owner_hint(string $sample = ''): string
{
if (!function_exists('posix_geteuid')) return '';
$detail = '';
if ($sample !== '') {
$path = realpath(__DIR__ . '/..') . '/' . $sample;
$dir = dirname($path);
$nm = fn($path) => (($o = @fileowner($path)) !== false) ? (@posix_getpwuid($o)['name'] ?? (string) $o) : '?';
$detail = ' Beispiel ' . $sample . ' (Besitzer ' . $nm($path) . ', Ordner-Besitzer ' . $nm($dir) . '): ' . (is_file($path) ? 'Rechte ' . substr(sprintf('%o', (int) @fileperms($path)), -4) . ', beschreibbar: ' . (is_writable($path) ? 'ja' : 'nein') : 'Datei fehlt')
. (!empty($GLOBALS['update_swap_reasons']) ? ' Ordner neu anlegen nicht möglich: ' . implode('; ', array_map(fn($k, $v) => "$k: $v", array_keys($GLOBALS['update_swap_reasons']), $GLOBALS['update_swap_reasons'])) . '.' : '')
. '; Ordner Rechte ' . substr(sprintf('%o', (int) @fileperms($dir)), -4) . ', beschreibbar: ' . (is_writable($dir) ? 'ja' : 'nein') . '.';
}
$me = @posix_getpwuid(posix_geteuid())['name'] ?? (string) posix_geteuid();
$owner = @fileowner(__DIR__ . '/../version.php');
$ownerName = $owner !== false ? (@posix_getpwuid($owner)['name'] ?? (string) $owner) : '?';
return $detail . " Der Webserver läuft als „{$me}“, die Programmdateien gehören „{$ownerName}“. Der Webserver-Benutzer muss die Dateien ändern dürfen (Besitzer auf „{$me}“ ändern oder Schreibrechte für die Gruppe geben; beim Hoster im Dateimanager oder per Support).";
}
/** Kopiert einen Ordner samt Inhalt. */
function update_copy_tree(string $from, string $to): bool
{
if (!is_dir($to) && !@mkdir($to, 0755)) return false;
foreach (scandir($from) ?: [] as $e) {
if ($e === '.' || $e === '..') continue;
if (is_dir("$from/$e") ? !update_copy_tree("$from/$e", "$to/$e") : !@copy("$from/$e", "$to/$e")) return false;
}
return true;
}
/** Löscht einen Ordner samt Inhalt, soweit erlaubt. */
function update_remove_tree(string $dir): void
{
foreach (scandir($dir) ?: [] as $e) {
if ($e === '.' || $e === '..') continue;
is_dir("$dir/$e") && !is_link("$dir/$e") ? update_remove_tree("$dir/$e") : @unlink("$dir/$e");
}
@rmdir($dir);
}
/**
* Der Webserver darf in einen Ordner nicht schreiben (er gehört einem anderen Benutzer, z. B. root): Dann wird der Ordner samt Inhalt neu angelegt,
* und zwar der oberste nicht beschreibbare Ordner unterhalb des ersten beschreibbaren. Das geht, solange der DeskHub-Hauptordner dem Webserver gehört:
* Umbenennen und Neuanlegen im beschreibbaren Elternordner ist unabhängig vom Besitzer der Dateien darin. Der Inhalt wird kopiert, der alte Ordner
* gelöscht oder, wenn das nicht erlaubt ist, als „….alt-…“ stehen gelassen (und per .htaccess gesperrt).
*/
function update_swap_dir(string $dir): bool
{
$root = (string) realpath(__DIR__ . '/..');
$name = basename($dir);
if (!is_dir($dir) || is_link($dir)) return false;
$cand = $dir;
while (!is_writable(dirname($cand))) {
if ($cand === $root || dirname($cand) === $cand) {
$GLOBALS['update_swap_reasons'][$name] = 'auch der DeskHub-Hauptordner ist für den Webserver nicht beschreibbar';
return false;
}
$cand = dirname($cand);
}
if ($cand === $root) return false;
// Daten- und Konfigurationsordner werden nie umgesetzt (Größe, laufende Zugriffe)
$top = explode('/', trim(str_replace('\\', '/', substr($cand, strlen($root))), '/'))[0];
if (in_array($top, ['config', 'database', 'uploads', 'backups', 'plugins', 'app_downloads'], true)) {
$GLOBALS['update_swap_reasons'][$name] = "Ordner „{$top}“ gehört einem anderen Benutzer und wird aus Sicherheitsgründen nicht umgesetzt";
return false;
}
$old = $cand . '.alt-' . date('Ymd-His');
if (!@rename($cand, $old)) {
$GLOBALS['update_swap_reasons'][$name] = 'Umbenennen nicht erlaubt (' . trim((string) (error_get_last()['message'] ?? '')) . ')';
return false;
}
if (!update_copy_tree($old, $cand)) {
update_remove_tree($cand);
@rename($old, $cand);
$GLOBALS['update_swap_reasons'][$name] = 'Inhalt konnte nicht kopiert werden';
return false;
}
update_remove_tree($old);
if (is_dir($old)) {
$GLOBALS['update_leftovers'][] = ltrim(str_replace('\\', '/', substr($old, strlen($root))), '/');
update_block_leftovers($root);
}
return true;
}
/** Trägt in die .htaccess im Hauptordner eine Regel ein, die übrig gebliebene „….alt-…“-Ordner nicht ausliefert (falls noch nicht vorhanden). */
function update_block_leftovers(string $root): void
{
$file = $root . '/.htaccess';
$rule = "\n# DeskHub: übrig gebliebene alte Ordner eines Updates nicht ausliefern\nRewriteCond %{REQUEST_URI} \.alt-[0-9]{8}-[0-9]{6}(/|$) [NC]\nRewriteRule ^ - [F,L]\n";
$cur = is_file($file) ? (string) @file_get_contents($file) : '';
if ($cur !== '' && !str_contains($cur, '.alt-[0-9]{8}')) update_write_file($file, $cur . $rule);
}
/** Schreibt eine Datei. Gelingt das nicht (z. B. gehört die Datei dem FTP-Benutzer), wird sie über eine Hilfsdatei im selben Ordner ersetzt. */
function update_write_file(string $dest, string $data): bool
{
if (@file_put_contents($dest, $data) !== false) return true;
// Selbstheilung: Gehören Datei oder Ordner dem Webserver-Benutzer, aber ohne Schreibrecht, wird das Recht gesetzt (fremde Dateien kann PHP nicht ändern)
$dir = dirname($dest);
if (function_exists('posix_geteuid')) {
$me = posix_geteuid();
if (is_file($dest) && @fileowner($dest) === $me && !is_writable($dest)) @chmod($dest, 0644);
if (@fileowner($dir) === $me && !is_writable($dir)) @chmod($dir, 0755);
if (@file_put_contents($dest, $data) !== false) return true;
}
$tmp = $dest . '.neu' . bin2hex(random_bytes(3));
if (@file_put_contents($tmp, $data) === false) return false;
@unlink($dest);
if (@rename($tmp, $dest)) return true;
@unlink($tmp);
// Letzter Ausweg: Ordner gehört einem anderen Benutzer, der übergeordnete ist beschreibbar → Ordner neu anlegen
if (!is_writable($dir) && update_swap_dir($dir)) return @file_put_contents($dest, $data) !== false;
return false;
}
// ── ZIP installieren ─────────────────────────────────────────────────────────
function do_install(string $zipPath): array
{
$zip = new ZipArchive();
$openResult = $zip->open($zipPath);
if ($openResult !== true) {
return ['ok' => false, 'error' => 'ZIP konnte nicht geöffnet werden (Code: ' . $openResult . ', Datei: ' . $zipPath . ', Größe: ' . filesize($zipPath) . ')'];
}
$rootDir = str_replace('/', DIRECTORY_SEPARATOR, realpath(__DIR__ . '/..'));
$protected = [
'config/config.php',
'database/deskhub.sqlite',
'database/deskhub.db',
'.env',
'version.php',
'includes/bootstrap.php', // Bootstrap nicht mid-request überschreiben
];
// Gitea-Prefix erkennen (z.B. "deskhub/")
$prefix = '';
for ($i = 0; $i < min($zip->numFiles, 5); $i++) {
$n = $zip->getNameIndex($i);
if (substr($n, -1) === '/' && substr_count(rtrim($n, '/'), '/') === 0) {
$prefix = $n;
break;
}
}
$extracted = 0;
$deferred = null;
$unchanged = 0;
$failed = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
// Verzeichnisse überspringen
if (substr($name, -1) === '/') continue;
// Prefix entfernen
if ($prefix) {
if (strpos($name, $prefix) !== 0) continue;
$rel = substr($name, strlen($prefix));
} else {
$rel = $name;
}
if ($rel === '') continue;
// Pfad prüfen: kein Verlassen des Installationsordners (Zip-Slip), keine absoluten Pfade
$relNorm = str_replace(chr(92), '/', $rel);
if ($relNorm[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $relNorm) || preg_match('#^[A-Za-z]:#', $relNorm) || strpos($relNorm, "\0") !== false) {
$failed[] = $rel;
continue;
}
// Die Plugin-Webseite und Entwickler-Werkzeuge gehören nicht auf Kunden-Installationen; Plugins, App-Dateien, Uploads und Backups gehören dem System und werden von Updates nie angefasst
// Die Plugin-Webseite und Entwickler-Werkzeuge gehören nicht auf Kunden-Installationen; Plugins, App-Dateien, Uploads und Backups gehören dem System und werden von Updates nie angefasst
if (update_path_is_skipped($relNorm)) continue;
// Geschützte Dateien überspringen
$skip = false;
foreach ($protected as $p) {
if ($rel === $p || str_replace('/', DIRECTORY_SEPARATOR, $rel) === str_replace('/', DIRECTORY_SEPARATOR, $p)) {
$skip = true;
break;
}
}
if ($skip) continue;
// Windows-Pfad
$dest = $rootDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
$dir = dirname($dest);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$data = $zip->getFromIndex($i);
if ($data === false) continue;
// Zugriffsregeln (.htaccess) gehören dem Server: Hoster tragen dort eigene Zeilen ein (z. B. die PHP-Version). Eine vorhandene wird nie überschrieben.
if (strtolower(basename($relNorm)) === '.htaccess' && is_file($dest)) {
$unchanged++;
continue;
}
// Unveränderte Dateien brauchen nicht geschrieben zu werden (gehören sie dem FTP-Benutzer, wäre das sonst ein unnötiger Fehler)
if (is_file($dest) && filesize($dest) === strlen($data) && hash_equals(md5($data), (string) md5_file($dest))) {
$unchanged++;
continue;
}
// Diese Seite wird zuletzt ersetzt: Das laufende Skript ist schon geladen, so mischt sich nichts Altes und Neues im selben Aufruf
if ($relNorm === 'staff/update.php') {
$deferred = [$dest, $data, $rel];
continue;
}
if (update_write_file($dest, $data)) {
$extracted++;
} else {
$failed[] = $rel;
}
}
if ($deferred) {
if (is_file($deferred[0]) && md5_file($deferred[0]) === md5($deferred[1])) $unchanged++;
elseif (update_write_file($deferred[0], $deferred[1])) $extracted++;
else $failed[] = $deferred[2];
}
$zip->close();
if ($extracted === 0 && $unchanged === 0) {
$msg = 'Keine Dateien extrahiert!';
if ($prefix) $msg .= ' Prefix: "' . $prefix . '"';
if ($failed) $msg .= ' Fehlgeschlagen: ' . implode(', ', array_slice($failed, 0, 3));
return ['ok' => false, 'error' => $msg];
}
return ['ok' => true, 'extracted' => $extracted, 'unchanged' => $unchanged, 'failed' => $failed];
}
/**
* Installiert ein geprüftes Paket: Backup, Dateien, Datenbank-Update, Versionsnummer. Bei Erfolg Weiterleitung, sonst die Fehlertexte.
*
* @return list<string>
*/
function update_finish_install(string $tmp, string $tagName, bool $signatureChecked, array $staff, string $sourceLabel): array
{
$errors = [];
$failedNote = '';
$bk = create_backup();
$res = do_install($tmp);
@unlink($tmp);
if (!$res['ok']) return [$res['error']];
// Schema-Migrationen nach Update einspielen
require_once __DIR__ . '/../database/migrate.php';
$mig = run_migrations(get_db());
$migNote = $mig['applied'] > 0 ? " · {$mig['applied']} Migration(en) eingespielt" : '';
if ($mig['errors']) $errors = array_merge($errors, $mig['errors']);
audit_log($staff['id'], "Auto-Update $tagName ($sourceLabel): {$res['extracted']} Dateien{$migNote}" . ($signatureChecked ? ' (Signatur geprüft)' : ''));
// Versionsnummer in version.php aktualisieren
$newVer = ltrim($tagName, 'v');
$verFile = realpath(__DIR__ . '/../version.php');
$versionOk = false;
if ($verFile && $newVer) {
$vc = file_get_contents($verFile);
$vc = preg_replace("/define\('DESKHUB_VERSION',\s*'[^']+'\)/", "define('DESKHUB_VERSION', '$newVer')", $vc);
$versionOk = update_write_file($verFile, $vc) && str_contains((string) @file_get_contents($verFile), "'$newVer'");
}
if (!$versionOk) $errors[] = "Die Dateien wurden installiert, aber version.php konnte nicht auf $newVer geändert werden (keine Schreibrechte). Bitte die Datei version.php im Hauptordner von Hand auf Version $newVer setzen oder ihre Rechte prüfen (der Webserver-Benutzer muss sie ändern dürfen).";
if ($res['failed']) $failedNote = ' ⚠ ' . count($res['failed']) . ' Datei(en) konnten nicht ersetzt werden und blieben auf dem alten Stand (sie gehören einem anderen Benutzer, nur der Hoster kann das ändern): ' . implode(', ', array_slice($res['failed'], 0, 8)) . (count($res['failed']) > 8 ? ' …' : '') . update_owner_hint((string) $res['failed'][0]);
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
if ($errors) return $errors;
flash('success', "✅ Update auf $newVer: {$res['extracted']} Dateien." . ($signatureChecked ? ' Signatur geprüft.' : '') . (!empty($GLOBALS['update_leftovers']) ? ' Alte Ordner, die sich nicht löschen ließen (bitte per FTP entfernen): ' . implode(', ', $GLOBALS['update_leftovers']) . '.' : '') . ($bk['ok'] ? " Backup: {$bk['filename']}" : '') . $failedNote);
redirect('/staff/update.php');
return [];
}
// ── POST ─────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF prüfen ohne Token-Rotation (wegen Datei-Upload multipart)
$csrfToken = $_POST['csrf_token'] ?? '';
if (!$csrfToken || !hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) {
http_response_code(403);
die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.');
}
$action = $_POST['action'] ?? '';
// Zugangstoken für den Update-Server speichern oder entfernen (nur Admins erreichen diese Seite)
if ($action === 'save_update_token') {
$newToken = trim((string) ($_POST['update_token'] ?? ''));
if ($newToken === '' || !preg_match('/^[A-Za-z0-9_\-.]{20,200}$/', $newToken)) {
$errors[] = 'Das Token sieht ungültig aus (erwartet: 20 bis 200 Zeichen aus Buchstaben, Ziffern, _ - .). Bitte genau so einfügen, wie Gitea es anzeigt.';
} else {
set_setting('update_token', encrypt_secret($newToken));
audit_log($staff['id'], 'Zugangstoken für den Update-Server gespeichert');
flash('success', 'Zugangstoken gespeichert. Die Release-Liste wird jetzt damit geladen.');
redirect('/staff/update.php');
}
}
if ($action === 'remove_update_token') {
set_setting('update_token', '');
audit_log($staff['id'], 'Zugangstoken für den Update-Server entfernt');
flash('success', 'Zugangstoken entfernt.');
redirect('/staff/update.php');
}
// Manueller Upload
if ($action === 'upload_update') {
$f = $_FILES['update_zip'] ?? [];
if (($f['error'] ?? 99) !== UPLOAD_ERR_OK) {
$errors[] = 'Upload-Fehler Code: ' . ($f['error'] ?? 'unbekannt');
} elseif (strtolower(pathinfo($f['name'] ?? '', PATHINFO_EXTENSION)) !== 'zip') {
$errors[] = 'Nur ZIP-Dateien erlaubt.';
} else {
$tmp = $f['tmp_name'];
$bk = create_backup();
$res = do_install($tmp);
if (!$res['ok']) {
$errors[] = $res['error'];
} else {
// Schema-Migrationen nach Update einspielen
require_once __DIR__ . '/../database/migrate.php';
$mig = run_migrations(get_db());
$migNote = $mig['applied'] > 0 ? " · {$mig['applied']} Migration(en) eingespielt" : '';
if ($mig['errors']) $errors = array_merge($errors, $mig['errors']);
audit_log($staff['id'], "Update installiert: {$res['extracted']} Dateien{$migNote}");
flash('success', "✅ {$res['extracted']} Dateien aktualisiert.{$migNote}" . ($bk['ok'] ? " Backup: {$bk['filename']}" : ''));
redirect('/staff/update.php');
}
}
}
// Nur die Datenbank auf den Stand des installierten Codes bringen (z. B. nach Datei-Upload per FTP)
if ($action === 'migrate_db') {
require_once __DIR__ . '/../database/migrate.php';
$pending = migrations_pending($pdo);
if (!$pending) {
flash('success', 'Die Datenbank ist bereits auf dem aktuellen Stand.');
redirect('/staff/update.php');
}
$bk = create_backup();
if (!$bk['ok']) {
$errors[] = 'Backup fehlgeschlagen – die Datenbank wurde nicht verändert: ' . ($bk['error'] ?? 'unbekannter Fehler');
} else {
$mig = run_migrations($pdo);
if ($mig['errors']) {
$errors = array_merge($errors, $mig['errors']);
audit_log($staff['id'], 'Datenbank-Update mit Fehlern: ' . implode('; ', $mig['errors']));
} else {
audit_log($staff['id'], "Datenbank aktualisiert: {$mig['applied']} Migration(en)");
flash('success', "✅ Datenbank aktualisiert ({$mig['applied']} Migration(en)). Backup: {$bk['filename']}");
redirect('/staff/update.php');
}
}
}
// Update von der Plugin-Webseite des Herausgebers (Hauptquelle): Prüfsumme und, mit eingebautem Schlüssel, Signatur werden geprüft.
if ($action === 'auto_update' && preg_match('/^v?\d+\.\d+\.\d+([\-+][0-9A-Za-z.\-]+)?$/', trim($_POST['tag_name'] ?? '')) && ($pubRel = update_publisher_find(trim($_POST['tag_name'])))) {
[$pubTmp, $pubErr] = update_publisher_fetch($pubRel);
if ($pubErr !== null) {
audit_log($staff['id'], 'Update ' . $pubRel['tag_name'] . ' abgelehnt: ' . $pubErr);
$errors[] = $pubErr;
} else {
$errors = array_merge($errors, update_finish_install($pubTmp, (string) $pubRel['tag_name'], update_signature_required(), $staff, 'Plugin-Webseite'));
}
$action = 'handled';
}
// Ausweichquelle: Auto-Download aus den Gitea-Releases. Ist ein Herausgeber-Schlüssel eingebaut, wird nur ein signiertes Release installiert.
if ($action === 'auto_update') {
$tagName = trim($_POST['tag_name'] ?? '');
$headers = update_http_headers();
if (!preg_match('/^v?\d+\.\d+\.\d+([\-+][0-9A-Za-z.\-]+)?$/', $tagName)) {
$errors[] = 'Kein gültiges Release gewählt.';
} else {
$verifyRequired = update_signature_required();
$zipUrl = UPDATE_REPO_API === '' ? '' : 'https://git.viper.ipv64.net/M_Viper/deskhub/archive/' . rawurlencode($tagName) . '.zip';
$sigUrl = null;
[$relCode, $relBody] = update_download(UPDATE_REPO_API . '/releases/tags/' . rawurlencode($tagName), $headers, 10);
$release = $relCode === 200 ? (json_decode($relBody, true) ?: []) : [];
$assets = update_find_assets($release, $tagName);
if ($assets['zip']) { $zipUrl = $assets['zip']; $sigUrl = $assets['sig']; }
if ($verifyRequired && (!$assets['zip'] || !$sigUrl)) {
$errors[] = 'Dieses Release ist nicht signiert (Anhänge deskhub-' . ltrim($tagName, 'vV') . '.zip und .zip.sig fehlen). Aus Sicherheitsgründen wird es nicht installiert.';
} else {
[$code, $data, $err] = update_download($zipUrl, $headers, 120);
if ($err) {
$errors[] = 'cURL-Fehler: ' . $err . (stripos($err, 'certificate') !== false || stripos($err, 'SSL') !== false ? ' – Das Serverzertifikat konnte nicht geprüft werden. Bitte das CA-Zertifikatspaket des Servers aktualisieren (php.ini: curl.cainfo). Die Prüfung wird aus Sicherheitsgründen nicht abgeschaltet.' : '');
} elseif ($code !== 200) {
$errors[] = "Download fehlgeschlagen (HTTP $code).";
} else {
$tmp = tempnam(sys_get_temp_dir(), 'dh_') . '.zip';
file_put_contents($tmp, $data);
$sigError = null;
if ($verifyRequired) {
[$sc, $sigBody] = update_download($sigUrl, $headers, 20);
$sigError = $sc === 200 ? update_verify_package($tagName, $tmp, $sigBody) : "Die Signatur konnte nicht geladen werden (HTTP $sc).";
}
if ($sigError !== null) {
@unlink($tmp);
audit_log($staff['id'], "Update $tagName abgelehnt: $sigError");
$errors[] = $sigError;
} else {
$bk = create_backup();
$res = do_install($tmp);
@unlink($tmp);
if (!$res['ok']) {
$errors[] = $res['error'];
} else {
// Schema-Migrationen nach Update einspielen
require_once __DIR__ . '/../database/migrate.php';
$mig = run_migrations(get_db());
$migNote = $mig['applied'] > 0 ? " · {$mig['applied']} Migration(en) eingespielt" : '';
if ($mig['errors']) $errors = array_merge($errors, $mig['errors']);
audit_log($staff['id'], "Auto-Update $tagName: {$res['extracted']} Dateien{$migNote}" . ($verifyRequired ? ' (Signatur geprüft)' : ''));
// Versionsnummer in version.php aktualisieren
$newVer = ltrim($tagName, 'v');
$verFile = realpath(__DIR__ . '/../version.php');
if ($verFile && $newVer) {
$vc = file_get_contents($verFile);
$vc = preg_replace("/define\('DESKHUB_VERSION',\s*'[^']+'\)/", "define('DESKHUB_VERSION', '$newVer')", $vc);
file_put_contents($verFile, $vc);
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
flash('success', "✅ Update auf $newVer: {$res['extracted']} Dateien." . ($verifyRequired ? ' Signatur geprüft.' : '') . ($bk['ok'] ? " Backup: {$bk['filename']}" : ''));
redirect('/staff/update.php');
}
}
}
}
}
}
}
// ── Releases laden ────────────────────────────────────────────────────────────
$releases = update_publisher_releases();
$relSource = $releases ? 'Plugin-Webseite' : 'Gitea';
$relErr = '';
$code = $releases ? 200 : 0;
if (!$releases) [$code, $resp] = update_download(UPDATE_REPO_API . '/releases?limit=20', update_http_headers(), 8);
if ($releases) {
// Hauptquelle: Plugin-Webseite
} elseif ($code === 200) {
$all = json_decode($resp, true) ?: [];
usort($all, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name']));
$releases = $all;
} else {
$relErr = "API HTTP $code" . (update_http_headers() === []
? ($code === 404 || $code === 401 || $code === 403 ? ' – das Repository ist vermutlich privat. Unten unter „Zugang zum Update-Server“ ein Lese-Token eintragen.' : '')
: ($code === 404 || $code === 401 || $code === 403 ? ' – das eingetragene Token wird nicht akzeptiert (falsch, abgelaufen oder ohne Leserecht).' : ''));
}
$signedOnly = update_signature_required();
// Version frisch aus Datei lesen
$verFile = realpath(__DIR__ . '/../version.php') ?: '';
$verData = $verFile ? file_get_contents($verFile) : '';
preg_match("/define\s*\(\s*'DESKHUB_VERSION'\s*,\s*'([^']+)'/", $verData, $vm);
$curVer = $vm[1] ?? DESKHUB_VERSION;
$latest = $releases[0] ?? null;
$hasUpdate = $latest && version_compare($latest['tag_name'], $curVer, '>');
require_once __DIR__ . '/../database/migrate.php';
$pendingMigrations = migrations_pending($pdo);
$pageTitle = 'System-Update';
$activePage = 'update';
require __DIR__ . '/_header.php';
?>
<div class="topbar"><h1>🔄 System-Update</h1></div>
<?php foreach ($errors as $e): ?>
<div class="flash error"><?= htmlspecialchars($e) ?></div>
<?php endforeach; ?>
<?php if ($relErr): ?>
<div class="flash warning" style="margin-bottom:16px">Die Liste der Versionen konnte nicht geladen werden: <?= htmlspecialchars($relErr) ?></div>
<?php endif; ?>
<?php if ($hasUpdate): ?>
<div style="background:#e0f2f1;border:1px solid #0f6f6a;border-radius:10px;padding:16px 20px;margin-bottom:20px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<strong style="color:#0f6f6a">Update verfügbar: Version <?= htmlspecialchars($latest['tag_name']) ?></strong>
<span style="color:#64748b;font-size:.85rem;margin-left:10px">vom <?= date('d.m.Y', strtotime($latest['published_at'])) ?></span>
<?php if (trim((string) ($latest['body'] ?? '')) !== ''): ?>
<div style="margin-top:8px;font-size:.86rem;color:#334155;white-space:pre-wrap"><strong>Änderungen:</strong> <?= htmlspecialchars(mb_substr(trim((string) $latest['body']), 0, 600)) ?></div>
<?php endif; ?>
</div>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="auto_update">
<input type="hidden" name="tag_name" value="<?= htmlspecialchars($latest['tag_name']) ?>">
<button type="submit" class="btn" onclick="return confirm('Update auf <?= htmlspecialchars($latest['tag_name']) ?> installieren?')">Jetzt installieren</button>
</form>
</div>
<?php endif; ?>
<div class="card" style="margin-bottom:20px;<?= $pendingMigrations ? 'border-color:#d97706' : '' ?>">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px">Datenbank</div>
<?php if ($pendingMigrations): ?>
<strong style="color:#b45309"><?= count($pendingMigrations) ?> ausstehende Datenbank-Änderung(en)</strong>
<div style="color:var(--muted);font-size:.82rem;margin-top:4px">Der Code ist neuer als die Datenbank. Vor dem Einspielen wird automatisch ein Backup erstellt.</div>
<details style="margin-top:6px;font-size:.8rem;color:var(--muted)"><summary>Details</summary><?= htmlspecialchars(implode(', ', $pendingMigrations)) ?></details>
<?php else: ?>
<strong style="color:#15803d">✓ Die Datenbank ist aktuell</strong>
<div style="color:var(--muted);font-size:.82rem;margin-top:4px">Alle bekannten Datenbank-Änderungen sind eingespielt.</div>
<?php endif; ?>
</div>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="migrate_db">
<button type="submit" class="btn <?= $pendingMigrations ? '' : 'secondary' ?>" <?= $pendingMigrations ? "onclick=\"return confirm('Datenbank jetzt aktualisieren? Es wird zuerst ein Backup erstellt.')\"" : 'disabled' ?>>🗄 Datenbank aktualisieren</button>
</form>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px">
<div class="card" style="margin:0">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Installierte Version</div>
<div style="font-size:3rem;font-weight:800;color:var(--primary);line-height:1"><?= htmlspecialchars($curVer) ?></div>
<div style="color:var(--muted);font-size:.82rem;margin-top:6px">DeskHub Ticketsystem</div>
<?php if ($releases): ?><div style="color:var(--muted);font-size:.78rem;margin-top:4px">Updates kommen von: <?= htmlspecialchars($relSource) ?><?= $relSource === 'Gitea' && update_publisher_endpoint(['list' => 1]) !== '' ? ' (Plugin-Webseite nicht erreichbar oder ohne Update)' : '' ?></div><?php endif; ?>
<?php if ($releases): ?>
<button onclick="document.getElementById('rel-modal').style.display='flex'" class="btn secondary" style="margin-top:16px">📋 Alle Releases</button>
<?php endif; ?>
</div>
<div class="card" style="margin:0">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Manuelles Update</div>
<p style="color:var(--muted);font-size:.85rem;margin-bottom:14px">Eine ZIP-Datei des Herausgebers hochladen und installieren.</p>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="action" value="upload_update">
<input type="file" name="update_zip" accept=".zip" required style="margin-bottom:12px">
<button type="submit" class="btn secondary">⬆️ Hochladen &amp; installieren</button>
</form>
</div>
</div>
<?php if (count($releases) > 1): ?>
<div class="card" style="margin-bottom:20px">
<h3>Bestimmte Version installieren</h3>
<form method="post" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
<?= csrf_field() ?>
<input type="hidden" name="action" value="auto_update">
<div style="flex:1;min-width:200px">
<select name="tag_name" required>
<option value="">– Version wählen –</option>
<?php foreach ($releases as $r): $isCur = $r['tag_name']===$curVer; ?>
<option value="<?= htmlspecialchars($r['tag_name']) ?>" <?= $isCur?'disabled':'' ?>>
<?= htmlspecialchars($r['tag_name']) ?><?= $isCur?' (installiert)':'' ?> – <?= date('d.m.Y', strtotime($r['published_at'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn secondary" onclick="return confirm('Version installieren?')">Installieren</button>
</form>
</div>
<?php endif; ?>
<?php [$__tokenValue, $__tokenSource] = update_token_info(); ?>
<div class="card" style="margin-bottom:20px">
<h3>Zugang zum Update-Server</h3>
<p style="color:var(--muted);font-size:.86rem;margin-bottom:12px">
Nur für die Ausweichquelle Gitea (wenn die Plugin-Webseite keine Updates liefert). Nötig, wenn das Repository mit den Releases <strong>privat</strong> ist. Gitea meldet dann beim Laden der Versionen „404“. Ein Token mit <strong>Leserecht</strong> genügt
(in Gitea: Einstellungen → Anwendungen → Zugriffstoken erzeugen, Berechtigung „repository: Lesen“). Es wird verschlüsselt gespeichert.
</p>
<?php if ($__tokenSource === 'config'): ?>
<p style="margin-bottom:10px"><strong style="color:#15803d">✓ Token aus config/config.php wird verwendet.</strong> Es hat Vorrang vor einem hier gespeicherten.</p>
<?php elseif ($__tokenSource === 'einstellungen'): ?>
<p style="margin-bottom:10px"><strong style="color:#15803d">✓ Token gespeichert.</strong> Zum Ersetzen unten ein neues eintragen.</p>
<?php else: ?>
<p style="margin-bottom:10px;color:var(--muted)">Kein Token eingetragen (öffentlicher Zugriff).</p>
<?php endif; ?>
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_update_token">
<div style="flex:1;min-width:260px">
<label for="update_token" style="margin-top:0">Zugriffstoken</label>
<input type="password" id="update_token" name="update_token" autocomplete="off" placeholder="<?= $__tokenSource === 'einstellungen' ? '•••••••• (gespeichert)' : 'Token aus Gitea einfügen' ?>" required>
</div>
<button type="submit" class="btn">Speichern</button>
</form>
<?php if ($__tokenSource === 'einstellungen'): ?>
<form method="post" style="margin-top:10px" onsubmit="return confirm('Gespeichertes Token entfernen?')">
<?= csrf_field() ?><input type="hidden" name="action" value="remove_update_token">
<button type="submit" class="btn secondary">Token entfernen</button>
</form>
<?php endif; ?>
<details style="margin-top:12px;font-size:.82rem;color:var(--muted)"><summary>Alternative: in config/config.php eintragen</summary>
<p style="margin:8px 0 4px">In der Datei <code>config/config.php</code> (Hauptordner von DeskHub, im Ordner <code>config</code>) als eigener Eintrag im großen <code>return [ … ];</code>, zum Beispiel direkt unter <code>'app_key'</code>:</p>
<pre style="background:var(--bg-soft,#f4f6f9);padding:10px;border-radius:6px;overflow:auto">'update' => ['token' =&gt; 'HIER_DAS_TOKEN'],</pre>
</details>
</div>
<div class="card">
<h3>Hinweise</h3>
<ul style="padding-left:20px;line-height:2;font-size:.88rem;color:var(--muted)">
<li><strong style="color:var(--ink)">config/config.php</strong> und <strong style="color:var(--ink)">database/deskhub.sqlite</strong> werden niemals überschrieben</li>
<?php if ($signedOnly): ?><li><strong style="color:var(--ink)">Nur signierte Releases:</strong> Updates werden nur installiert, wenn sie mit dem Schlüssel des Herausgebers signiert sind. Ein manipuliertes Paket wird abgelehnt. (Ein manuell hochgeladenes ZIP wird nicht geprüft: Laden Sie nur Pakete aus vertrauenswürdiger Quelle hoch.)</li><?php endif; ?>
<li>Nach dem Update Browser-Cache leeren <kbd>Strg+F5</kbd></li>
</ul>
</div>
<!-- Releases Modal -->
<div id="rel-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9999;align-items:center;justify-content:center;padding:20px">
<div style="background:#fff;border-radius:14px;width:100%;max-width:600px;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;padding:18px 24px;border-bottom:1px solid var(--border)">
<h3 style="margin:0">Releases</h3>
<button onclick="document.getElementById('rel-modal').style.display='none'" style="background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--muted)">&times;</button>
</div>
<div style="overflow-y:auto;padding:20px 24px">
<?php foreach ($releases as $r):
$isNew = version_compare($r['tag_name'], $curVer, '>');
$isCur = $r['tag_name'] === $curVer;
?>
<div style="border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:10px<?= $isNew?';border-color:var(--primary)':'' ?>">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div style="display:flex;gap:8px;align-items:center">
<strong><?= htmlspecialchars($r['tag_name']) ?></strong>
<?php if ($isNew): ?><span style="background:var(--primary);color:#fff;font-size:.7rem;padding:2px 8px;border-radius:20px;font-weight:700">NEU</span><?php endif; ?>
<?php if ($isCur): ?><span style="background:#e2f5f3;color:var(--primary);font-size:.7rem;padding:2px 8px;border-radius:20px;font-weight:700">INSTALLIERT</span><?php endif; ?>
</div>
<span style="color:var(--muted);font-size:.78rem"><?= date('d.m.Y', strtotime($r['published_at'])) ?></span>
</div>
<?php if (!empty($r['body'])): ?>
<div style="font-size:.83rem;color:var(--muted);white-space:pre-wrap;margin-bottom:10px"><?= htmlspecialchars(mb_substr($r['body'], 0, 200)) ?><?= mb_strlen($r['body'])>200?'…':'' ?></div>
<?php endif; ?>
<?php if (!$isCur): ?>
<form method="post" onsubmit="return confirm('<?= htmlspecialchars($r['tag_name']) ?> installieren?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="auto_update">
<input type="hidden" name="tag_name" value="<?= htmlspecialchars($r['tag_name']) ?>">
<button type="submit" class="btn <?= $isNew?'':'secondary' ?>" style="font-size:.82rem;padding:6px 14px">⬆️ Installieren</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<script>
document.getElementById('rel-modal')?.addEventListener('click', function(e){ if(e.target===this) this.style.display='none'; });
document.addEventListener('keydown', function(e){ if(e.key==='Escape') document.getElementById('rel-modal').style.display='none'; });
</script>
<?php require __DIR__ . '/_footer.php'; ?>