Files
deskhub/DeskHub-Webseite/staff/update.php
T
2026-10-16 19:12:24 +00:00

421 lines
22 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];
}
// ── 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'] ?? '';
// 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() === [] ? ' (Ist das Repository privat? Dann in config/config.php unter update → token ein Lese-Token eintragen.)' : '');
}
$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 ($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>
</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): ?>
<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">ZIP-Datei von Gitea 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; ?>
<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; ?>
<?php if ($relErr): ?><li style="color:#b45309">Release-Liste konnte nicht geladen werden: <?= htmlspecialchars($relErr) ?></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'; ?>