Merge pull request #6 from friloo/claude/ecstatic-cannon-H5hGy

Claude/ecstatic cannon h5h gy
This commit is contained in:
friloo 2026-06-05 20:56:51 +02:00 committed by GitHub
commit 15dc87cb2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1680 additions and 36 deletions

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
@ -37,7 +38,7 @@ if (isset($_GET['ajax_stats'])) {
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$controller->syncVouchersToDatabase($db, $site['id']);

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
@ -44,7 +45,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_site'])) {
// Mit neuem Passwort aktualisieren
$db->execute(
"UPDATE sites SET name = ?, site_id = ?, unifi_controller_url = ?, unifi_username = ?, unifi_password = ?, public_access = ? WHERE id = ?",
[$name, $siteIdStr, $controllerUrl, $username, $password, $publicAccess, $siteId]
[$name, $siteIdStr, $controllerUrl, $username, Crypto::encrypt($password), $publicAccess, $siteId]
);
} else {
// Ohne Passwort-Änderung
@ -88,7 +89,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_site'])) {
$db->execute(
"INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, public_access)
VALUES (?, ?, ?, ?, ?, ?)",
[$name, $siteId, $controllerUrl, $username, $password, $publicAccess]
[$name, $siteId, $controllerUrl, $username, Crypto::encrypt($password), $publicAccess]
);
$success = 'Site erfolgreich hinzugefügt!';

25
admin/update.php Normal file
View file

@ -0,0 +1,25 @@
<?php
/**
* Admin-Entry-Point fuer den Updater (URL: /admin/update.php).
*
* Bewusst sehr duenn gehalten: laedt nur die Projekt-Basis + den isolierten
* Updater-Bootstrap und delegiert alles an \Updater\UpdateController.
*
* Beim Rueckbau des Updaters kann diese Datei mitgeloescht werden.
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../updater/bootstrap.php';
$auth = new Auth();
$auth->requireAdmin();
$db = Database::getInstance();
$controller = new \Updater\UpdateController($db, $auth);
$controller->handle();

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
@ -68,7 +69,7 @@ if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$controller->syncVouchersToDatabase($db, $siteId);
@ -149,7 +150,7 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);

View file

@ -6,6 +6,11 @@ define('DB_NAME', '');
define('DB_USER', '');
define('DB_PASS', '');
// Anwendungs-Schluessel fuer Verschluesselung-at-rest (UniFi-Passwoerter).
// Wird vom Installer automatisch mit einem zufaelligen Wert befuellt.
// Leer = keine Verschluesselung (Klartext, Legacy-Verhalten).
define('APP_KEY', '');
// Sitzungs-Einstellungen
define('SESSION_LIFETIME', 3600); // 1 Stunde

View file

@ -172,7 +172,7 @@ try {
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);

View file

@ -159,7 +159,19 @@ class Auth {
// Prüfen ob eingeloggt
public function isLoggedIn() {
return isset($_SESSION['user_id']) && isset($_SESSION['login_time']);
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
return false;
}
// Absolutes Session-Timeout durchsetzen (SESSION_LIFETIME aus config.php).
// Bisher wurde die Lebensdauer nie geprueft Sessions liefen unbegrenzt.
$lifetime = defined('SESSION_LIFETIME') ? (int)SESSION_LIFETIME : 3600;
if ($lifetime > 0 && (time() - (int)$_SESSION['login_time']) > $lifetime) {
$this->logout();
return false;
}
return true;
}
// Prüfen ob Admin

117
includes/Crypto.php Normal file
View file

@ -0,0 +1,117 @@
<?php
/**
* Crypto - Symmetrische Verschluesselung fuer sensible Felder (z.B. UniFi-Passwoerter).
*
* Designziele:
* - Verschluesselung-at-rest mit einem Schluessel (APP_KEY) aus der config.php.
* - Vollstaendige Abwaertskompatibilitaet: Bestehende Installationen ohne APP_KEY
* und bereits im Klartext gespeicherte Werte funktionieren unveraendert weiter.
* decrypt() gibt Werte, die nicht unserem Ciphertext-Format entsprechen,
* unveraendert zurueck (Klartext-Passthrough).
* - encrypt() verschluesselt nur, wenn ein APP_KEY vorhanden ist sonst Passthrough.
*
* Format des Ciphertexts: "enc:v1:" . base64(nonce|ciphertext)
*/
class Crypto {
private const PREFIX = 'enc:v1:';
/** Liefert den 32-Byte-Schluessel oder null, wenn kein/ungueltiger APP_KEY gesetzt ist. */
private static function key() {
if (!defined('APP_KEY') || APP_KEY === '') {
return null;
}
$key = base64_decode(APP_KEY, true);
if ($key === false || strlen($key) !== 32) {
return null;
}
return $key;
}
/** Erzeugt einen neuen, base64-kodierten 32-Byte-Schluessel fuer die config.php. */
public static function generateKey() {
return base64_encode(random_bytes(32));
}
/**
* Verschluesselt einen Klartext. Ohne gueltigen APP_KEY wird der Wert
* unveraendert zurueckgegeben (kein Bruch bestehender Installationen).
*/
public static function encrypt($plaintext) {
if ($plaintext === null || $plaintext === '') {
return $plaintext;
}
$key = self::key();
if ($key === null) {
return $plaintext; // Kein Schluessel -> Klartext (Legacy-Verhalten)
}
// Bevorzugt libsodium (PHP-Core seit 7.2), sonst OpenSSL.
if (function_exists('sodium_crypto_secretbox')) {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plaintext, $nonce, $key);
return self::PREFIX . base64_encode($nonce . $cipher);
}
if (function_exists('openssl_encrypt')) {
$ivLen = openssl_cipher_iv_length('aes-256-gcm');
$iv = random_bytes($ivLen);
$tag = '';
$cipher = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($cipher === false) {
return $plaintext;
}
return self::PREFIX . base64_encode($iv . $tag . $cipher);
}
// Keine Krypto-Funktion verfuegbar -> Klartext (besser als Datenverlust)
return $plaintext;
}
/**
* Entschluesselt einen Wert. Nicht-verschluesselte Werte (Legacy/Klartext)
* werden unveraendert zurueckgegeben.
*/
public static function decrypt($value) {
if ($value === null || $value === '' || strpos($value, self::PREFIX) !== 0) {
return $value; // Klartext-Passthrough
}
$key = self::key();
if ($key === null) {
return $value;
}
$raw = base64_decode(substr($value, strlen(self::PREFIX)), true);
if ($raw === false) {
return $value;
}
if (function_exists('sodium_crypto_secretbox_open')) {
$nonceLen = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
if (strlen($raw) <= $nonceLen) {
return $value;
}
$nonce = substr($raw, 0, $nonceLen);
$cipher = substr($raw, $nonceLen);
$plain = sodium_crypto_secretbox_open($cipher, $nonce, $key);
return $plain === false ? $value : $plain;
}
if (function_exists('openssl_decrypt')) {
$ivLen = openssl_cipher_iv_length('aes-256-gcm');
$tagLen = 16;
if (strlen($raw) <= $ivLen + $tagLen) {
return $value;
}
$iv = substr($raw, 0, $ivLen);
$tag = substr($raw, $ivLen, $tagLen);
$cipher = substr($raw, $ivLen + $tagLen);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
return $plain === false ? $value : $plain;
}
return $value;
}
/** Prueft, ob ein Wert bereits in unserem verschluesselten Format vorliegt. */
public static function isEncrypted($value) {
return is_string($value) && strpos($value, self::PREFIX) === 0;
}
}

View file

@ -1,4 +1,6 @@
<?php
require_once __DIR__ . '/Crypto.php';
class UniFiController {
private $controllerUrl;
private $username;
@ -169,16 +171,44 @@ class UniFiController {
throw new Exception("Voucher konnte nicht erstellt werden");
}
// Voucher-Code abrufen
// Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher
// unsortiert zurueck ein blindes reset() kann bei parallelen
// Erstellungen den falschen (fremden) Code liefern. Daher gezielt
// nach dem soeben erstellten Voucher suchen: gleiche note + neueste
// create_time.
$vouchers = $this->getVouchers();
if (empty($vouchers)) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
}
// Neuesten Voucher zurückgeben
$latestVoucher = reset($vouchers);
$latestVoucher = null;
foreach ($vouchers as $voucher) {
// Nur Voucher mit passender Notiz beruecksichtigen
if (($voucher['note'] ?? null) !== $voucherName) {
continue;
}
if ($latestVoucher === null
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
$latestVoucher = $voucher;
}
}
// Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen),
// den global neuesten Voucher nehmen.
if ($latestVoucher === null) {
foreach ($vouchers as $voucher) {
if ($latestVoucher === null
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
$latestVoucher = $voucher;
}
}
}
if ($latestVoucher === null || empty($latestVoucher['code'])) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
}
return [
'code' => $latestVoucher['code'],
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),

View file

@ -1,7 +1,16 @@
<?php
// Updater maintenance hook — see updater/README.md
$maintenanceFile = __DIR__ . '/updater/storage/.maintenance';
if (file_exists($maintenanceFile)) {
http_response_code(503);
require __DIR__ . '/updater/templates/maintenance.html';
exit;
}
// Error Reporting für Debugging
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
@ -13,6 +22,32 @@ $auth = new Auth();
$db = Database::getInstance();
$mailer = new Mailer();
/**
* Session-basierter Throttle fuer die anonyme oeffentliche Voucher-Erstellung.
* Erlaubt max. 10 Erstellungen in 10 Minuten pro Session. Verhindert, dass
* der oeffentliche Modus zum Spammen des UniFi-Controllers missbraucht wird.
*/
function isVoucherRateLimited() {
$window = 600; // 10 Minuten
$maxRequests = 10;
$now = time();
$timestamps = $_SESSION['voucher_create_times'] ?? [];
// Nur Eintraege innerhalb des Zeitfensters behalten
$timestamps = array_values(array_filter($timestamps, function ($t) use ($now, $window) {
return ($now - $t) < $window;
}));
if (count($timestamps) >= $maxRequests) {
$_SESSION['voucher_create_times'] = $timestamps;
return true;
}
$timestamps[] = $now;
$_SESSION['voucher_create_times'] = $timestamps;
return false;
}
// Settings laden
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
@ -67,8 +102,14 @@ if ($auth->isLoggedIn()) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
if (!$publicAccess && !$auth->isLoggedIn()) {
$error = 'Sie müssen angemeldet sein';
} elseif ($auth->isLoggedIn() && !$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
// CSRF wird jetzt fuer ALLE geprueft auch fuer anonyme oeffentliche
// Erstellung (Token wird per Session auch ohne Login vergeben).
$error = 'Ungültiges Sicherheits-Token';
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
// Einfacher Session-basierter Throttle gegen Missbrauch/Spam im
// oeffentlichen Modus (kein Login = kein Benutzerkontext).
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
} else {
try {
$siteId = (int)($_POST['site_id'] ?? 0);
@ -114,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
@ -558,9 +599,8 @@ $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
<!-- FIX: Trigger-Feld kommt nicht mehr vom Submit-Button -->
<input type="hidden" name="create_voucher" value="1">
<?php if ($auth->isLoggedIn()): ?>
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<?php endif; ?>
<!-- CSRF-Token fuer alle (auch anonyme oeffentliche Erstellung) -->
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
<div class="form-group">
<label for="voucher_name">Voucher-Name *</label>

View file

@ -1,9 +1,27 @@
<?php
session_start();
// Prüfen ob bereits installiert
if (file_exists(__DIR__ . '/config.php') && !isset($_GET['reinstall'])) {
die('System bereits installiert. Wenn Sie neu installieren möchten, löschen Sie die config.php oder rufen Sie install.php?reinstall=1 auf.');
// Prüfen ob bereits installiert.
// Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres
// erreichbar sein sonst koennte jeder die Konfiguration ueberschreiben und
// einen neuen Admin anlegen. Reinstall ist nur fuer angemeldete Admins erlaubt.
if (file_exists(__DIR__ . '/config.php')) {
if (!isset($_GET['reinstall'])) {
die('System bereits installiert. Eine Neuinstallation ist nur fuer angemeldete Administratoren ueber install.php?reinstall=1 moeglich.');
}
// Reinstall angefordert -> Admin-Authentifizierung erzwingen
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
try {
$reinstallAuth = new Auth();
if (!$reinstallAuth->isAdmin()) {
die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator <a href="login.php">anmelden</a>.');
}
} catch (Exception $e) {
die('Neuinstallation nicht moeglich (Konfigurationsfehler).');
}
}
$step = isset($_POST['step']) ? (int)$_POST['step'] : 1;
@ -130,6 +148,9 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
$configContent .= "define('DB_NAME', '{$db['name']}');\n";
$configContent .= "define('DB_USER', '{$db['user']}');\n";
$configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\n";
$configContent .= "// Anwendungs-Schluessel fuer Verschluesselung-at-rest (z.B. UniFi-Passwoerter)\n";
$configContent .= "// NICHT aendern, sonst koennen bestehende verschluesselte Werte nicht mehr gelesen werden.\n";
$configContent .= "define('APP_KEY', '" . base64_encode(random_bytes(32)) . "');\n\n";
$configContent .= "// Sitzungs-Einstellungen\n";
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
$configContent .= "// Zeitzone\n";
@ -140,7 +161,7 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
$htaccess = "# UniFi Voucher System\n\n";
$htaccess .= "# Security\n";
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|\\.md)$\">\n";
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n";
$htaccess .= " Order Allow,Deny\n";
$htaccess .= " Deny from all\n";
$htaccess .= "</FilesMatch>\n\n";

View file

@ -1,7 +1,8 @@
<?php
// Error Reporting (kann nach erfolgreicher Einrichtung entfernt werden)
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
// Absolute Pfade verwenden
require_once __DIR__ . '/config.php';

View file

@ -1,7 +1,8 @@
<?php
// Umfassendes Error Reporting
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('log_errors', 1);
// Versuche Dateien zu laden

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
@ -36,8 +37,18 @@ if (isset($_GET['error'])) {
// Authorization Code erhalten
if (isset($_GET['code'])) {
// OAuth-State validieren (CSRF-Schutz). Der State wurde in login.php erzeugt
// und in der Session hinterlegt; er muss exakt zurueckkommen.
$sessionState = $_SESSION['m365_state'] ?? '';
$returnedState = $_GET['state'] ?? '';
unset($_SESSION['m365_state']); // One-Time-Token, nach Pruefung verbrauchen
if ($sessionState === '' || !hash_equals($sessionState, $returnedState)) {
die("Ungültiger oder fehlender Sicherheits-Token (OAuth state). Bitte erneut anmelden.<br><a href='login.php'>Zurück zum Login</a>");
}
$code = $_GET['code'];
// Token anfordern
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token";

View file

@ -1,9 +1,15 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
// Diagnose-Seite nur fuer angemeldete Admins (leakt sonst M365-Konfiguration)
$auth = new Auth();
$auth->requireAdmin();
$db = Database::getInstance();
@ -78,4 +84,22 @@ $redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
<h2>1. Konfiguration Status</h2>
<p>Client ID: <?= !empty($clientId) ? '<span class="ok">✓ Gesetzt</span>' : '<span class="error">✗ Fehlt</span>' ?></p>
<p>Client Secret: <?= !empty($clientSecret) ? '<span class="ok">✓ Gesetzt</span>' : '<span class="error">✗ Fehlt</span>' ?></p>
<p>Tenant ID: <?= !empty($tenant
<p>Tenant ID: <?= !empty($tenantId) ? '<span class="ok">✓ Gesetzt</span>' : '<span class="error">✗ Fehlt</span>' ?></p>
</div>
<div class="section">
<h2>2. Redirect URI</h2>
<p>Diese URI muss exakt in der Azure-App-Registrierung hinterlegt sein:</p>
<div class="url"><?= htmlspecialchars($redirectUri) ?></div>
</div>
<div class="section">
<h2>3. Hinweise</h2>
<ul>
<li>Alle drei Werte (Client ID, Client Secret, Tenant ID) müssen gesetzt sein.</li>
<li>Die Redirect URI in Azure AD muss exakt mit der obigen übereinstimmen.</li>
<li>Benötigte API-Berechtigungen: <code>openid</code>, <code>profile</code>, <code>email</code>, <code>User.Read</code>.</li>
</ul>
</div>
</body>
</html>

View file

@ -1,6 +1,17 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
// Diagnose-Seite nur fuer angemeldete Admins zugaenglich (verhindert Info-Leak)
if (file_exists(__DIR__ . '/config.php')) {
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/Crypto.php';
$auth = new Auth();
$auth->requireAdmin();
}
echo "<h1>System Test</h1>";
@ -111,7 +122,7 @@ try {
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'username' => $site['unifi_username'],
'password' => $site['unifi_password']
'password' => Crypto::decrypt($site['unifi_password'])
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,

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

88
updater/README.md Normal file
View 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 28,
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.

View 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
View 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];
}
}

View 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
View 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;
}
});

View 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
View 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
View file

@ -0,0 +1,3 @@
# Updater-Laufzeitdaten nicht versionieren, nur den Ordner behalten.
*
!.gitignore

View 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>

View 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>