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]; } // ── 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', 'staff/update.php', // Sich selbst nicht überschreiben während des Updates '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; $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 if (preg_match('#^(plugins|uploads|backups|app_downloads)/#', $relNorm) || preg_match('#^(plugin-webseite|tools|tests|docs|desktop|mobile|theme-packages|vorschaubilder|\.gitea|\.github)/#', $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; $written = file_put_contents($dest, $data); if ($written !== false) { $extracted++; } else { $failed[] = $rel; } } $zip->close(); if ($extracted === 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, 'failed' => $failed]; } // ── 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'); } } } // Auto-Download vom Update-Server. 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 = []; $relErr = ''; [$code, $resp] = update_download(UPDATE_REPO_API . '/releases?limit=20', update_http_headers(), 8); if ($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'; ?>
ZIP-Datei von Gitea hochladen und installieren.
Nur nötig, wenn das Repository mit den Releases privat ist. Gitea meldet dann beim Laden der Versionen „404“. Ein Token mit Leserecht genügt (in Gitea: Einstellungen → Anwendungen → Zugriffstoken erzeugen, Berechtigung „repository: Lesen“). Es wird verschlüsselt gespeichert.
✓ Token aus config/config.php wird verwendet. Es hat Vorrang vor einem hier gespeicherten.
✓ Token gespeichert. Zum Ersetzen unten ein neues eintragen.
Kein Token eingetragen (öffentlicher Zugriff).
In der Datei config/config.php (Hauptordner von DeskHub, im Ordner config) als eigener Eintrag im großen return [ … ];, zum Beispiel direkt unter 'app_key':
'update' => ['token' => 'HIER_DAS_TOKEN'],