Updater-System (OpenNIT-Modell) im isolierten updater/-Ordner
Zieht Quellcode + DB-Migrationen über einen HTTP-Update-Proxy nach. Vollständig isoliert: eigener Namespace Updater\, eigener Autoloader, eigene Settings (updater/storage/updater-settings.json), eigenes Migrations-System (_updater_migrations), eigener AuditLogger. Komponenten (alle in updater/): - UpdateManager: Version/.version, Maintenance, Progress, checkForUpdates, installUpdate (Staging + PROTECTED_PATHS + Migrationen + opcache + finally) - MigrationRunner: MySQL-Tracking, string-/kommentar-bewusster SQL-Splitter, isIgnorableSqlError, 60s-Lockfile-Cache - UpdateController + admin/update.php (dünner Entry-Shim), Inline-Admin-UI mit Channel-Selector, Update-Check, Progress-Bar, Migrations-Tab - UpdaterFactory (zentraler Channel-Fallback), AuditLogger (audit_log) - Templates: maintenance.html, update.php; routes.php (Doku) - README.md mit vollständiger Rückbau-Anleitung Einzige Bestandscode-Änderung: 4-Zeilen-Maintenance-Hook in index.php (markiert mit "// Updater maintenance hook"). Proxy: update.loheide.eu/openvouchertool[-development]
This commit is contained in:
parent
3483da274f
commit
43149074c9
14 changed files with 1374 additions and 0 deletions
42
updater/AuditLogger.php
Normal file
42
updater/AuditLogger.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
namespace Updater;
|
||||
|
||||
/**
|
||||
* Minimaler AuditLogger fuer den Updater.
|
||||
*
|
||||
* Das Projekt besitzt KEINE eigene AuditLogger-Klasse, aber eine vorhandene
|
||||
* Tabelle `audit_log`. Dieser Logger schreibt dort hinein, ohne eine
|
||||
* Projekt-Klasse zu erweitern. Faellt das Schreiben fehl (z.B. Tabelle fehlt),
|
||||
* wird der Fehler still ignoriert – Logging darf ein Update nie blockieren.
|
||||
*
|
||||
* Wird dem UpdateManager optional injiziert; ist er null, wird Logging
|
||||
* komplett uebersprungen.
|
||||
*/
|
||||
class AuditLogger
|
||||
{
|
||||
/** @var \Database */
|
||||
private $db;
|
||||
|
||||
public function __construct(\Database $db)
|
||||
{
|
||||
$this->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.
|
||||
}
|
||||
}
|
||||
}
|
||||
253
updater/MigrationRunner.php
Normal file
253
updater/MigrationRunner.php
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
<?php
|
||||
namespace Updater;
|
||||
|
||||
/**
|
||||
* MigrationRunner – fuehrt ausschliesslich Updater-eigene SQL-Migrationen aus
|
||||
* (Ordner updater/migrations/). Produktive Schema-Migrationen des Projekts
|
||||
* (database.sql) werden NICHT angefasst.
|
||||
*
|
||||
* DB-Treiber dieses Projekts: MySQL/MariaDB. Die Tracking-Tabelle traegt das
|
||||
* Praefix `_updater_`, damit sie sich nicht mit einer evtl. vorhandenen
|
||||
* `_migrations`-Tabelle beisst.
|
||||
*/
|
||||
class MigrationRunner
|
||||
{
|
||||
/** @var \PDO */
|
||||
private $pdo;
|
||||
private $driver;
|
||||
private $migrationsDir;
|
||||
private $lockFile;
|
||||
|
||||
public function __construct(\PDO $pdo, $migrationsDir, $storageDir)
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
88
updater/README.md
Normal file
88
updater/README.md
Normal file
|
|
@ -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://<host>/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.
|
||||
122
updater/UpdateController.php
Normal file
122
updater/UpdateController.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
namespace Updater;
|
||||
|
||||
/**
|
||||
* UpdateController – bedient die Admin-Seite /admin/update sowie deren
|
||||
* AJAX-Actions (check, install, progress, set_channel, migrations).
|
||||
*
|
||||
* Nutzt die vorhandenen Projekt-Klassen \Database und \Auth per Injection.
|
||||
* Die eigentliche Logik liegt im UpdateManager; dieser Controller ist nur
|
||||
* die duenne Auslieferungs-/Routing-Schicht.
|
||||
*/
|
||||
class UpdateController
|
||||
{
|
||||
/** @var \Database */
|
||||
private $db;
|
||||
/** @var \Auth */
|
||||
private $auth;
|
||||
/** @var UpdateManager */
|
||||
private $manager;
|
||||
|
||||
public function __construct(\Database $db, \Auth $auth)
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
426
updater/UpdateManager.php
Normal file
426
updater/UpdateManager.php
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
<?php
|
||||
namespace Updater;
|
||||
|
||||
/**
|
||||
* UpdateManager – zieht Quellcode + DB-Migrationen aus einem zentralen
|
||||
* Repository ueber einen HTTP-Update-Proxy nach.
|
||||
*
|
||||
* Architektur: [diese App] <-HTTPS-> [Update-Proxy] <-pull- [Git-Repo]
|
||||
* Versions-Identitaet: 40-stelliger Git-Commit-SHA in <storage>/.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-<sha>/"). 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];
|
||||
}
|
||||
}
|
||||
51
updater/UpdaterFactory.php
Normal file
51
updater/UpdaterFactory.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
namespace Updater;
|
||||
|
||||
/**
|
||||
* Zentrale Fabrik fuer den UpdateManager.
|
||||
*
|
||||
* Der Channel wird ausschliesslich aus einer EIGENEN, isolierten Settings-Datei
|
||||
* gelesen (updater/storage/updater-settings.json) – NICHT aus der bestehenden
|
||||
* Settings-/Config-Konvention des Projekts. Beim Rueckbau einfach loeschbar.
|
||||
*
|
||||
* Default-Channel-Fallback existiert NUR hier (eine Stelle).
|
||||
*/
|
||||
final class UpdaterFactory
|
||||
{
|
||||
private static function settingsFile(): string
|
||||
{
|
||||
return __DIR__ . '/storage/updater-settings.json';
|
||||
}
|
||||
|
||||
public static function create(\Database $db, ?AuditLogger $audit = null): UpdateManager
|
||||
{
|
||||
$settings = self::loadSettings();
|
||||
$channel = $settings['channel'] ?? 'stable';
|
||||
return new UpdateManager($db, $audit, $channel);
|
||||
}
|
||||
|
||||
public static function loadSettings(): array
|
||||
{
|
||||
$file = self::settingsFile();
|
||||
if (!is_file($file)) {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($file), true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
public static function saveChannel(string $channel): void
|
||||
{
|
||||
if (!isset(UpdateManager::CHANNELS[$channel])) {
|
||||
$channel = 'stable';
|
||||
}
|
||||
$settings = self::loadSettings();
|
||||
$settings['channel'] = $channel;
|
||||
|
||||
$dir = dirname(self::settingsFile());
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
file_put_contents(self::settingsFile(), json_encode($settings, JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
23
updater/bootstrap.php
Normal file
23
updater/bootstrap.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
/**
|
||||
* Updater-Bootstrap.
|
||||
*
|
||||
* Registriert einen minimalen Autoloader fuer den `Updater\`-Namespace.
|
||||
* Das Projekt hat keinen Composer/PSR-4-Autoloader – dieser Loader ist
|
||||
* vollstaendig isoliert und beeinflusst bestehende require-Aufrufe nicht.
|
||||
*
|
||||
* Beim Rueckbau des Updaters genuegt es, den Ordner `updater/` zu loeschen;
|
||||
* dieser Autoloader verschwindet damit ebenfalls.
|
||||
*/
|
||||
|
||||
spl_autoload_register(function ($class) {
|
||||
$prefix = 'Updater\\';
|
||||
if (strpos($class, $prefix) !== 0) {
|
||||
return;
|
||||
}
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$file = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
3
updater/migrations/.gitkeep
Normal file
3
updater/migrations/.gitkeep
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Updater-eigene SQL-Migrationen liegen hier (Dateien: NNNN_beschreibung.sql).
|
||||
# Produktive Schema-Migrationen des Projekts (database.sql) gehoeren NICHT hierher.
|
||||
# Die Tracking-Tabelle `_updater_migrations` wird automatisch angelegt.
|
||||
29
updater/routes.php
Normal file
29
updater/routes.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
/**
|
||||
* Routen-Registrierung fuer den Updater.
|
||||
*
|
||||
* Dieses Projekt besitzt KEIN zentrales Routing-System (Slim/FastRoute/o.ae.) –
|
||||
* die Auslieferung erfolgt datei-basiert ueber den Webserver. Die Updater-Route
|
||||
* `/admin/update` wird daher durch die reale Datei `admin/update.php`
|
||||
* bereitgestellt (duenner Shim -> \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'],
|
||||
];
|
||||
3
updater/storage/.gitignore
vendored
Normal file
3
updater/storage/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Updater-Laufzeitdaten – nicht versionieren, nur den Ordner behalten.
|
||||
*
|
||||
!.gitignore
|
||||
42
updater/templates/maintenance.html
Normal file
42
updater/templates/maintenance.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="15">
|
||||
<title>Wartungsmodus</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon { font-size: 56px; margin-bottom: 20px; }
|
||||
h1 { color: #333; font-size: 24px; margin-bottom: 12px; }
|
||||
p { color: #666; line-height: 1.6; font-size: 15px; }
|
||||
.hint { margin-top: 24px; font-size: 13px; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">🛠️</div>
|
||||
<h1>Kurz mal Wartung</h1>
|
||||
<p>Es wird gerade ein Update eingespielt. Die Seite ist in wenigen Augenblicken wieder erreichbar.</p>
|
||||
<p class="hint">Diese Seite aktualisiert sich automatisch.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
259
updater/templates/update.php
Normal file
259
updater/templates/update.php
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<?php
|
||||
/**
|
||||
* Admin-UI fuer den Updater. Standalone-Template mit Inline-CSS/JS
|
||||
* (das Projekt hat kein gemeinsames Admin-Layout-/Template-System).
|
||||
*
|
||||
* Verfuegbare Variablen: $manager, $auth, $currentSha, $channel, $csrfToken
|
||||
*
|
||||
* @var \Updater\UpdateManager $manager
|
||||
* @var \Auth $auth
|
||||
* @var string $currentSha
|
||||
* @var string $channel
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
$shortSha = $currentSha !== '' ? substr($currentSha, 0, 7) : 'unbekannt';
|
||||
$channels = \Updater\UpdateManager::CHANNELS;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>System-Update</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.wrap { max-width: 760px; margin: 0 auto; }
|
||||
.topbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.topbar h1 { color: #fff; font-size: 22px; }
|
||||
.topbar a {
|
||||
background: rgba(255,255,255,.18); color: #fff; text-decoration: none;
|
||||
padding: 9px 16px; border-radius: 8px; font-size: 14px;
|
||||
}
|
||||
.card {
|
||||
background: #fff; border-radius: 16px; padding: 28px;
|
||||
box-shadow: 0 14px 40px rgba(0,0,0,.2); margin-bottom: 22px;
|
||||
}
|
||||
.card h2 { font-size: 16px; color: #333; margin-bottom: 16px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.row:last-child { border-bottom: none; }
|
||||
.row .k { color: #777; font-size: 14px; }
|
||||
.row .v { color: #222; font-size: 14px; font-weight: 600; font-family: monospace; }
|
||||
.btn {
|
||||
background: #667eea; color: #fff; border: none; border-radius: 9px;
|
||||
padding: 12px 20px; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
transition: background .2s;
|
||||
}
|
||||
.btn:hover { background: #5568d3; }
|
||||
.btn:disabled { background: #bbb; cursor: not-allowed; }
|
||||
.btn-green { background: #2e9e5b; }
|
||||
.btn-green:hover { background: #257d49; }
|
||||
select {
|
||||
padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;
|
||||
}
|
||||
.muted { color: #888; font-size: 13px; margin-top: 8px; }
|
||||
.progress-wrap { display: none; margin-top: 16px; }
|
||||
.progress-bar { height: 14px; background: #eee; border-radius: 8px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; width: 0; background: #667eea; transition: width .3s; }
|
||||
.progress-msg { font-size: 13px; color: #555; margin-top: 8px; }
|
||||
.alert { padding: 12px 14px; border-radius: 9px; font-size: 14px; margin-top: 14px; display: none; }
|
||||
.alert.show { display: block; }
|
||||
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
|
||||
.alert-ok { background: #efe; border: 1px solid #cfc; color: #2a7; }
|
||||
.changelog {
|
||||
background: #f8f9fb; border-radius: 9px; padding: 14px; margin-top: 14px;
|
||||
font-size: 13px; color: #444; white-space: pre-wrap; max-height: 260px; overflow: auto;
|
||||
display: none;
|
||||
}
|
||||
.mig-item { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; }
|
||||
.badge { padding: 2px 8px; border-radius: 6px; font-size: 12px; font-weight: 600; }
|
||||
.badge-on { background: #e3f6ea; color: #2a7; }
|
||||
.badge-off { background: #fdeaea; color: #c33; }
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.tab { padding: 8px 14px; border-radius: 8px; background: #f0f0f0; cursor: pointer; font-size: 14px; }
|
||||
.tab.active { background: #667eea; color: #fff; }
|
||||
.pane { display: none; }
|
||||
.pane.active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<h1>🔄 System-Update</h1>
|
||||
<a href="/admin/">← Administration</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-pane="update">Update</div>
|
||||
<div class="tab" data-pane="migrations">Migrationen</div>
|
||||
</div>
|
||||
|
||||
<div class="pane active" id="pane-update">
|
||||
<div class="card">
|
||||
<h2>Aktuelle Version</h2>
|
||||
<div class="row"><span class="k">Installierte Version (SHA)</span><span class="v" id="curSha"><?= htmlspecialchars($shortSha) ?></span></div>
|
||||
<div class="row"><span class="k">Update-Channel</span>
|
||||
<span class="v">
|
||||
<select id="channel">
|
||||
<?php foreach ($channels as $key => $url): ?>
|
||||
<option value="<?= htmlspecialchars($key) ?>" <?= $channel === $key ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($key)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">Channel wird in <code>updater/storage/updater-settings.json</code> gespeichert.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Updates</h2>
|
||||
<button class="btn" id="btnCheck">Auf Updates prüfen</button>
|
||||
<button class="btn btn-green" id="btnInstall" style="display:none;">Update installieren</button>
|
||||
|
||||
<div class="changelog" id="changelog"></div>
|
||||
|
||||
<div class="progress-wrap" id="progressWrap">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<div class="progress-msg" id="progressMsg"></div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-error" id="alertError"></div>
|
||||
<div class="alert alert-ok" id="alertOk"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-migrations">
|
||||
<div class="card">
|
||||
<h2>Migrations-Status</h2>
|
||||
<div id="migList"><p class="muted">Wird geladen …</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const CSRF = <?= json_encode($csrfToken) ?>;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function showAlert(type, msg) {
|
||||
const el = type === 'error' ? $('alertError') : $('alertOk');
|
||||
el.textContent = msg; el.classList.add('show');
|
||||
const other = type === 'error' ? $('alertOk') : $('alertError');
|
||||
other.classList.remove('show');
|
||||
}
|
||||
function clearAlerts() { $('alertError').classList.remove('show'); $('alertOk').classList.remove('show'); }
|
||||
|
||||
// Tabs
|
||||
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
|
||||
document.querySelectorAll('.pane').forEach(x => x.classList.remove('active'));
|
||||
t.classList.add('active');
|
||||
$('pane-' + t.dataset.pane).classList.add('active');
|
||||
if (t.dataset.pane === 'migrations') loadMigrations();
|
||||
}));
|
||||
|
||||
// Channel speichern
|
||||
$('channel').addEventListener('change', async (e) => {
|
||||
const body = new URLSearchParams({ action: 'set_channel', channel: e.target.value, csrf_token: CSRF });
|
||||
const r = await fetch('update.php', { method: 'POST', body });
|
||||
if (r.ok) showAlert('ok', 'Channel auf "' + e.target.value + '" gesetzt.');
|
||||
else showAlert('error', 'Channel konnte nicht gespeichert werden.');
|
||||
});
|
||||
|
||||
// Auf Updates pruefen
|
||||
$('btnCheck').addEventListener('click', async () => {
|
||||
clearAlerts();
|
||||
$('btnCheck').disabled = true; $('btnCheck').textContent = 'Prüfe …';
|
||||
try {
|
||||
const r = await fetch('update.php?action=check');
|
||||
const d = await r.json();
|
||||
if (d.error) { showAlert('error', d.error); return; }
|
||||
if (d.has_update) {
|
||||
showAlert('ok', 'Update verfügbar: ' + (d.latest_sha ? d.latest_sha.substring(0,7) : '') +
|
||||
(d.versions_behind ? ' (' + d.versions_behind + ' Commits zurück)' : ''));
|
||||
$('btnInstall').style.display = 'inline-block';
|
||||
if (d.changelog || d.title) {
|
||||
const cl = $('changelog');
|
||||
cl.textContent = (d.title ? d.title + '\n\n' : '') + (d.changelog || '');
|
||||
cl.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
showAlert('ok', 'System ist aktuell.');
|
||||
$('btnInstall').style.display = 'none';
|
||||
$('changelog').style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('error', 'Prüfung fehlgeschlagen: ' + e.message);
|
||||
} finally {
|
||||
$('btnCheck').disabled = false; $('btnCheck').textContent = 'Auf Updates prüfen';
|
||||
}
|
||||
});
|
||||
|
||||
// Update installieren
|
||||
$('btnInstall').addEventListener('click', async () => {
|
||||
if (!confirm('Update jetzt installieren? Die Seite ist während des Updates kurz im Wartungsmodus.')) return;
|
||||
clearAlerts();
|
||||
$('btnInstall').disabled = true; $('btnCheck').disabled = true;
|
||||
$('progressWrap').style.display = 'block';
|
||||
|
||||
let polling = setInterval(pollProgress, 1500);
|
||||
try {
|
||||
const body = new URLSearchParams({ action: 'install', csrf_token: CSRF });
|
||||
const r = await fetch('update.php', { method: 'POST', body });
|
||||
const d = await r.json();
|
||||
clearInterval(polling); pollProgress();
|
||||
if (d.success) {
|
||||
setProgress(100, 'Update abgeschlossen.');
|
||||
showAlert('ok', 'Update erfolgreich installiert (' + (d.sha ? d.sha.substring(0,7) : '') + '). Seite wird neu geladen …');
|
||||
setTimeout(() => location.reload(), 2500);
|
||||
} else {
|
||||
showAlert('error', 'Update fehlgeschlagen: ' + (d.error || 'Unbekannter Fehler'));
|
||||
$('btnInstall').disabled = false; $('btnCheck').disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
clearInterval(polling);
|
||||
showAlert('error', 'Update-Request fehlgeschlagen: ' + e.message);
|
||||
$('btnInstall').disabled = false; $('btnCheck').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function setProgress(pct, msg) {
|
||||
$('progressFill').style.width = pct + '%';
|
||||
$('progressMsg').textContent = msg || '';
|
||||
}
|
||||
async function pollProgress() {
|
||||
try {
|
||||
const r = await fetch('update.php?action=progress');
|
||||
const d = await r.json();
|
||||
setProgress(d.percent || 0, d.message || '');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadMigrations() {
|
||||
const el = $('migList');
|
||||
el.innerHTML = '<p class="muted">Wird geladen …</p>';
|
||||
try {
|
||||
const r = await fetch('update.php?action=migrations');
|
||||
const d = await r.json();
|
||||
if (d.error) { el.innerHTML = '<p class="muted">' + d.error + '</p>'; return; }
|
||||
if (!d.migrations || !d.migrations.length) {
|
||||
el.innerHTML = '<p class="muted">Keine Updater-Migrationen vorhanden.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = d.migrations.map(m =>
|
||||
'<div class="mig-item"><span>' + m.filename + '</span>' +
|
||||
'<span class="badge ' + (m.applied ? 'badge-on">angewandt' : 'badge-off">offen') + '</span></div>'
|
||||
).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = '<p class="muted">Fehler: ' + e.message + '</p>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue