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:
Claude 2026-06-05 18:51:51 +00:00
parent 3483da274f
commit 43149074c9
No known key found for this signature in database
14 changed files with 1374 additions and 0 deletions

253
updater/MigrationRunner.php Normal file
View 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);
}
}