diff --git a/admin/update.php b/admin/update.php new file mode 100644 index 0000000..851d2d8 --- /dev/null +++ b/admin/update.php @@ -0,0 +1,25 @@ +requireAdmin(); + +$db = Database::getInstance(); + +$controller = new \Updater\UpdateController($db, $auth); +$controller->handle(); diff --git a/index.php b/index.php index ad41e26..eaf5979 100644 --- a/index.php +++ b/index.php @@ -1,4 +1,12 @@ db = $db; + } + + public function log($action, $details = null, $userId = null) + { + try { + $this->db->query( + "INSERT INTO audit_log (user_id, action, entity_type, details, ip_address) + VALUES (?, ?, 'updater', ?, ?)", + [ + $userId, + $action, + is_string($details) ? $details : json_encode($details, JSON_UNESCAPED_UNICODE), + $_SERVER['REMOTE_ADDR'] ?? null, + ] + ); + } catch (\Throwable $e) { + // Logging darf nie ein Update verhindern. + } + } +} diff --git a/updater/MigrationRunner.php b/updater/MigrationRunner.php new file mode 100644 index 0000000..bb93d5d --- /dev/null +++ b/updater/MigrationRunner.php @@ -0,0 +1,253 @@ +pdo = $pdo; + $this->driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $this->migrationsDir = rtrim($migrationsDir, '/'); + $this->lockFile = rtrim($storageDir, '/') . '/.migrations-lock'; + } + + /** Stellt die Tracking-Tabelle sicher (treiber-spezifisch). */ + public function ensureTable() + { + if ($this->driver === 'sqlite') { + $sql = 'CREATE TABLE IF NOT EXISTS "_updater_migrations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "filename" TEXT NOT NULL UNIQUE, + "applied_at" DATETIME DEFAULT CURRENT_TIMESTAMP + )'; + } else { + $sql = 'CREATE TABLE IF NOT EXISTS `_updater_migrations` ( + `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `filename` VARCHAR(255) NOT NULL UNIQUE, + `applied_at` DATETIME DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + } + $this->pdo->exec($sql); + } + + /** + * Fuehrt alle noch nicht angewandten Migrationen aus. + * 60-Sekunden-Cache via Lockfile-Timestamp verhindert, dass bei haeufigen + * Aufrufen unnoetig gescannt wird. + * + * @param bool $force Cache ignorieren (z.B. direkt nach einem Update) + * @return array Liste der angewandten Dateinamen + */ + public function runPending($force = false) + { + if (!$force && $this->isCacheFresh()) { + return []; + } + + $this->ensureTable(); + $applied = $this->appliedFilenames(); + $files = $this->migrationFiles(); + $done = []; + + foreach ($files as $file) { + $name = basename($file); + if (in_array($name, $applied, true)) { + continue; + } + + $sql = file_get_contents($file); + $statements = $this->splitStatements($sql); + + $this->pdo->beginTransaction(); + try { + foreach ($statements as $stmt) { + if (trim($stmt) === '') { + continue; + } + try { + $this->pdo->exec($stmt); + } catch (\PDOException $e) { + if (!$this->isIgnorableSqlError($e->getMessage(), $this->driver)) { + throw $e; + } + } + } + $ins = $this->pdo->prepare( + $this->driver === 'sqlite' + ? 'INSERT OR IGNORE INTO "_updater_migrations" (filename) VALUES (?)' + : 'INSERT IGNORE INTO `_updater_migrations` (filename) VALUES (?)' + ); + $ins->execute([$name]); + $this->pdo->commit(); + $done[] = $name; + } catch (\Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw new \RuntimeException("Migration $name fehlgeschlagen: " . $e->getMessage(), 0, $e); + } + } + + $this->touchCache(); + return $done; + } + + /** Liefert Status aller Migrationen (angewandt / offen). */ + public function status() + { + $this->ensureTable(); + $applied = $this->appliedFilenames(); + $result = []; + foreach ($this->migrationFiles() as $file) { + $name = basename($file); + $result[] = ['filename' => $name, 'applied' => in_array($name, $applied, true)]; + } + return $result; + } + + private function appliedFilenames() + { + try { + $rows = $this->pdo->query('SELECT filename FROM ' . + ($this->driver === 'sqlite' ? '"_updater_migrations"' : '`_updater_migrations`'))->fetchAll(\PDO::FETCH_COLUMN); + return $rows ?: []; + } catch (\Throwable $e) { + return []; + } + } + + private function migrationFiles() + { + if (!is_dir($this->migrationsDir)) { + return []; + } + $files = glob($this->migrationsDir . '/*.sql'); + sort($files, SORT_STRING); + return $files; + } + + /** + * String- und kommentar-bewusster SQL-Splitter. Trennt an ';' ausserhalb + * von Strings/Kommentaren. Verhindert das naive Zerschneiden von Statements, + * die ';' innerhalb von String-Literalen enthalten. + */ + private function splitStatements($sql) + { + $statements = []; + $buffer = ''; + $len = strlen($sql); + $inSingle = false; // '...' + $inDouble = false; // "..." + $inBacktick = false; // `...` + $inLineComment = false; // -- ... oder # ... + $inBlockComment = false; // /* ... */ + + for ($i = 0; $i < $len; $i++) { + $ch = $sql[$i]; + $next = $i + 1 < $len ? $sql[$i + 1] : ''; + + if ($inLineComment) { + if ($ch === "\n") { + $inLineComment = false; + $buffer .= $ch; + } + continue; + } + if ($inBlockComment) { + if ($ch === '*' && $next === '/') { + $inBlockComment = false; + $i++; + } + continue; + } + + if (!$inSingle && !$inDouble && !$inBacktick) { + if ($ch === '-' && $next === '-') { $inLineComment = true; $i++; continue; } + if ($ch === '#') { $inLineComment = true; continue; } + if ($ch === '/' && $next === '*') { $inBlockComment = true; $i++; continue; } + } + + // String-/Identifier-Grenzen umschalten (mit Backslash-Escape) + if ($ch === "'" && !$inDouble && !$inBacktick) { + if ($inSingle && $this->isEscaped($sql, $i)) { $buffer .= $ch; continue; } + $inSingle = !$inSingle; + } elseif ($ch === '"' && !$inSingle && !$inBacktick) { + if ($inDouble && $this->isEscaped($sql, $i)) { $buffer .= $ch; continue; } + $inDouble = !$inDouble; + } elseif ($ch === '`' && !$inSingle && !$inDouble) { + $inBacktick = !$inBacktick; + } + + if ($ch === ';' && !$inSingle && !$inDouble && !$inBacktick) { + $statements[] = $buffer; + $buffer = ''; + continue; + } + + $buffer .= $ch; + } + + if (trim($buffer) !== '') { + $statements[] = $buffer; + } + return $statements; + } + + private function isEscaped($sql, $pos) + { + $backslashes = 0; + $p = $pos - 1; + while ($p >= 0 && $sql[$p] === '\\') { $backslashes++; $p--; } + return ($backslashes % 2) === 1; + } + + /** + * Treiber-spezifische Liste ignorierbarer SQL-Fehler (idempotente + * Migrationen: Spalte/Index/Tabelle existiert bereits o.ae.). + */ + public function isIgnorableSqlError($msg, $driver) + { + $msg = strtolower($msg); + $common = [ + 'already exists', + 'duplicate column', + 'duplicate key name', + ]; + foreach ($common as $needle) { + if (strpos($msg, $needle) !== false) { + return true; + } + } + if ($driver === 'sqlite') { + return strpos($msg, 'duplicate column name') !== false + || strpos($msg, 'already exists') !== false; + } + // MySQL/MariaDB + return strpos($msg, "can't drop") !== false && strpos($msg, 'check that column/key exists') !== false; + } + + private function isCacheFresh() + { + return is_file($this->lockFile) && (time() - filemtime($this->lockFile)) < 60; + } + + private function touchCache() + { + @touch($this->lockFile); + } +} diff --git a/updater/README.md b/updater/README.md new file mode 100644 index 0000000..c1701ef --- /dev/null +++ b/updater/README.md @@ -0,0 +1,88 @@ +# Updater (OpenVoucherTool) + +Auto-Update-System nach dem OpenNIT-Modell: zieht Quellcode + DB-Migrationen +aus einem zentralen Repository über einen HTTP-Update-Proxy nach. + +``` +[diese App] ←HTTPS→ [Update-Proxy] ←pull→ [Git-Repo] +``` + +- **Versions-Identität:** 40-stelliger Git-Commit-SHA in `updater/storage/.version` +- **Channels:** `stable` → `https://update.loheide.eu/openvouchertool`, + `development` → `https://update.loheide.eu/openvouchertool-development` +- **User-Agent:** `OpenVoucherTool-Updater/1.0` +- **DB-Treiber:** MySQL/MariaDB (Migrations-Tracking-Tabelle `_updater_migrations`) + +## Aufruf + +Admin-Oberfläche: **`/admin/update.php`** (nur für angemeldete Admins). + +| Endpoint | Methode | Zweck | +|---|---|---| +| `admin/update.php` | GET | Admin-UI | +| `admin/update.php?action=check` | GET | Update-Prüfung (JSON) | +| `admin/update.php?action=progress` | GET | Fortschritt (JSON) | +| `admin/update.php?action=migrations` | GET | Migrations-Status (JSON) | +| `admin/update.php` `action=install` | POST | Update installieren (+`csrf_token`) | +| `admin/update.php` `action=set_channel` | POST | Channel wählen (+`csrf_token`) | + +> Optional: In der Admin-Navigation (`admin/index.php`) einen Link zu +> `update.php` ergänzen, damit die Seite auffindbar ist. Das ist bewusst +> **nicht** automatisch geschehen (Isolations-Prinzip – siehe unten). + +## Isolation + +Der Updater liegt vollständig in `updater/` (eigener Namespace `Updater\`) plus +zwei dünne, klar markierte Anknüpfungen: + +1. **Front-Controller-Hook** in `index.php` (Maintenance-Check, markiert mit + `// Updater maintenance hook`). +2. **Entry-Shim** `admin/update.php` (lädt nur Basis + Bootstrap, delegiert an + den Controller). + +Eigene Settings (`updater/storage/updater-settings.json`), eigener Autoloader +(`updater/bootstrap.php`), eigener AuditLogger (schreibt in die vorhandene +`audit_log`-Tabelle), eigenes Migrations-System (`updater/migrations/` + +Tabelle `_updater_migrations`). Es werden **keine** Projekt-Klassen erweitert – +`\Database` und `\Auth` werden nur per Injection genutzt. + +### Geschützte Pfade (werden bei Updates nie überschrieben) + +`config.php`, `.htaccess`, `.env*`, `.git/`, `.gitignore`, `public/uploads/`, +`vendor/`, `composer.lock`, `updater/storage/`. + +## Rückbau (restlos entfernen) + +1. In `index.php` den Block **„Updater maintenance hook"** (die Zeilen 2–8, + beginnend mit `$maintenanceFile = …` bis zum schließenden `}`) entfernen. +2. `rm -r updater/` +3. `rm admin/update.php` +4. *(optional)* In der Datenbank: `DROP TABLE _updater_migrations;` +5. *(optional, falls vorhanden)* Laufzeitdateien sind bereits in `updater/` + und damit mit Schritt 2 weg. Nichts liegt außerhalb. + +Danach ist keine Spur des Updaters mehr im Bestandscode – der Beweis für die +Isolation. + +## Smoke-Test + +```bash +# 1) Syntax aller Updater-Dateien +php -l updater/UpdateManager.php +php -l updater/MigrationRunner.php +php -l updater/UpdateController.php +php -l updater/UpdaterFactory.php +php -l updater/AuditLogger.php + +# 2) Admin-Seite aufrufen (eingeloggt als Admin): +# https:///admin/update.php +# -> "Auf Updates prüfen" klicken. Erwartung: Proxy-Antwort oder klare +# Fehlermeldung (wenn Proxy/Channel nicht erreichbar). + +# 3) Maintenance-Mode manuell testen: +touch updater/storage/.maintenance # index.php zeigt jetzt 503-Wartungsseite +rm updater/storage/.maintenance # wieder normal +``` + +> Hinweis: Ein vollständiger Installations-Durchlauf (`action=install`) setzt +> einen erreichbaren Update-Proxy unter den oben genannten URLs voraus. diff --git a/updater/UpdateController.php b/updater/UpdateController.php new file mode 100644 index 0000000..99d9e28 --- /dev/null +++ b/updater/UpdateController.php @@ -0,0 +1,122 @@ +db = $db; + $this->auth = $auth; + $this->manager = UpdaterFactory::create($db, new AuditLogger($db)); + } + + public function handle(): void + { + $action = $_GET['action'] ?? $_POST['action'] ?? ''; + switch ($action) { + case 'check': $this->actionCheck(); break; + case 'install': $this->actionInstall(); break; + case 'progress': $this->actionProgress(); break; + case 'set_channel': $this->actionSetChannel(); break; + case 'migrations': $this->actionMigrations(); break; + default: $this->renderPage(); + } + } + + // ------------------------------------------------------------- AJAX-Actions + + private function actionCheck(): void + { + try { + $this->json($this->manager->checkForUpdates()); + } catch (\Throwable $e) { + $this->json(['error' => $e->getMessage()], 502); + } + } + + private function actionInstall(): void + { + if (!$this->auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $this->json(['error' => 'Ungültiges Sicherheits-Token'], 403); + return; + } + // Session-Lock freigeben, damit parallele Progress-Polls nicht blockieren. + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + @set_time_limit(300); + $userId = $_SESSION['user_id'] ?? null; + $result = $this->manager->installUpdate($userId !== null ? (int)$userId : null); + $this->json($result, $result['success'] ? 200 : 500); + } + + private function actionProgress(): void + { + // Kein CSRF noetig (read-only). Session-Lock sofort freigeben. + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + $this->json($this->manager->getProgress()); + } + + private function actionSetChannel(): void + { + if (!$this->auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $this->json(['error' => 'Ungültiges Sicherheits-Token'], 403); + return; + } + $channel = $_POST['channel'] ?? 'stable'; + UpdaterFactory::saveChannel($channel); + $this->json(['success' => true, 'channel' => $channel]); + } + + private function actionMigrations(): void + { + try { + $runner = new MigrationRunner( + $this->db->getConnection(), + __DIR__ . '/migrations', + __DIR__ . '/storage' + ); + $this->json(['migrations' => $runner->status()]); + } catch (\Throwable $e) { + $this->json(['error' => $e->getMessage()], 500); + } + } + + // ------------------------------------------------------------------- Render + + private function renderPage(): void + { + $manager = $this->manager; + $auth = $this->auth; + $currentSha = $manager->getCurrentVersion(); + $channel = $manager->getChannel(); + $csrfToken = $auth->getCsrfToken(); + require __DIR__ . '/templates/update.php'; + } + + private function json($data, int $status = 200): void + { + if (!headers_sent()) { + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + } + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} diff --git a/updater/UpdateManager.php b/updater/UpdateManager.php new file mode 100644 index 0000000..88a310b --- /dev/null +++ b/updater/UpdateManager.php @@ -0,0 +1,426 @@ + [Update-Proxy] <-pull- [Git-Repo] + * Versions-Identitaet: 40-stelliger Git-Commit-SHA in /.version. + * + * Vollstaendig isoliert im updater/-Ordner; nutzt die vorhandene + * \Database-Klasse per Injection (keine Erweiterung von Projekt-Klassen). + */ +class UpdateManager +{ + public const CHANNELS = [ + 'stable' => 'https://update.loheide.eu/openvouchertool', + 'development' => 'https://update.loheide.eu/openvouchertool-development', + ]; + + private const USER_AGENT = 'OpenVoucherTool-Updater/1.0'; + + /** + * Pfade, die beim Staging->Production-Move NIEMALS ueberschrieben werden. + * Relativ zum Projekt-Root. Prefix-Match (Ordner mit Slash). + */ + private const PROTECTED_PATHS = [ + 'config.php', + '.htaccess', + '.env', + '.env.example', + '.git/', + '.gitignore', + 'public/uploads/', + 'vendor/', + 'composer.lock', + // Updater-Laufzeitdaten (Version, Settings, Staging) – nie ueberschreiben. + // Der restliche updater/-Code SOLL aktualisiert werden. + 'updater/storage/', + ]; + + /** @var \Database */ + private $db; + /** @var AuditLogger|null */ + private $audit; + private $channel; + private $proxyUrl; + private $rootDir; + private $storageDir; + + public function __construct(\Database $db, ?AuditLogger $audit = null, string $channel = 'stable') + { + $this->db = $db; + $this->audit = $audit; + $this->channel = isset(self::CHANNELS[$channel]) ? $channel : 'stable'; + $this->proxyUrl = self::CHANNELS[$this->channel]; + $this->rootDir = dirname(__DIR__); // updater/ liegt im Projekt-Root + $this->storageDir = __DIR__ . '/storage'; // updater/storage/ + if (!is_dir($this->storageDir)) { + @mkdir($this->storageDir, 0775, true); + } + } + + public function getChannel(): string + { + return $this->channel; + } + + public function getProxyUrl(): string + { + return $this->proxyUrl; + } + + // ---------------------------------------------------------------- Version + + public function getCurrentVersion(): string + { + $file = $this->storageDir . '/.version'; + return is_file($file) ? trim((string)file_get_contents($file)) : ''; + } + + public function saveCurrentVersion(string $sha): void + { + file_put_contents($this->storageDir . '/.version', trim($sha)); + } + + // ------------------------------------------------------------ Maintenance + + public function maintenanceOn(): void + { + file_put_contents($this->storageDir . '/.maintenance', date('c')); + } + + public function maintenanceOff(): void + { + $file = $this->storageDir . '/.maintenance'; + if (is_file($file)) { + @unlink($file); + } + } + + public function isMaintenance(): bool + { + return is_file($this->storageDir . '/.maintenance'); + } + + // --------------------------------------------------------------- Progress + + public function getProgress(): array + { + $file = $this->storageDir . '/.update-progress'; + if (!is_file($file)) { + return ['percent' => 0, 'message' => '', 'status' => 'idle']; + } + $data = json_decode((string)file_get_contents($file), true); + return is_array($data) ? $data : ['percent' => 0, 'message' => '', 'status' => 'idle']; + } + + private function setProgress(int $percent, string $message, string $status = 'running'): void + { + file_put_contents($this->storageDir . '/.update-progress', json_encode([ + 'percent' => $percent, + 'message' => $message, + 'status' => $status, + 'updated_at' => date('c'), + ])); + } + + // ------------------------------------------------------------ Update-Check + + /** + * Fragt den Proxy nach verfuegbaren Updates. + * @return array Normalisiertes Ergebnis inkl. has_update/latest_sha/changelog. + */ + public function checkForUpdates(): array + { + $current = $this->getCurrentVersion(); + [$status, $body] = $this->httpGet($this->proxyUrl . '/check?current_sha=' . urlencode($current)); + + $data = json_decode((string)$body, true); + // Wichtig: Proxy kann JSON-Fehler auch mit HTTP 200 senden – nicht + // allein auf den Statuscode verlassen. + if (!is_array($data) || isset($data['error'])) { + $msg = is_array($data) && isset($data['error']) ? $data['error'] : "HTTP $status"; + throw new \RuntimeException('Update-Pruefung fehlgeschlagen: ' . $msg); + } + + return [ + 'has_update' => (bool)($data['has_update'] ?? false), + 'current_sha' => $current, + 'latest_sha' => $data['latest_sha'] ?? '', + 'latest_commit' => $data['latest_commit'] ?? null, + 'versions_behind' => (int)($data['versions_behind'] ?? 0), + 'title' => $data['title'] ?? '', + 'changelog' => $data['changelog'] ?? '', + 'channel' => $this->channel, + ]; + } + + // ------------------------------------------------------------ Update-Flow + + /** + * Fuehrt das Update durch. Reihenfolge laut Spezifikation, maintenanceOff() + * garantiert im finally. + * + * @return array Ergebnis-Status + */ + public function installUpdate(?int $userId = null): array + { + $this->setProgress(0, 'Update wird vorbereitet …', 'running'); + $this->maintenanceOn(); + + $stagingDir = $this->storageDir . '/.update-staging'; + $zipPath = $this->storageDir . '/.update.zip'; + + try { + // 1) Neueste Version ermitteln + $this->setProgress(5, 'Ermittle neueste Version …'); + [$status, $body] = $this->httpGet($this->proxyUrl . '/version'); + $verData = json_decode((string)$body, true); + $latestSha = is_array($verData) ? ($verData['sha'] ?? '') : ''; + if ($latestSha === '') { + throw new \RuntimeException('Konnte neueste Version nicht ermitteln (Proxy-Antwort ungueltig).'); + } + + // 2) ZIP herunterladen (Fallback: Einzeldatei-Download) + $this->setProgress(20, 'Lade Update herunter …'); + $usedZip = $this->downloadZip($latestSha, $zipPath); + + // 3) Entpacken / Stagen + $this->setProgress(45, 'Entpacke Update …'); + $this->cleanDir($stagingDir); + if ($usedZip) { + $this->extractZip($zipPath, $stagingDir); + } else { + $this->downloadFilesIndividually($latestSha, $stagingDir); + } + $stagingRoot = $this->resolveStagingRoot($stagingDir); + + // 4) Staging -> Production (geschuetzte Pfade ueberspringen) + $this->setProgress(65, 'Wende Update an …'); + $this->applyStaging($stagingRoot); + + // 5) Migrationen ausfuehren + $this->setProgress(80, 'Fuehre Datenbank-Migrationen aus …'); + $runner = new MigrationRunner( + $this->db->getConnection(), + __DIR__ . '/migrations', + $this->storageDir + ); + $runner->runPending(true); + + // 6) Caches leeren + $this->setProgress(90, 'Leere Caches …'); + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + + // 7) Version speichern + $this->saveCurrentVersion($latestSha); + + $this->setProgress(100, 'Update abgeschlossen.', 'done'); + + if ($this->audit) { + $this->audit->log('update_installed', ['sha' => $latestSha, 'channel' => $this->channel], $userId); + } + + return ['success' => true, 'sha' => $latestSha]; + } catch (\Throwable $e) { + $this->setProgress(100, 'Fehler: ' . $e->getMessage(), 'error'); + if ($this->audit) { + $this->audit->log('update_failed', ['error' => $e->getMessage()], $userId); + } + return ['success' => false, 'error' => $e->getMessage()]; + } finally { + // .maintenance MUSS immer abgebaut werden. + $this->maintenanceOff(); + @unlink($zipPath); + $this->cleanDir($stagingDir); + @rmdir($stagingDir); + } + } + + // ----------------------------------------------------------------- Helper + + private function downloadZip(string $sha, string $target): bool + { + $fh = @fopen($target, 'w'); + if ($fh === false) { + return false; + } + $ch = curl_init($this->proxyUrl . '/zip?ref=' . urlencode($sha)); + curl_setopt_array($ch, [ + CURLOPT_FILE => $fh, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 120, + CURLOPT_USERAGENT => self::USER_AGENT, + ]); + $ok = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + fclose($fh); + + // Erfolg nur, wenn 200 UND es nach ZIP aussieht (Proxy kann JSON-Error 200 senden). + if (!$ok || $code !== 200 || stripos((string)$type, 'zip') === false) { + @unlink($target); + return false; + } + if (!class_exists('ZipArchive')) { + @unlink($target); + return false; // Einzeldatei-Fallback nutzen + } + return true; + } + + private function extractZip(string $zipPath, string $dest): void + { + $zip = new \ZipArchive(); + if ($zip->open($zipPath) !== true) { + throw new \RuntimeException('ZIP konnte nicht geoeffnet werden.'); + } + if (!is_dir($dest)) { + @mkdir($dest, 0775, true); + } + if (!$zip->extractTo($dest)) { + $zip->close(); + throw new \RuntimeException('ZIP konnte nicht entpackt werden.'); + } + $zip->close(); + } + + /** + * Fallback ohne ZIP: Dateiliste vom Proxy holen und einzeln laden. + */ + private function downloadFilesIndividually(string $sha, string $dest): void + { + [$status, $body] = $this->httpGet($this->proxyUrl . '/files?path='); + $files = json_decode((string)$body, true); + if (!is_array($files)) { + throw new \RuntimeException('Dateiliste vom Proxy ungueltig.'); + } + if (!is_dir($dest)) { + @mkdir($dest, 0775, true); + } + foreach ($files as $entry) { + if (($entry['type'] ?? '') !== 'file' || empty($entry['path'])) { + continue; + } + $relPath = $entry['path']; + [$st, $content] = $this->httpGet($this->proxyUrl . '/download/' . str_replace('%2F', '/', rawurlencode($relPath))); + if ($st !== 200) { + throw new \RuntimeException("Download fehlgeschlagen: $relPath (HTTP $st)"); + } + $targetPath = $dest . '/' . $relPath; + $dir = dirname($targetPath); + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + file_put_contents($targetPath, $content); + } + } + + /** + * Manche Proxies/Zipballs verpacken alles in EINEN Wurzelordner + * (z.B. "repo-/"). Diesen erkennen und als Staging-Root verwenden. + */ + private function resolveStagingRoot(string $stagingDir): string + { + $entries = array_values(array_filter(scandir($stagingDir), function ($e) { + return $e !== '.' && $e !== '..'; + })); + if (count($entries) === 1 && is_dir($stagingDir . '/' . $entries[0])) { + return $stagingDir . '/' . $entries[0]; + } + return $stagingDir; + } + + /** + * Verschiebt/kopiert den Staging-Baum in die Produktion und ueberspringt + * dabei alle PROTECTED_PATHS. + */ + private function applyStaging(string $stagingRoot): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($stagingRoot, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + $rel = ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen($stagingRoot))), '/'); + if ($rel === '' || $this->isProtected($rel)) { + continue; + } + $target = $this->rootDir . '/' . $rel; + if ($item->isDir()) { + if (!is_dir($target)) { + @mkdir($target, 0775, true); + } + } else { + $dir = dirname($target); + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + if (!@copy($item->getPathname(), $target)) { + throw new \RuntimeException("Konnte Datei nicht schreiben: $rel"); + } + } + } + } + + private function isProtected(string $rel): bool + { + foreach (self::PROTECTED_PATHS as $p) { + if (substr($p, -1) === '/') { + if (strpos($rel . '/', $p) === 0) { + return true; + } + } elseif ($rel === $p) { + return true; + } + } + return false; + } + + private function cleanDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $item) { + if ($item->isDir()) { + @rmdir($item->getPathname()); + } else { + @unlink($item->getPathname()); + } + } + } + + /** + * Einfacher HTTP-GET via curl. Gibt [httpStatus, body] zurueck. + */ + private function httpGet(string $url): array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_USERAGENT => self::USER_AGENT, + ]); + $body = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + if ($body === false) { + throw new \RuntimeException("Verbindung zum Update-Proxy fehlgeschlagen: $err"); + } + return [$status, $body]; + } +} diff --git a/updater/UpdaterFactory.php b/updater/UpdaterFactory.php new file mode 100644 index 0000000..8247425 --- /dev/null +++ b/updater/UpdaterFactory.php @@ -0,0 +1,51 @@ + \Updater\UpdateController). + * + * Diese Datei existiert, um die Routing-Konvention der Vorlage zu erfuellen und + * dokumentiert die einzige Routing-Einklink-Stelle. Sollte das Projekt spaeter + * einen echten Router erhalten, koennen hier die Routen registriert werden: + * + * $router->get('/admin/update', fn() => (new \Updater\UpdateController($db, $auth))->handle()); + * $router->post('/admin/update', fn() => (new \Updater\UpdateController($db, $auth))->handle()); + * + * Aktuelle Endpunkte (alle ueber admin/update.php, Parameter `action`): + * GET /admin/update.php -> Admin-UI + * GET /admin/update.php?action=check -> Update-Pruefung (JSON) + * GET /admin/update.php?action=progress -> Fortschritt (JSON) + * GET /admin/update.php?action=migrations-> Migrations-Status (JSON) + * POST /admin/update.php action=install (+csrf_token) + * POST /admin/update.php action=set_channel (+csrf_token, channel) + */ + +return [ + ['method' => 'GET', 'path' => '/admin/update', 'handler' => 'admin/update.php'], + ['method' => 'POST', 'path' => '/admin/update', 'handler' => 'admin/update.php'], +]; diff --git a/updater/storage/.gitignore b/updater/storage/.gitignore new file mode 100644 index 0000000..359160d --- /dev/null +++ b/updater/storage/.gitignore @@ -0,0 +1,3 @@ +# Updater-Laufzeitdaten – nicht versionieren, nur den Ordner behalten. +* +!.gitignore diff --git a/updater/templates/maintenance.html b/updater/templates/maintenance.html new file mode 100644 index 0000000..ff7b1e4 --- /dev/null +++ b/updater/templates/maintenance.html @@ -0,0 +1,42 @@ + + + + + + + Wartungsmodus + + + +
+
🛠️
+

Kurz mal Wartung

+

Es wird gerade ein Update eingespielt. Die Seite ist in wenigen Augenblicken wieder erreichbar.

+

Diese Seite aktualisiert sich automatisch.

+
+ + diff --git a/updater/templates/update.php b/updater/templates/update.php new file mode 100644 index 0000000..725cb30 --- /dev/null +++ b/updater/templates/update.php @@ -0,0 +1,259 @@ + + + + + + + System-Update + + + +
+
+

🔄 System-Update

+ ← Administration +
+ +
+
Update
+
Migrationen
+
+ +
+
+

Aktuelle Version

+
Installierte Version (SHA)
+
Update-Channel + + + +
+

Channel wird in updater/storage/updater-settings.json gespeichert.

+
+ +
+

Updates

+ + + +
+ +
+
+
+
+ +
+
+
+
+ +
+
+

Migrations-Status

+

Wird geladen …

+
+
+
+ + + +