| Server IP : 202.61.199.114 / Your IP : 216.73.217.139 Web Server : nginx/1.22.1 System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64 User : web20 ( 1018) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /var/www/seaside2.pacim.de/web/ |
Upload File : |
<?php
/**
* PaCIM SaaS — Installations-Wizard.
*
* Schritte:
* 1) Server-Anforderungen prüfen (PHP-Version, Extensions, Schreibrechte).
* 2) MySQL-Zugangsdaten abfragen + Verbindung testen.
* 3) Administrator + Unternehmensname festlegen.
* 4) Installation: Datenbank/Schema/Migrationen, Admin-User, Settings,
* Konfig-Datei + Marker schreiben.
* 5) Erfolgsmeldung mit Link zum Login.
*
* Sicherheit: Wenn `app/config/.installed` existiert, ist der Wizard gesperrt
* (Override via `?force=1`).
*/
declare(strict_types=1);
session_start();
$projectRoot = __DIR__;
$configDir = $projectRoot . '/app/config';
$configFile = $configDir . '/database.php';
$installedFile = $configDir . '/.installed';
$schemaFile = $projectRoot . '/database/schema.sql';
$migrationsDir = $projectRoot . '/database/migrations';
$uploadsDir = $projectRoot . '/public/uploads';
$storageDir = $projectRoot . '/storage';
$dataDir = $projectRoot . '/data';
$forceReinstall = isset($_GET['force']) && $_GET['force'] === '1';
$earlyStep = (int)($_GET['step'] ?? $_POST['step'] ?? 0);
// AJAX-Endpoints: Step 4 fragt diese pro Installationsschritt einzeln ab.
// Müssen VOR dem Marker-Block stehen, damit die Frontend-Loop weiterlaufen
// kann, auch wenn der letzte Schritt den Marker schon gesetzt hat.
if (isset($_GET['ajax'])) {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
try {
$action = (string)$_GET['ajax'];
if ($action === 'plan') {
$resp = ['ok' => true, 'steps' => buildInstallPlan($migrationsDir)];
} elseif ($action === 'run') {
$resp = runInstallStep(
(string)($_GET['id'] ?? ''),
$schemaFile,
$migrationsDir,
$configFile,
$installedFile
);
} else {
$resp = ['ok' => false, 'type' => 'error', 'message' => 'Unbekannte AJAX-Action: ' . $action];
}
} catch (Throwable $e) {
$resp = ['ok' => false, 'type' => 'error', 'message' => $e->getMessage()];
}
echo json_encode($resp, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
// Standalone-Aufruf: nur die nginx-Direktiven anzeigen (auch nach Installation
// jederzeit nutzbar — z.B. wenn der Admin sie nachträglich kopieren möchte).
if (isset($_GET['show']) && $_GET['show'] === 'nginx') {
ob_start();
echo '<h2 class="mb-4">nginx-Direktiven (ISPConfig)</h2>';
renderNginxBlock($projectRoot);
echo '<p class="mt-3"><a class="btn btn-light" href="/setup.php">← Zurück zum Wizard</a></p>';
renderShell('Setup · nginx-Konfiguration', (string)ob_get_clean());
exit;
}
// Marker-Check: nach erfolgreicher Installation den Wizard sperren.
// Ausnahme: `?step=5` (Erfolgsseite) und `?force=1` (Neuinstallation).
if (file_exists($installedFile) && !$forceReinstall && $earlyStep !== 5) {
http_response_code(403);
renderShell('Bereits installiert',
'<div class="alert alert-success">' .
'<strong>PaCIM ist bereits installiert.</strong><br>' .
'Marker-Datei: <code>app/config/.installed</code>' .
'</div>' .
'<p>Falls eine Neuinstallation gewünscht ist, lösche die Marker-Datei' .
' oder rufe diese Seite mit <code>?force=1</code> auf:</p>' .
'<p><a class="btn btn-outline-danger" href="?force=1">Wizard trotzdem starten</a> ' .
'<a class="btn btn-outline-secondary" href="?show=nginx">nginx-Direktiven anzeigen</a></p>' .
'<p class="mt-3"><a class="btn btn-primary" href="/?page=login">Zum Login</a></p>'
);
exit;
}
// ---------------------------------------------------------------------------
// Schritte
// ---------------------------------------------------------------------------
$step = (int)($_GET['step'] ?? $_POST['step'] ?? 1);
if ($step < 1 || $step > 5) $step = 1;
$errors = [];
// ---- Step 1: Anforderungen prüfen ----------------------------------------
$requirements = checkRequirements($configDir, $uploadsDir, $storageDir);
$allReqOk = !in_array(false, array_column($requirements, 'ok'), true);
if ($step === 1 && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if ($allReqOk) {
$step = 2;
} else {
$errors[] = 'Bitte zuerst die rot markierten Anforderungen beheben.';
}
}
// ---- Step 2: Datenbank ---------------------------------------------------
$db = $_SESSION['setup']['db'] ?? [
'host' => '127.0.0.1',
'port' => '3306',
'database' => 'pacim_saas',
'username' => '',
'password' => '',
];
if ($step === 2 && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$db = [
'host' => trim((string)($_POST['db_host'] ?? '')),
'port' => trim((string)($_POST['db_port'] ?? '3306')),
'database' => trim((string)($_POST['db_name'] ?? '')),
'username' => trim((string)($_POST['db_user'] ?? '')),
'password' => (string)($_POST['db_pass'] ?? ''),
];
if ($db['host'] === '' || $db['database'] === '' || $db['username'] === '') {
$errors[] = 'Bitte Host, Datenbankname und Benutzer angeben.';
} elseif (!preg_match('/^[A-Za-z0-9_]+$/', $db['database'])) {
$errors[] = 'Datenbankname darf nur Buchstaben, Ziffern und Unterstriche enthalten.';
} else {
// Verbindung ohne dbname → Server erreichbar?
$err = tryServerConnection($db);
if ($err !== null) {
$errors[] = 'MySQL-Verbindung fehlgeschlagen: ' . $err;
} else {
$_SESSION['setup']['db'] = $db;
$step = 3;
}
}
}
// ---- Step 3: Admin + Unternehmen -----------------------------------------
$admin = $_SESSION['setup']['admin'] ?? [
'company' => '',
'first_name' => '',
'last_name' => '',
'email' => '',
'password' => '',
'password2' => '',
];
if ($step === 3 && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$admin = [
'company' => trim((string)($_POST['company'] ?? '')),
'first_name' => trim((string)($_POST['first_name'] ?? '')),
'last_name' => trim((string)($_POST['last_name'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')),
'password' => (string)($_POST['password'] ?? ''),
'password2' => (string)($_POST['password2'] ?? ''),
];
if ($admin['company'] === '') $errors[] = 'Bitte einen Unternehmensnamen angeben.';
if ($admin['first_name'] === '' || $admin['last_name'] === '') $errors[] = 'Bitte Vor- und Nachname des Administrators angeben.';
if (!filter_var($admin['email'], FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige E-Mail-Adresse angeben.';
if (strlen($admin['password']) < 8) $errors[] = 'Das Administrator-Passwort muss mindestens 8 Zeichen lang sein.';
if ($admin['password'] !== $admin['password2']) $errors[] = 'Die beiden Passwörter stimmen nicht überein.';
if (empty($errors)) {
$_SESSION['setup']['admin'] = $admin;
$step = 4;
}
}
// ---- Step 4: Installation läuft AJAX-gesteuert (siehe renderInstallStep + JS).
// Hier nur Sanity-Check, dass die Vorgänger-Schritte abgeschlossen sind.
if ($step === 4) {
if (!isset($_SESSION['setup']['db'], $_SESSION['setup']['admin'])) {
$errors[] = 'Daten aus den vorherigen Schritten fehlen. Bitte den Wizard neu starten.';
$step = 1;
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
$titles = [
1 => '1 / 4 · Anforderungen',
2 => '2 / 4 · Datenbank',
3 => '3 / 4 · Administrator',
4 => '4 / 4 · Installation',
5 => 'Fertig',
];
ob_start();
echo '<div class="d-flex flex-wrap align-items-baseline mb-4 gap-3">';
echo '<h2 class="mb-0">PaCIM SaaS · Installation</h2>';
echo '<span class="badge text-bg-light border">' . htmlspecialchars($titles[$step], ENT_QUOTES, 'UTF-8') . '</span>';
echo '</div>';
renderProgress($step);
if ($errors) {
echo '<div class="alert alert-danger"><ul class="mb-0">';
foreach ($errors as $err) echo '<li>' . htmlspecialchars($err, ENT_QUOTES, 'UTF-8') . '</li>';
echo '</ul></div>';
}
switch ($step) {
case 1:
renderRequirementsStep($requirements);
break;
case 2:
renderDbStep($db);
break;
case 3:
renderAdminStep($admin);
break;
case 4:
renderInstallStep();
break;
case 5:
renderDoneStep([]);
break;
}
$content = ob_get_clean();
renderShell('Setup · ' . $titles[$step], $content);
// ===========================================================================
// Helpers
// ===========================================================================
function checkRequirements(string $configDir, string $uploadsDir, string $storageDir): array
{
$res = [];
$phpOk = version_compare(PHP_VERSION, '8.1.0', '>=');
$res[] = ['name' => 'PHP-Version ≥ 8.1', 'value' => PHP_VERSION, 'ok' => $phpOk];
foreach (['pdo_mysql', 'mbstring', 'session', 'json', 'fileinfo', 'gd', 'openssl', 'zip'] as $ext) {
$res[] = [
'name' => 'PHP-Extension: ' . $ext,
'value' => extension_loaded($ext) ? 'geladen' : 'fehlt',
'ok' => extension_loaded($ext),
];
}
[$procUser, $procGroup] = currentProcessOwner();
foreach ([
'app/config (schreibbar)' => $configDir,
'public/uploads (schreibbar)' => $uploadsDir,
'storage (schreibbar)' => $storageDir,
] as $label => $path) {
if (!is_dir($path)) @mkdir($path, 0775, true);
$ok = is_dir($path) && is_writable($path);
$row = [
'name' => $label,
'value' => is_dir($path) ? $path : 'fehlt',
'ok' => $ok,
];
if (!$ok) {
$row['fix'] = buildPermissionFixCommand($path, $procUser, $procGroup);
}
$res[] = $row;
}
return $res;
}
/**
* Ermittelt den Eigentümer des laufenden PHP-Prozesses (=Web-User).
* Auf ISPConfig+nginx üblich: pro-Site-User wie `web1` mit Gruppe `client1`.
*/
function currentProcessOwner(): array
{
$user = null; $group = null;
if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
$pw = @posix_getpwuid(posix_geteuid());
if (is_array($pw)) $user = $pw['name'] ?? null;
}
if (function_exists('posix_getegid') && function_exists('posix_getgrgid')) {
$gr = @posix_getgrgid(posix_getegid());
if (is_array($gr)) $group = $gr['name'] ?? null;
}
if (!$user) $user = trim((string)@get_current_user()) ?: 'www-data';
if (!$group) $group = $user;
return [$user, $group];
}
function buildPermissionFixCommand(string $path, string $user, string $group): string
{
$shellPath = escapeshellarg($path);
$shellUser = escapeshellarg($user);
$shellGroup = escapeshellarg($group);
// mkdir → chown → chmod als ein kopierbarer One-Liner.
return "sudo mkdir -p $shellPath && "
. "sudo chown -R $shellUser:$shellGroup $shellPath && "
. "sudo chmod -R u+rwX,g+rwX $shellPath";
}
function tryServerConnection(array $db): ?string
{
try {
openPdo($db, false, 5);
return null;
} catch (Throwable $e) {
return $e->getMessage();
}
}
/**
* Öffnet eine PDO-Verbindung mit gepufferten Queries.
* `MYSQL_ATTR_USE_BUFFERED_QUERY = true` ist Pflicht, weil Migrationen wie
* `PREPARE … EXECUTE …` Result-Sets liefern, die sonst die Folge-Statements
* mit „Cannot execute queries while other unbuffered queries are active"
* abbrechen lassen.
*/
function openPdo(array $db, bool $withDb, int $timeout = 10): PDO
{
$dsn = $withDb
? sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$db['host'], $db['port'] ?: '3306', $db['database'])
: sprintf('mysql:host=%s;port=%s;charset=utf8mb4',
$db['host'], $db['port'] ?: '3306');
$opts = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => $timeout,
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
];
return new PDO($dsn, $db['username'], $db['password'], $opts);
}
function stripDbBoilerplate(string $sql): string
{
$sql = preg_replace('/CREATE\s+DATABASE[^;]+;/i', '', $sql) ?? $sql;
$sql = preg_replace('/USE\s+`?[A-Za-z0-9_]+`?\s*;/i', '', $sql) ?? $sql;
return $sql;
}
/**
* Baut den Installations-Plan: Reihenfolge der Schritte, die das AJAX-UI
* sequenziell abruft.
*/
function buildInstallPlan(string $migrationsDir): array
{
$plan = [
['id' => 'test_connection', 'name' => 'MySQL-Server-Verbindung testen'],
['id' => 'check_perms', 'name' => 'Benutzerrechte prüfen (CREATE/DROP)'],
['id' => 'create_database', 'name' => 'Datenbank anlegen (falls fehlt)'],
['id' => 'connect_database', 'name' => 'Mit Datenbank verbinden'],
['id' => 'check_existing', 'name' => 'Bestehende Tabellen prüfen'],
['id' => 'import_schema', 'name' => 'Schema importieren (schema.sql)'],
];
if (is_dir($migrationsDir)) {
$files = glob($migrationsDir . '/*.sql') ?: [];
sort($files, SORT_STRING);
foreach ($files as $f) {
$base = basename($f);
$plan[] = ['id' => 'migration:' . $base, 'name' => 'Migration: ' . $base];
}
}
$plan[] = ['id' => 'create_admin', 'name' => 'Administrator-Konto anlegen'];
$plan[] = ['id' => 'cleanup_demo', 'name' => 'Demo-Benutzer entfernen'];
$plan[] = ['id' => 'save_settings', 'name' => 'Unternehmens-Einstellungen speichern'];
$plan[] = ['id' => 'create_symlinks', 'name' => 'Symlinks für /assets und /uploads anlegen'];
$plan[] = ['id' => 'write_config', 'name' => 'app/config/database.php schreiben'];
// Verify: prüft nach allen Migrationen, ob die erwarteten Tabellen wirklich
// existieren. Schlägt fehl → Marker bleibt aus, Wizard zeigt eine klare
// Fehlermeldung. Verhindert "halbe" Installationen.
$plan[] = ['id' => 'verify_tables', 'name' => 'Erwartete Tabellen überprüfen'];
$plan[] = ['id' => 'write_marker', 'name' => 'Installations-Marker setzen'];
return $plan;
}
/**
* Führt einen einzelnen Installations-Schritt aus.
* Antwort-Format:
* ['ok' => bool, 'type' => 'ok'|'warn'|'skip'|'error', 'message' => string, 'detail' => ?string]
*/
function runInstallStep(string $id, string $schemaFile, string $migrationsDir, string $configFile, string $installedFile): array
{
$db = $_SESSION['setup']['db'] ?? null;
$admin = $_SESSION['setup']['admin'] ?? null;
if ($id !== 'write_marker' && (!$db || !$admin)) {
return ['ok' => false, 'type' => 'error', 'message' => 'Sitzungsdaten verloren — bitte Wizard von vorn starten.'];
}
try {
if (str_starts_with($id, 'migration:')) {
return stepMigration(substr($id, 10), $migrationsDir, (array)$db);
}
return match ($id) {
'test_connection' => stepTestConnection((array)$db),
'check_perms' => stepCheckPerms((array)$db),
'create_database' => stepCreateDatabase((array)$db),
'connect_database' => stepConnectDatabase((array)$db),
'check_existing' => stepCheckExisting((array)$db),
'import_schema' => stepImportSchema((array)$db, $schemaFile),
'create_admin' => stepCreateAdmin((array)$db, (array)$admin),
'cleanup_demo' => stepCleanupDemoUsers((array)$db, (array)$admin),
'save_settings' => stepSaveSettings((array)$db, (array)$admin),
'create_symlinks' => stepCreateSymlinks(),
'write_config' => stepWriteConfig((array)$db, $configFile),
'verify_tables' => stepVerifyTables((array)$db),
'write_marker' => stepWriteMarker($installedFile),
default => ['ok' => false, 'type' => 'error', 'message' => 'Unbekannter Schritt: ' . $id],
};
} catch (Throwable $e) {
return ['ok' => false, 'type' => 'error', 'message' => $e->getMessage()];
}
}
function stepTestConnection(array $db): array
{
openPdo($db, false);
return ['ok' => true, 'type' => 'ok',
'message' => "Verbindung zu {$db['host']}:" . ($db['port'] ?: '3306') . " als {$db['username']} erfolgreich"];
}
function stepCheckPerms(array $db): array
{
$pdo = openPdo($db, false);
$probe = 'pacim_probe_' . bin2hex(random_bytes(4));
try {
$pdo->exec("CREATE DATABASE `$probe` CHARACTER SET utf8mb4");
$pdo->exec("DROP DATABASE `$probe`");
return ['ok' => true, 'type' => 'ok', 'message' => 'CREATE/DROP DATABASE erlaubt'];
} catch (Throwable $e) {
return ['ok' => true, 'type' => 'warn',
'message' => 'Kein CREATE-Recht für neue Datenbanken — DB muss bereits existieren.',
'detail' => $e->getMessage()];
}
}
function stepCreateDatabase(array $db): array
{
$pdo = openPdo($db, false);
$st = $pdo->prepare("SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?");
$st->execute([$db['database']]);
if ($st->fetch()) {
return ['ok' => true, 'type' => 'skip', 'message' => "Datenbank `{$db['database']}` existiert bereits."];
}
$quoted = '`' . str_replace('`', '', $db['database']) . '`';
$pdo->exec("CREATE DATABASE $quoted CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
return ['ok' => true, 'type' => 'ok', 'message' => "Datenbank `{$db['database']}` angelegt."];
}
function stepConnectDatabase(array $db): array
{
openPdo($db, true);
return ['ok' => true, 'type' => 'ok', 'message' => "Verbunden mit `{$db['database']}`."];
}
function stepCheckExisting(array $db): array
{
$pdo = openPdo($db, true);
$tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN);
$n = count($tables);
if ($n === 0) {
return ['ok' => true, 'type' => 'ok', 'message' => 'Datenbank ist leer — frische Installation.'];
}
if (in_array('users', $tables, true)) {
$admins = (int)$pdo->query("SELECT COUNT(*) FROM users WHERE role='admin' AND status='active'")->fetchColumn();
return ['ok' => true, 'type' => 'warn',
'message' => "Bestehende Installation gefunden: $n Tabellen, $admins aktive Admin-Konten.",
'detail' => 'Schema und Migrationen sind idempotent (IF NOT EXISTS). Bestehende Daten bleiben erhalten; der konfigurierte Admin-Account wird aktualisiert.'];
}
return ['ok' => true, 'type' => 'warn',
'message' => "Datenbank enthält bereits $n Tabellen ohne `users`.",
'detail' => 'Tabellen könnten beim Schema-Import kollidieren.'];
}
function stepImportSchema(array $db, string $schemaFile): array
{
if (!is_file($schemaFile)) {
return ['ok' => false, 'type' => 'error', 'message' => 'schema.sql nicht gefunden: ' . $schemaFile];
}
$pdo = openPdo($db, true);
$sql = stripDbBoilerplate((string)file_get_contents($schemaFile));
execSqlScript($pdo, $sql);
return ['ok' => true, 'type' => 'ok', 'message' => 'Schema importiert.'];
}
function stepMigration(string $filename, string $migrationsDir, array $db): array
{
$path = $migrationsDir . '/' . $filename;
if (!is_file($path)) {
return ['ok' => false, 'type' => 'error', 'message' => 'Migration nicht gefunden: ' . $filename];
}
$pdo = openPdo($db, true);
$sql = stripDbBoilerplate((string)file_get_contents($path));
// Pro-Statement-Ausführung: tolerierbare Einzelfehler nicht den ganzen
// Migrations-Schritt abbrechen lassen (sonst stoppt z.B. eine FK-Verletzung
// bei einem Seed-INSERT alles, obwohl die Schema-Änderungen schon ok wären).
$tolerated = [];
$hardError = null;
foreach (splitAndCleanStatements($sql) as $oneSql) {
try {
execOneStatement($pdo, $oneSql);
} catch (Throwable $e) {
if (isTolerableMigrationError($e)) {
$tolerated[] = compactStmt($oneSql) . ' → ' . $e->getMessage();
continue;
}
$hardError = $e->getMessage() . "\n— in Statement: " . compactStmt($oneSql);
break;
}
}
if ($hardError !== null) {
return ['ok' => false, 'type' => 'error', 'message' => $hardError];
}
if ($tolerated) {
return ['ok' => true, 'type' => 'warn',
'message' => count($tolerated) . ' Statement(s) übersprungen (idempotent / Demo-Daten fehlen)',
'detail' => implode("\n", $tolerated)];
}
return ['ok' => true, 'type' => 'ok', 'message' => 'OK'];
}
function splitAndCleanStatements(string $sql): array
{
$sql = preg_replace('!/\*.*?\*/!s', '', $sql) ?? $sql;
$sql = preg_replace('/^\s*--.*$/m', '', $sql) ?? $sql;
$out = [];
foreach (splitSqlStatements($sql) as $s) {
$t = trim($s);
if ($t !== '' && $t !== ';') $out[] = $t;
}
return $out;
}
/**
* Entscheidet, ob ein bei einer Migration aufgetretener Fehler tolerierbar ist.
*
* Verlässlicher als Stringmatching auf die Fehlermeldung (mehrsprachig,
* unterschiedliche Formulierungen je MariaDB/MySQL-Version) ist der
* MySQL-Error-Code. Wir lesen ihn aus `PDOException::$errorInfo[1]`.
*
* Quelle der Codes: MySQL „Server Error Reference".
*/
function isTolerableMigrationError(Throwable $e): bool
{
$mysqlCode = 0;
if ($e instanceof PDOException) {
$info = $e->errorInfo;
if (is_array($info) && isset($info[1])) {
$mysqlCode = (int)$info[1];
}
}
// White-List tolerierbarer Codes — typische Idempotenz-Effekte bei
// wiederholten Migrations-Läufen plus Demo-/Seed-Daten-Lücken in Production.
static $tolerableCodes = [
1007, // ER_DB_CREATE_EXISTS — CREATE DATABASE: DB existiert
1008, // ER_DB_DROP_EXISTS — DROP DATABASE: DB fehlt
1022, // ER_DUP_KEY — Doppelter Schlüssel
1050, // ER_TABLE_EXISTS_ERROR — Tabelle existiert
1051, // ER_BAD_TABLE_ERROR — DROP TABLE: Tabelle fehlt
1054, // ER_BAD_FIELD_ERROR — Unknown column
1060, // ER_DUP_FIELDNAME — ALTER TABLE ADD: Spalte existiert
1061, // ER_DUP_KEYNAME — CREATE INDEX: Name existiert
1062, // ER_DUP_ENTRY — INSERT: duplicate unique value
1068, // ER_MULTIPLE_PRI_KEY — Mehrere PK (Re-Run)
1091, // ER_CANT_DROP_FIELD_OR_KEY — DROP COLUMN/INDEX: existiert nicht
1146, // ER_NO_SUCH_TABLE — Table doesn't exist
1216, // ER_NO_REFERENCED_ROW — FK: Parent-Zeile fehlt
1217, // ER_ROW_IS_REFERENCED — FK: Child-Zeile blockiert DELETE
1452, // ER_NO_REFERENCED_ROW_2 — INSERT: FK-Parent fehlt (z.B. Seed-IDs)
1553, // ER_DROP_INDEX_FK — Index wird von FK gebraucht (ohne IF EXISTS)
1826, // ER_FK_DUP_NAME — CREATE FK: Name existiert
4025, // ER_CHECK_CONSTRAINT_VIOLATED — CHECK-Constraint
];
if (in_array($mysqlCode, $tolerableCodes, true)) return true;
// Defensiv-Fallback: Stringmatching für Treiber/Backends, die keinen Code
// liefern (oder bei Treiber-Internen Fehlern ohne errorInfo).
$msg = strtolower($e->getMessage());
foreach ([
'duplicate',
'already exists',
'check that column/key exists',
'check that it exists',
"doesn't exist",
'no such table',
'no such column',
'unknown column',
'unknown table',
'foreign key constraint fails',
'cannot add or update',
'cannot drop',
'multiple primary key',
] as $needle) {
if (str_contains($msg, $needle)) return true;
}
return false;
}
function compactStmt(string $sql, int $max = 120): string
{
$s = preg_replace('/\s+/', ' ', trim($sql)) ?? $sql;
return mb_strlen($s) > $max ? mb_substr($s, 0, $max) . '…' : $s;
}
function stepCreateAdmin(array $db, array $admin): array
{
$pdo = openPdo($db, true);
$hash = password_hash($admin['password'], PASSWORD_BCRYPT);
$st = $pdo->prepare("SELECT id FROM users WHERE email = :e LIMIT 1");
$st->execute(['e' => $admin['email']]);
$row = $st->fetch();
if ($row) {
$pdo->prepare("UPDATE users SET password=:p, first_name=:f, last_name=:l, role='admin', status='active' WHERE id=:id")
->execute(['p' => $hash, 'f' => $admin['first_name'], 'l' => $admin['last_name'], 'id' => (int)$row['id']]);
return ['ok' => true, 'type' => 'warn', 'message' => 'Bestehenden User aktualisiert: ' . $admin['email']];
}
$pdo->prepare("INSERT INTO users (email, password, first_name, last_name, role, status)
VALUES (:e, :p, :f, :l, 'admin', 'active')")
->execute(['e' => $admin['email'], 'p' => $hash, 'f' => $admin['first_name'], 'l' => $admin['last_name']]);
return ['ok' => true, 'type' => 'ok', 'message' => 'Administrator angelegt: ' . $admin['email']];
}
/**
* Entfernt die Demo-User aus Migration 006 (manager / checkin / accounting /
* admin @ pacim.local). Der gerade angelegte Admin wird per E-Mail-Vergleich
* geschützt, damit der Wizard auch mit `admin@pacim.local` als Login funktioniert.
*/
function stepCleanupDemoUsers(array $db, array $admin): array
{
$pdo = openPdo($db, true);
$demoEmails = ['admin@pacim.local', 'manager@pacim.local', 'checkin@pacim.local', 'accounting@pacim.local'];
$adminEmail = strtolower(trim((string)($admin['email'] ?? '')));
$toDelete = array_values(array_filter($demoEmails, static fn ($e) => strtolower($e) !== $adminEmail));
if (!$toDelete) {
return ['ok' => true, 'type' => 'skip', 'message' => 'Keine Demo-User zum Löschen (Admin verwendet eine Demo-Adresse).'];
}
$placeholders = implode(',', array_fill(0, count($toDelete), '?'));
$stmt = $pdo->prepare("DELETE FROM users WHERE email IN ($placeholders)");
$stmt->execute($toDelete);
$n = $stmt->rowCount();
return ['ok' => true, 'type' => $n > 0 ? 'ok' : 'skip',
'message' => $n > 0 ? "$n Demo-User gelöscht" : 'Keine Demo-User vorhanden',
'detail' => implode(', ', $toDelete)];
}
/**
* Legt Symlinks `<project>/assets` → `<project>/public/assets` und
* `<project>/uploads` → `<project>/public/uploads` an. Damit funktionieren
* Asset-URLs unabhängig von nginx-Alias-Konfiguration (DocRoot = Projekt-Root).
*/
function stepCreateSymlinks(): array
{
$root = __DIR__;
$links = [
$root . '/assets' => $root . '/public/assets',
$root . '/uploads' => $root . '/public/uploads',
];
$created = [];
$skipped = [];
$errors = [];
foreach ($links as $link => $target) {
// Existierende physische Verzeichnisse nicht überschreiben.
if (is_link($link)) {
$skipped[] = basename($link) . ' (Symlink existiert)';
continue;
}
if (file_exists($link)) {
$skipped[] = basename($link) . ' (Pfad existiert als echte Datei/Verzeichnis — nicht überschrieben)';
continue;
}
if (!is_dir($target)) {
@mkdir($target, 0775, true);
}
// Relativer Ziel-Pfad — sonst bricht der Symlink bei Verschieben des Projekts.
$relTarget = 'public/' . basename($link);
if (@symlink($relTarget, $link)) {
$created[] = basename($link) . ' → ' . $relTarget;
} else {
$errors[] = basename($link) . ': symlink() schlug fehl (vermutlich keine Rechte)';
}
}
if ($errors) {
return ['ok' => false, 'type' => 'error',
'message' => 'Symlinks konnten nicht angelegt werden',
'detail' => implode("\n", array_merge($errors, $skipped, $created))
. "\n\nManuell anlegen:\n cd " . $root . "\n ln -s public/assets assets\n ln -s public/uploads uploads"];
}
if (!$created && $skipped) {
return ['ok' => true, 'type' => 'skip',
'message' => 'Symlinks bereits vorhanden — nichts zu tun.',
'detail' => implode("\n", $skipped)];
}
return ['ok' => true, 'type' => 'ok',
'message' => count($created) . ' Symlink(s) angelegt',
'detail' => implode("\n", array_merge($created, $skipped))];
}
function stepSaveSettings(array $db, array $admin): array
{
$pdo = openPdo($db, true);
$pdo->prepare("INSERT INTO settings (config_key, config_value) VALUES ('company_name', :v)
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)")
->execute(['v' => $admin['company']]);
$pdo->prepare("INSERT IGNORE INTO settings (config_key, config_value) VALUES ('currency', 'EUR')")
->execute();
return ['ok' => true, 'type' => 'ok', 'message' => 'Unternehmen: ' . $admin['company']];
}
function stepWriteConfig(array $db, string $configFile): array
{
$cfg = "<?php\nreturn [\n"
. " 'host' => " . var_export($db['host'], true) . ",\n"
. " 'port' => " . var_export((int)($db['port'] ?: 3306), true) . ",\n"
. " 'database' => " . var_export($db['database'], true) . ",\n"
. " 'username' => " . var_export($db['username'], true) . ",\n"
. " 'password' => " . var_export($db['password'], true) . ",\n"
. " 'charset' => 'utf8mb4',\n"
. "];\n";
if (file_put_contents($configFile, $cfg) === false) {
return ['ok' => false, 'type' => 'error', 'message' => 'Konnte ' . $configFile . ' nicht schreiben.'];
}
@chmod($configFile, 0640);
return ['ok' => true, 'type' => 'ok', 'message' => 'Konfigurationsdatei geschrieben.'];
}
/**
* Letzter Sicherheits-Check vor dem Installations-Marker: prüft, ob alle Tabellen
* existieren, die auch der Laufzeit-Health-Check (app/core/HealthCheck.php)
* erwartet. Wird hier zentral ausgewertet, damit die "expected"-Liste an
* EINEM Ort gepflegt wird. Liefert warn (= grün, Banner zeigt es danach) statt
* hartem error, damit ein einzelnes Hänger-Tabelle die Installation nicht
* komplett wegschmeißt.
*/
function stepVerifyTables(array $db): array
{
$pdo = openPdo($db, true);
$rows = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN) ?: [];
$existing = array_map('strval', $rows);
// Quelle für die "soll"-Liste: HealthCheck::EXPECTED_TABLES (Reflection,
// damit wir die Liste hier nicht duplizieren).
$expected = [];
$hcFile = __DIR__ . '/app/core/HealthCheck.php';
if (is_file($hcFile)) {
require_once $hcFile;
if (class_exists('HealthCheck')) {
try {
$ref = new ReflectionClass('HealthCheck');
if ($ref->hasConstant('EXPECTED_TABLES')) {
$expected = (array)$ref->getConstant('EXPECTED_TABLES');
}
} catch (Throwable $e) { /* Reflection ignorieren — leere Liste = Skip */ }
}
}
if (!$expected) {
return ['ok' => true, 'type' => 'skip', 'message' => 'Health-Check-Liste nicht gefunden — überspringe Verify.'];
}
$missing = array_values(array_diff($expected, $existing));
if (!$missing) {
return ['ok' => true, 'type' => 'ok',
'message' => 'Alle ' . count($expected) . ' erwarteten Tabellen vorhanden.'];
}
return ['ok' => true, 'type' => 'warn',
'message' => count($missing) . ' Tabelle(n) fehlen — können später per Health-Banner nachinstalliert werden.',
'detail' => implode(', ', $missing)];
}
function stepWriteMarker(string $installedFile): array
{
if (file_put_contents($installedFile, gmdate('c') . "\n") === false) {
return ['ok' => false, 'type' => 'error', 'message' => 'Konnte Marker nicht schreiben: ' . $installedFile];
}
// Sensible Daten aus der Session entfernen.
unset($_SESSION['setup']);
return ['ok' => true, 'type' => 'ok', 'message' => 'Marker .installed gesetzt — Installation abgeschlossen.'];
}
/**
* Führt ein SQL-Skript Statement für Statement aus.
* Robust gegen Strings mit `;`, einzeilige `-- …`-Kommentare und Blockkommentare.
*
* Wichtig: jedes Statement läuft über `query()`, und alle möglicherweise
* gelieferten Result-Sets werden vollständig konsumiert (`fetchAll` +
* `nextRowset` + `closeCursor`). Sonst wirft die nächste Anweisung
* „SQLSTATE[HY000] 2014 Cannot execute queries while other unbuffered
* queries are active" — typischerweise nach `PREPARE … EXECUTE …` oder
* `CALL stored_procedure(…)`.
*/
function execSqlScript(PDO $pdo, string $sql): void
{
// Blockkommentare entfernen.
$sql = preg_replace('!/\*.*?\*/!s', '', $sql) ?? $sql;
// Zeilenkommentare entfernen.
$sql = preg_replace('/^\s*--.*$/m', '', $sql) ?? $sql;
$stmts = splitSqlStatements($sql);
foreach ($stmts as $stmt) {
$t = trim($stmt);
if ($t === '' || $t === ';') continue;
execOneStatement($pdo, $t);
}
}
/**
* Führt ein einzelnes SQL-Statement aus und räumt ALLE Result-Sets ab,
* damit der Verbindungs-Zustand sauber für die nächste Abfrage ist.
*/
function execOneStatement(PDO $pdo, string $sql): void
{
$st = $pdo->query($sql);
if (!($st instanceof PDOStatement)) return;
do {
try { $st->fetchAll(); } catch (Throwable) { /* kein Rowset */ }
try { $more = $st->nextRowset(); } catch (Throwable) { $more = false; }
} while ($more);
try { $st->closeCursor(); } catch (Throwable) { /* egal */ }
}
/**
* Splittet SQL-Skripte in Einzel-Statements und kennt die `DELIMITER`-Direktive
* des mysql-CLI (nötig für Prozeduren/Trigger, deren Body Semikola enthält).
*
* DROP PROCEDURE IF EXISTS foo;
* DELIMITER //
* CREATE PROCEDURE foo() BEGIN … END//
* DELIMITER ;
*
* String-Quotes (`'`, `"`, backtick) werden respektiert, damit Semikola in
* Werten nicht als Trenner gelten.
*/
function splitSqlStatements(string $sql): array
{
$out = [];
$buf = '';
$delim = ';';
$i = 0;
$len = strlen($sql);
$inStr = false;
$quote = '';
while ($i < $len) {
// Am Zeilenanfang prüfen, ob eine DELIMITER-Direktive vorliegt.
$atLineStart = !$inStr && ($i === 0 || $sql[$i - 1] === "\n");
if ($atLineStart) {
$j = $i;
while ($j < $len && ($sql[$j] === ' ' || $sql[$j] === "\t")) $j++;
if ($j + 9 <= $len
&& strcasecmp(substr($sql, $j, 9), 'DELIMITER') === 0
&& ($j + 9 === $len || $sql[$j + 9] === ' ' || $sql[$j + 9] === "\t")) {
$nl = strpos($sql, "\n", $j + 9);
if ($nl === false) $nl = $len;
$newDelim = trim(substr($sql, $j + 9, $nl - $j - 9));
if ($newDelim !== '') {
if (trim($buf) !== '') { $out[] = $buf; $buf = ''; }
$delim = $newDelim;
$i = $nl + 1;
continue;
}
}
}
$c = $sql[$i];
if ($inStr) {
$buf .= $c;
if ($c === '\\' && $i + 1 < $len) { $buf .= $sql[$i + 1]; $i += 2; continue; }
if ($c === $quote) $inStr = false;
$i++;
continue;
}
if ($c === '\'' || $c === '"' || $c === '`') {
$inStr = true; $quote = $c; $buf .= $c; $i++; continue;
}
// Aktuelles Delimiter-Match (kann mehrere Zeichen umfassen, z.B. "//").
$dlen = strlen($delim);
if ($dlen > 0 && substr_compare($sql, $delim, $i, $dlen) === 0) {
if (trim($buf) !== '') $out[] = $buf;
$buf = '';
$i += $dlen;
continue;
}
$buf .= $c;
$i++;
}
if (trim($buf) !== '') $out[] = $buf;
return $out;
}
// ---------------------------------------------------------------------------
// View-Helpers
// ---------------------------------------------------------------------------
function renderShell(string $title, string $content): void
{
$title = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
echo <<<HTML
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>$title</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<style>
body { background: #f5f6f8; }
.setup-wrap { max-width: 760px; margin: 4rem auto; }
.step-bar { display: flex; gap: 0.5rem; margin-bottom: 2rem; }
.step-bar .step { flex: 1; height: 0.5rem; border-radius: 999px; background: #e3e6ea; }
.step-bar .step.active { background: #0d6efd; }
.step-bar .step.done { background: #198754; }
.req-row { display: flex; justify-content: space-between; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid #eee; }
.req-row:last-child { border-bottom: 0; }
.req-row .name { font-weight: 500; }
.req-row .val { font-family: ui-monospace, Menlo, Consolas, monospace; color: #555; font-size: 0.9em; }
.install-log { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.85em; background: #0e1116; color: #d7dee8; border-radius: 0.5rem; padding: 1rem; }
.install-log .l-ok { color: #6cd1a3; }
.install-log .l-warn { color: #f0c674; }
.install-log .l-error { color: #ff6b6b; }
</style>
</head>
<body>
<div class="setup-wrap">
<div class="card shadow-sm">
<div class="card-body p-4 p-md-5">
$content
</div>
</div>
<p class="text-muted small text-center mt-3">PaCIM SaaS · Installations-Wizard</p>
</div>
</body>
</html>
HTML;
}
function renderProgress(int $step): void
{
$total = 4;
$cur = min($step, $total);
echo '<div class="step-bar">';
for ($i = 1; $i <= $total; $i++) {
$cls = $i < $cur ? 'step done' : ($i === $cur ? 'step active' : 'step');
echo '<div class="' . $cls . '"></div>';
}
echo '</div>';
}
function renderRequirementsStep(array $reqs): void
{
$allOk = !in_array(false, array_column($reqs, 'ok'), true);
echo '<p class="text-muted">Vor der Installation werden Server-Voraussetzungen geprüft. ' .
'Bitte alle Punkte als <strong class="text-success">OK</strong> bestätigen.</p>';
echo '<div class="border rounded p-3 mb-4">';
$fixCmds = [];
foreach ($reqs as $r) {
$icon = $r['ok'] ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
echo '<div class="req-row">';
echo ' <div><span class="me-2">' . $icon . '</span><span class="name">' . htmlspecialchars($r['name'], ENT_QUOTES, 'UTF-8') . '</span></div>';
echo ' <div class="val">' . htmlspecialchars((string)$r['value'], ENT_QUOTES, 'UTF-8') . '</div>';
echo '</div>';
if (!empty($r['fix'])) {
$fixCmds[] = $r['fix'];
echo '<div class="ms-4 mt-1 mb-2">';
echo ' <div class="small text-muted mb-1">Konsolen-Befehl, um Rechte zu setzen:</div>';
echo ' <div class="position-relative">';
echo ' <button type="button" class="btn btn-sm btn-outline-secondary position-absolute top-0 end-0 m-1"';
echo ' onclick="navigator.clipboard.writeText(this.nextElementSibling.innerText).then(()=>{this.innerText=\'Kopiert\';setTimeout(()=>this.innerText=\'Kopieren\',1500);});">Kopieren</button>';
echo ' <pre class="install-log mb-0" style="white-space: pre-wrap; word-break: break-all;">' .
htmlspecialchars($r['fix'], ENT_QUOTES, 'UTF-8') . '</pre>';
echo ' </div>';
echo '</div>';
}
}
echo '</div>';
// Sammel-Befehl: alle Permission-Fixes zu einer Zeile zusammenfassen.
if (count($fixCmds) > 1) {
$combined = implode(" && \\\n", $fixCmds);
echo '<div class="alert alert-warning">';
echo ' <strong class="d-block mb-2"><i class="bi bi-terminal me-1"></i>Alle fehlenden Rechte in einem Aufruf:</strong>';
echo ' <div class="position-relative">';
echo ' <button type="button" class="btn btn-sm btn-outline-dark position-absolute top-0 end-0 m-1"';
echo ' onclick="navigator.clipboard.writeText(this.nextElementSibling.innerText).then(()=>{this.innerText=\'Kopiert\';setTimeout(()=>this.innerText=\'Kopieren\',1500);});">Kopieren</button>';
echo ' <pre class="install-log mb-2" style="white-space: pre-wrap; word-break: break-all;">' .
htmlspecialchars($combined, ENT_QUOTES, 'UTF-8') . '</pre>';
echo ' </div>';
echo ' <div class="small mt-2">Danach diese Seite mit <i class="bi bi-arrow-clockwise"></i> neu laden.</div>';
echo '</div>';
}
echo '<form method="post"><input type="hidden" name="step" value="1">';
echo '<button type="submit" class="btn btn-primary" ' . ($allOk ? '' : 'disabled') . '>Weiter <i class="bi bi-arrow-right"></i></button>';
if (!$allOk) {
echo ' <a href="?step=1" class="btn btn-light ms-2"><i class="bi bi-arrow-clockwise"></i> Erneut prüfen</a>';
}
echo '</form>';
}
function renderDbStep(array $db): void
{
$h = static fn ($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
echo '<p class="text-muted">Zugangsdaten zur MySQL-/MariaDB-Datenbank. Die Datenbank wird angelegt, falls sie noch nicht existiert (der Benutzer braucht dafür <code>CREATE</code>-Recht).</p>';
echo '<form method="post" class="row g-3">';
echo '<input type="hidden" name="step" value="2">';
echo '<div class="col-md-8"><label class="form-label">Host</label><input class="form-control" name="db_host" required value="' . $h($db['host']) . '"></div>';
echo '<div class="col-md-4"><label class="form-label">Port</label><input class="form-control" name="db_port" value="' . $h($db['port']) . '"></div>';
echo '<div class="col-12"><label class="form-label">Datenbank-Name</label><input class="form-control" name="db_name" required value="' . $h($db['database']) . '"></div>';
echo '<div class="col-md-6"><label class="form-label">Benutzer</label><input class="form-control" name="db_user" required value="' . $h($db['username']) . '"></div>';
echo '<div class="col-md-6"><label class="form-label">Passwort</label><input type="password" class="form-control" name="db_pass" value="' . $h($db['password']) . '"></div>';
echo '<div class="col-12 d-flex justify-content-between">';
echo ' <a class="btn btn-light" href="?step=1">Zurück</a>';
echo ' <button type="submit" class="btn btn-primary">Verbindung testen & weiter <i class="bi bi-arrow-right"></i></button>';
echo '</div>';
echo '</form>';
}
function renderAdminStep(array $admin): void
{
$h = static fn ($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
echo '<p class="text-muted">Administrator-Konto und Anzeigename des Unternehmens. Du kannst beides später unter <em>Einstellungen</em> ändern.</p>';
echo '<form method="post" class="row g-3">';
echo '<input type="hidden" name="step" value="3">';
echo '<div class="col-12"><label class="form-label">Unternehmensname</label><input class="form-control" name="company" required value="' . $h($admin['company']) . '"></div>';
echo '<div class="col-md-6"><label class="form-label">Vorname</label><input class="form-control" name="first_name" required value="' . $h($admin['first_name']) . '"></div>';
echo '<div class="col-md-6"><label class="form-label">Nachname</label><input class="form-control" name="last_name" required value="' . $h($admin['last_name']) . '"></div>';
echo '<div class="col-12"><label class="form-label">E-Mail (Login)</label><input type="email" class="form-control" name="email" required value="' . $h($admin['email']) . '"></div>';
echo '<div class="col-md-6"><label class="form-label">Passwort</label><input type="password" class="form-control" name="password" required minlength="8"></div>';
echo '<div class="col-md-6"><label class="form-label">Passwort wiederholen</label><input type="password" class="form-control" name="password2" required minlength="8"></div>';
echo '<div class="col-12 d-flex justify-content-between">';
echo ' <a class="btn btn-light" href="?step=2">Zurück</a>';
echo ' <button type="submit" class="btn btn-primary">Jetzt installieren <i class="bi bi-rocket-takeoff"></i></button>';
echo '</div>';
echo '</form>';
}
/**
* Step 4 — AJAX-getriebenes Installations-UI.
* Holt zuerst den Plan, rendert die Schritte und arbeitet sie sequentiell ab.
* Bei Fehlern pausiert die Loop und zeigt „Erneut versuchen / Überspringen / Abbrechen".
*/
function renderInstallStep(): void
{
?>
<p class="text-muted">Jeder Schritt liefert eine Live-Statusmeldung. Bei einem Fehler kannst du den Schritt wiederholen, überspringen oder abbrechen — die Installation läuft dann an der Stelle weiter.</p>
<div id="install-progress" class="border rounded p-2 mb-3" style="max-height: 28rem; overflow-y: auto;"></div>
<div id="install-controls"></div>
<style>
.ins-row { display: grid; grid-template-columns: 1.6rem 1fr auto; align-items: start; padding: 0.4rem 0.5rem; gap: 0.5rem; border-bottom: 1px dashed #eee; }
.ins-row:last-child { border-bottom: 0; }
.ins-icon { font-size: 1rem; line-height: 1.3; width: 1.6rem; text-align: center; }
.ins-name { font-weight: 500; }
.ins-msg { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.8em; color: #666; display: block; }
.ins-detail { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.75em; color: #888; white-space: pre-wrap; word-break: break-word; background: #f7f8fa; border-radius: 4px; padding: 0.4rem 0.6rem; margin-top: 0.25rem; }
.ins-row.is-running { background: #fff8e1; }
.ins-row.is-error { background: #fdecea; }
.spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<script>
(async function () {
const list = document.getElementById('install-progress');
const controls = document.getElementById('install-controls');
const iconFor = (type) => ({
ok: '<i class="bi bi-check-circle-fill text-success"></i>',
warn: '<i class="bi bi-exclamation-triangle-fill text-warning"></i>',
skip: '<i class="bi bi-skip-forward-fill text-muted"></i>',
error: '<i class="bi bi-x-circle-fill text-danger"></i>',
pending: '<i class="bi bi-circle text-muted"></i>',
running: '<span class="spin"><i class="bi bi-arrow-clockwise text-primary"></i></span>',
}[type] || '<i class="bi bi-circle text-muted"></i>');
function renderRow(step) {
const row = document.createElement('div');
row.className = 'ins-row';
row.id = 'row-' + cssId(step.id);
row.innerHTML =
'<div class="ins-icon">' + iconFor('pending') + '</div>' +
'<div><div class="ins-name"></div><span class="ins-msg"></span><div class="ins-detail" style="display:none"></div></div>' +
'<div class="text-muted small ins-time"></div>';
row.querySelector('.ins-name').textContent = step.name;
list.appendChild(row);
return row;
}
function cssId(s) { return s.replace(/[^a-zA-Z0-9_-]/g, '_'); }
function setRow(row, state, message, detail) {
row.classList.toggle('is-running', state === 'running');
row.classList.toggle('is-error', state === 'error');
row.querySelector('.ins-icon').innerHTML = iconFor(state);
row.querySelector('.ins-msg').textContent = message || '';
const dEl = row.querySelector('.ins-detail');
if (detail) { dEl.textContent = detail; dEl.style.display = ''; }
else { dEl.textContent = ''; dEl.style.display = 'none'; }
}
async function callRun(stepId) {
const r = await fetch('?ajax=run&id=' + encodeURIComponent(stepId), { credentials: 'same-origin' });
if (!r.ok) {
return { ok: false, type: 'error', message: 'HTTP ' + r.status + ' ' + r.statusText };
}
// Antwort vorsichtig parsen — PHP kann bei harten Fatals HTML statt JSON liefern.
const text = await r.text();
try { return JSON.parse(text); }
catch (e) { return { ok: false, type: 'error', message: 'Ungültige Server-Antwort', detail: text.slice(0, 4000) }; }
}
function askUserChoice(info) {
return new Promise((resolve) => {
controls.innerHTML =
'<div class="alert alert-danger">' +
' <strong><i class="bi bi-x-octagon me-2"></i>Fehler:</strong> ' +
' <span id="err-msg"></span>' +
' <div id="err-detail" class="install-log mt-2" style="display:none; white-space: pre-wrap;"></div>' +
'</div>' +
'<div class="d-flex flex-wrap gap-2">' +
' <button class="btn btn-primary" id="btn-retry"><i class="bi bi-arrow-clockwise me-1"></i>Erneut versuchen</button>' +
' <button class="btn btn-outline-secondary" id="btn-skip"><i class="bi bi-skip-forward me-1"></i>Überspringen</button>' +
' <button class="btn btn-outline-danger" id="btn-abort"><i class="bi bi-stop-circle me-1"></i>Abbrechen</button>' +
'</div>';
document.getElementById('err-msg').textContent = info.message || '';
if (info.detail) {
const d = document.getElementById('err-detail');
d.textContent = info.detail;
d.style.display = '';
}
document.getElementById('btn-retry').onclick = () => { controls.innerHTML = ''; resolve('retry'); };
document.getElementById('btn-skip').onclick = () => { controls.innerHTML = ''; resolve('skip'); };
document.getElementById('btn-abort').onclick = () => { controls.innerHTML = '<div class="alert alert-secondary">Installation abgebrochen. Status oben spiegelt den letzten Stand.</div>'; resolve('abort'); };
});
}
function showDone() {
controls.innerHTML =
'<div class="alert alert-success">' +
'<strong><i class="bi bi-check-circle me-2"></i>Installation abgeschlossen.</strong> ' +
'Du kannst dich jetzt anmelden.' +
'</div>' +
'<p><a class="btn btn-primary btn-lg" href="?step=5">Weiter zur Übersicht <i class="bi bi-arrow-right"></i></a> ' +
'<a class="btn btn-light" href="/?page=login">Direkt zum Login</a></p>';
}
// --- Plan laden + Liste rendern ---
let plan;
try {
const pr = await fetch('?ajax=plan', { credentials: 'same-origin' });
plan = await pr.json();
} catch (e) {
controls.innerHTML = '<div class="alert alert-danger">Plan konnte nicht geladen werden: ' + e.message + '</div>';
return;
}
if (!plan.ok || !Array.isArray(plan.steps)) {
controls.innerHTML = '<div class="alert alert-danger">Plan-Antwort ungültig.</div>';
return;
}
const rows = plan.steps.map(renderRow);
// --- Loop ---
for (let i = 0; i < plan.steps.length; i++) {
const step = plan.steps[i];
const row = rows[i];
setRow(row, 'running', 'läuft …');
let resolved = false;
while (!resolved) {
const t0 = performance.now();
const data = await callRun(step.id);
const dt = (performance.now() - t0) / 1000;
row.querySelector('.ins-time').textContent = dt.toFixed(2) + 's';
if (data && data.ok) {
setRow(row, data.type || 'ok', data.message || 'OK', data.detail || '');
resolved = true;
break;
}
// Fehler → pausieren, User entscheiden lassen.
setRow(row, 'error', data && data.message ? data.message : 'Unbekannter Fehler', data && data.detail ? data.detail : '');
const choice = await askUserChoice({ message: data && data.message, detail: data && data.detail });
if (choice === 'retry') {
setRow(row, 'running', 'läuft …');
continue;
}
if (choice === 'skip') {
setRow(row, 'skip', '(übersprungen) ' + (data && data.message ? data.message : ''), data && data.detail ? data.detail : '');
resolved = true;
break;
}
// abort
return;
}
}
showDone();
})();
</script>
<?php
}
function renderDoneStep(array $log): void
{
echo '<div class="alert alert-success">';
echo '<strong><i class="bi bi-check-circle me-2"></i>Installation abgeschlossen.</strong>';
echo '</div>';
echo '<p>Du kannst dich jetzt mit deinen Administrator-Zugangsdaten anmelden.</p>';
echo '<p class="mt-3"><a class="btn btn-primary btn-lg" href="/?page=login">Zum Login <i class="bi bi-box-arrow-in-right"></i></a></p>';
// nginx-Direktiven für ISPConfig direkt mit ausgeben.
echo '<hr class="my-4">';
renderNginxBlock(__DIR__);
if ($log) {
echo '<details class="mt-4"><summary class="small text-muted">Installations-Log anzeigen</summary>';
echo '<div class="install-log mt-2">';
foreach ($log as $entry) {
$cls = 'l-' . $entry['type'];
$msg = htmlspecialchars($entry['msg'], ENT_QUOTES, 'UTF-8');
echo '<div class="' . $cls . '">[' . strtoupper($entry['type']) . '] ' . $msg . '</div>';
}
echo '</div></details>';
}
echo '<p class="text-muted small mt-4">Tipp: Lösche <code>setup.php</code> nach erfolgreicher Installation aus dem Web-Root.</p>';
}
/**
* Rendert einen Block mit nginx-Direktiven, die in das ISPConfig-Feld
* „Optionen → nginx Directives" der Website eingefügt werden sollen.
* Die Snippets nehmen an, dass der DocumentRoot auf das Projekt-Verzeichnis
* zeigt (dort, wo `index.php` + `setup.php` liegen).
*/
function renderNginxBlock(string $projectRoot): void
{
$docroot = htmlspecialchars($projectRoot, ENT_QUOTES, 'UTF-8');
$snippet = <<<NGINX
# ====================================================================
# PaCIM SaaS — nginx Directives
# In ISPConfig: Sites → <Website> → Options → „nginx Directives"
# DocumentRoot der Website muss auf das Projekt-Verzeichnis zeigen:
# $docroot
# (PHP-FPM-Handler ist in ISPConfig schon konfiguriert — NICHT überschreiben.)
# ====================================================================
# CORS-Preflight (OPTIONS) für die REST-API. ISPConfig blockt non-GET/POST
# standardmäßig mit 405 — diese Regel fängt das vor dem Block ab und
# antwortet mit 204. Die eigentlichen API-Antworten setzen CORS-Header in PHP.
if (\$request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Requested-With, Accept' always;
add_header 'Access-Control-Max-Age' '3600' always;
add_header 'Content-Type' 'text/plain; charset=UTF-8' always;
add_header 'Content-Length' '0' always;
return 204;
}
# Default-Einstiegspunkt
index index.php;
# Saubere URLs: alle nicht-physischen Pfade an /index.php weiterreichen.
# (Front-Controller — der entscheidet zwischen Setup und App.)
location / {
try_files \$uri \$uri/ /index.php?\$args;
}
# Statische Assets liefert nginx direkt aus public/ aus (schneller als der PHP-Fallback).
location ^~ /assets/ {
alias \$document_root/public/assets/;
try_files \$uri =404;
access_log off;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
location ^~ /uploads/ {
alias \$document_root/public/uploads/;
try_files \$uri =404;
}
# Sensible Anwendungs-Verzeichnisse strikt blockieren.
location ~ ^/(app|database|storage|data|scripts|backups|docs)/ {
deny all;
return 404;
}
# Sensible Dateiendungen blockieren (SQL-Dumps, Logs, Shell-Skripte, Backups …).
location ~* \\.(sql|md|log|sh|bak|swp|env|lock)$ {
deny all;
return 404;
}
# Versteckte Dateien wie .git, .installed, .htaccess — bis auf .well-known/.
location ~ /\\.(?!well-known) {
deny all;
return 404;
}
# OPTIONAL: Nach erfolgreicher Erstinstallation den Wizard sperren.
# Auskommentieren oder Marker `app/config/.installed` löschen, um ihn wieder freizugeben.
#location = /setup.php {
# deny all;
# return 404;
#}
# OPTIONAL: Security-Header (HSTS bitte erst nach gültigem HTTPS einschalten).
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
NGINX;
echo '<h5 class="mb-2"><i class="bi bi-server me-2"></i>nginx-Direktiven für ISPConfig</h5>';
echo '<p class="text-muted small mb-2">Diese Zeilen in der ISPConfig-Weboberfläche unter <em>Sites → Website → Options → „nginx Directives"</em> einfügen und speichern. ISPConfig schreibt sie in den Server-Block des nginx-vHost.</p>';
echo '<div class="position-relative">';
echo ' <button type="button" class="btn btn-sm btn-outline-secondary position-absolute top-0 end-0 m-2"';
echo ' onclick="navigator.clipboard.writeText(document.getElementById(\'nginx-snippet\').innerText).then(()=>{this.innerText=\'Kopiert\';setTimeout(()=>this.innerText=\'Kopieren\',1500);});">';
echo ' Kopieren';
echo ' </button>';
echo ' <pre id="nginx-snippet" class="install-log mb-0" style="max-height: 28rem; overflow:auto; white-space: pre;">';
echo htmlspecialchars($snippet, ENT_QUOTES, 'UTF-8');
echo '</pre>';
echo '</div>';
echo '<p class="text-muted small mt-2">Hinweis: Der DocumentRoot der ISPConfig-Website muss auf <code>' . $docroot . '</code> zeigen (das Verzeichnis mit <code>index.php</code> + <code>setup.php</code>).</p>';
}