Files
deskhub/DeskHub-Webseite/staff/update.php
T
2026-10-19 18:06:02 +00:00

481 lines
26 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'] ?? '';
// 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';
?>
<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>
</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; ?>
<?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 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'; ?>