Merge pull request #6 from friloo/claude/ecstatic-cannon-H5hGy
Claude/ecstatic cannon h5h gy
This commit is contained in:
commit
15dc87cb2d
30 changed files with 1680 additions and 36 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
error_reporting(E_ALL);
|
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__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
|
@ -37,7 +38,7 @@ if (isset($_GET['ajax_stats'])) {
|
||||||
$controller = new UniFiController(
|
$controller = new UniFiController(
|
||||||
$site['unifi_controller_url'],
|
$site['unifi_controller_url'],
|
||||||
$site['unifi_username'],
|
$site['unifi_username'],
|
||||||
$site['unifi_password'],
|
Crypto::decrypt($site['unifi_password']),
|
||||||
$site['site_id']
|
$site['site_id']
|
||||||
);
|
);
|
||||||
$controller->syncVouchersToDatabase($db, $site['id']);
|
$controller->syncVouchersToDatabase($db, $site['id']);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
error_reporting(E_ALL);
|
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__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
error_reporting(E_ALL);
|
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__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
|
@ -44,7 +45,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_site'])) {
|
||||||
// Mit neuem Passwort aktualisieren
|
// Mit neuem Passwort aktualisieren
|
||||||
$db->execute(
|
$db->execute(
|
||||||
"UPDATE sites SET name = ?, site_id = ?, unifi_controller_url = ?, unifi_username = ?, unifi_password = ?, public_access = ? WHERE id = ?",
|
"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 {
|
} else {
|
||||||
// Ohne Passwort-Änderung
|
// Ohne Passwort-Änderung
|
||||||
|
|
@ -88,7 +89,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_site'])) {
|
||||||
$db->execute(
|
$db->execute(
|
||||||
"INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, public_access)
|
"INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, public_access)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
[$name, $siteId, $controllerUrl, $username, $password, $publicAccess]
|
[$name, $siteId, $controllerUrl, $username, Crypto::encrypt($password), $publicAccess]
|
||||||
);
|
);
|
||||||
|
|
||||||
$success = 'Site erfolgreich hinzugefügt!';
|
$success = 'Site erfolgreich hinzugefügt!';
|
||||||
|
|
|
||||||
25
admin/update.php
Normal file
25
admin/update.php
Normal 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();
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
error_reporting(E_ALL);
|
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__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
error_reporting(E_ALL);
|
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__ . '/../config.php';
|
||||||
require_once __DIR__ . '/../includes/Database.php';
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
|
@ -68,7 +69,7 @@ if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
|
||||||
$controller = new UniFiController(
|
$controller = new UniFiController(
|
||||||
$site['unifi_controller_url'],
|
$site['unifi_controller_url'],
|
||||||
$site['unifi_username'],
|
$site['unifi_username'],
|
||||||
$site['unifi_password'],
|
Crypto::decrypt($site['unifi_password']),
|
||||||
$site['site_id']
|
$site['site_id']
|
||||||
);
|
);
|
||||||
$controller->syncVouchersToDatabase($db, $siteId);
|
$controller->syncVouchersToDatabase($db, $siteId);
|
||||||
|
|
@ -149,7 +150,7 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
|
||||||
$controller = new UniFiController(
|
$controller = new UniFiController(
|
||||||
$site['unifi_controller_url'],
|
$site['unifi_controller_url'],
|
||||||
$site['unifi_username'],
|
$site['unifi_username'],
|
||||||
$site['unifi_password'],
|
Crypto::decrypt($site['unifi_password']),
|
||||||
$site['site_id']
|
$site['site_id']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ define('DB_NAME', '');
|
||||||
define('DB_USER', '');
|
define('DB_USER', '');
|
||||||
define('DB_PASS', '');
|
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
|
// Sitzungs-Einstellungen
|
||||||
define('SESSION_LIFETIME', 3600); // 1 Stunde
|
define('SESSION_LIFETIME', 3600); // 1 Stunde
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ try {
|
||||||
$controller = new UniFiController(
|
$controller = new UniFiController(
|
||||||
$site['unifi_controller_url'],
|
$site['unifi_controller_url'],
|
||||||
$site['unifi_username'],
|
$site['unifi_username'],
|
||||||
$site['unifi_password'],
|
Crypto::decrypt($site['unifi_password']),
|
||||||
$site['site_id']
|
$site['site_id']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,19 @@ class Auth {
|
||||||
|
|
||||||
// Prüfen ob eingeloggt
|
// Prüfen ob eingeloggt
|
||||||
public function isLoggedIn() {
|
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
|
// Prüfen ob Admin
|
||||||
|
|
|
||||||
117
includes/Crypto.php
Normal file
117
includes/Crypto.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/Crypto.php';
|
||||||
|
|
||||||
class UniFiController {
|
class UniFiController {
|
||||||
private $controllerUrl;
|
private $controllerUrl;
|
||||||
private $username;
|
private $username;
|
||||||
|
|
@ -169,16 +171,44 @@ class UniFiController {
|
||||||
throw new Exception("Voucher konnte nicht erstellt werden");
|
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();
|
$vouchers = $this->getVouchers();
|
||||||
|
|
||||||
if (empty($vouchers)) {
|
if (empty($vouchers)) {
|
||||||
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
|
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neuesten Voucher zurückgeben
|
$latestVoucher = null;
|
||||||
$latestVoucher = reset($vouchers);
|
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 [
|
return [
|
||||||
'code' => $latestVoucher['code'],
|
'code' => $latestVoucher['code'],
|
||||||
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),
|
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),
|
||||||
|
|
|
||||||
52
index.php
52
index.php
|
|
@ -1,7 +1,16 @@
|
||||||
<?php
|
<?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 für Debugging
|
||||||
error_reporting(E_ALL);
|
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__ . '/config.php';
|
||||||
require_once __DIR__ . '/includes/Database.php';
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
|
|
@ -13,6 +22,32 @@ $auth = new Auth();
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$mailer = new Mailer();
|
$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
|
// Settings laden
|
||||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
$logoUrl = $db->getSetting('logo_url', '');
|
$logoUrl = $db->getSetting('logo_url', '');
|
||||||
|
|
@ -67,8 +102,14 @@ if ($auth->isLoggedIn()) {
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
||||||
if (!$publicAccess && !$auth->isLoggedIn()) {
|
if (!$publicAccess && !$auth->isLoggedIn()) {
|
||||||
$error = 'Sie müssen angemeldet sein';
|
$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';
|
$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 {
|
} else {
|
||||||
try {
|
try {
|
||||||
$siteId = (int)($_POST['site_id'] ?? 0);
|
$siteId = (int)($_POST['site_id'] ?? 0);
|
||||||
|
|
@ -114,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
||||||
$controller = new UniFiController(
|
$controller = new UniFiController(
|
||||||
$site['unifi_controller_url'],
|
$site['unifi_controller_url'],
|
||||||
$site['unifi_username'],
|
$site['unifi_username'],
|
||||||
$site['unifi_password'],
|
Crypto::decrypt($site['unifi_password']),
|
||||||
$site['site_id']
|
$site['site_id']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -558,9 +599,8 @@ $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
|
||||||
<!-- FIX: Trigger-Feld kommt nicht mehr vom Submit-Button -->
|
<!-- FIX: Trigger-Feld kommt nicht mehr vom Submit-Button -->
|
||||||
<input type="hidden" name="create_voucher" value="1">
|
<input type="hidden" name="create_voucher" value="1">
|
||||||
|
|
||||||
<?php if ($auth->isLoggedIn()): ?>
|
<!-- CSRF-Token fuer alle (auch anonyme oeffentliche Erstellung) -->
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="voucher_name">Voucher-Name *</label>
|
<label for="voucher_name">Voucher-Name *</label>
|
||||||
|
|
|
||||||
29
install.php
29
install.php
|
|
@ -1,9 +1,27 @@
|
||||||
<?php
|
<?php
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
// Prüfen ob bereits installiert
|
// Prüfen ob bereits installiert.
|
||||||
if (file_exists(__DIR__ . '/config.php') && !isset($_GET['reinstall'])) {
|
// Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres
|
||||||
die('System bereits installiert. Wenn Sie neu installieren möchten, löschen Sie die config.php oder rufen Sie install.php?reinstall=1 auf.');
|
// 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;
|
$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_NAME', '{$db['name']}');\n";
|
||||||
$configContent .= "define('DB_USER', '{$db['user']}');\n";
|
$configContent .= "define('DB_USER', '{$db['user']}');\n";
|
||||||
$configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\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 .= "// Sitzungs-Einstellungen\n";
|
||||||
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
|
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
|
||||||
$configContent .= "// Zeitzone\n";
|
$configContent .= "// Zeitzone\n";
|
||||||
|
|
@ -140,7 +161,7 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
|
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
|
||||||
$htaccess = "# UniFi Voucher System\n\n";
|
$htaccess = "# UniFi Voucher System\n\n";
|
||||||
$htaccess .= "# Security\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 .= " Order Allow,Deny\n";
|
||||||
$htaccess .= " Deny from all\n";
|
$htaccess .= " Deny from all\n";
|
||||||
$htaccess .= "</FilesMatch>\n\n";
|
$htaccess .= "</FilesMatch>\n\n";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
// Error Reporting (kann nach erfolgreicher Einrichtung entfernt werden)
|
// Error Reporting (kann nach erfolgreicher Einrichtung entfernt werden)
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
// Absolute Pfade verwenden
|
// Absolute Pfade verwenden
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
// Umfassendes Error Reporting
|
// Umfassendes Error Reporting
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
ini_set('log_errors', 1);
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
// Versuche Dateien zu laden
|
// Versuche Dateien zu laden
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
error_reporting(E_ALL);
|
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__ . '/config.php';
|
||||||
require_once __DIR__ . '/includes/Database.php';
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
|
|
@ -36,8 +37,18 @@ if (isset($_GET['error'])) {
|
||||||
|
|
||||||
// Authorization Code erhalten
|
// Authorization Code erhalten
|
||||||
if (isset($_GET['code'])) {
|
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'];
|
$code = $_GET['code'];
|
||||||
|
|
||||||
// Token anfordern
|
// Token anfordern
|
||||||
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token";
|
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
<?php
|
<?php
|
||||||
error_reporting(E_ALL);
|
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__ . '/config.php';
|
||||||
require_once __DIR__ . '/includes/Database.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();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
|
@ -78,4 +84,22 @@ $redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
|
||||||
<h2>1. Konfiguration Status</h2>
|
<h2>1. Konfiguration Status</h2>
|
||||||
<p>Client ID: <?= !empty($clientId) ? '<span class="ok">✓ Gesetzt</span>' : '<span class="error">✗ Fehlt</span>' ?></p>
|
<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>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>
|
||||||
15
test.php
15
test.php
|
|
@ -1,6 +1,17 @@
|
||||||
<?php
|
<?php
|
||||||
error_reporting(E_ALL);
|
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>";
|
echo "<h1>System Test</h1>";
|
||||||
|
|
||||||
|
|
@ -111,7 +122,7 @@ try {
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => json_encode([
|
CURLOPT_POSTFIELDS => json_encode([
|
||||||
'username' => $site['unifi_username'],
|
'username' => $site['unifi_username'],
|
||||||
'password' => $site['unifi_password']
|
'password' => Crypto::decrypt($site['unifi_password'])
|
||||||
]),
|
]),
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
|
|
||||||
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