From fe6b4631354225bdf0b6c1ec10b4fb48ced911e9 Mon Sep 17 00:00:00 2001 From: Maaxxs <61059039+MaaxxsDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:06:12 +0200 Subject: [PATCH] Add first-time setup wizard (/setup) Passwortgeschuetzter Assistent fuer die Ersteinrichtung auf einem neuen Server: Datenbank-Zugangsdaten testen & speichern, Datenbankstruktur anlegen, ersten Admin-Account erstellen. Sperrt sich danach dauerhaft selbst, sobald ein Admin-Account existiert. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 4 + hifi-src/src/App.jsx | 6 + hifi-src/src/components/SetupGate.jsx | 47 +++ hifi-src/src/pages/public/SetupWizard.jsx | 341 +++++++++++++++++++ hifi/api/config/config.php | 3 + hifi/api/config/setup.php.example | 11 + hifi/api/public/index.php | 10 + hifi/api/src/Controllers/SetupController.php | 209 ++++++++++++ 8 files changed, 631 insertions(+) create mode 100644 hifi-src/src/components/SetupGate.jsx create mode 100644 hifi-src/src/pages/public/SetupWizard.jsx create mode 100644 hifi/api/config/setup.php.example create mode 100644 hifi/api/src/Controllers/SetupController.php diff --git a/.gitignore b/.gitignore index 67a03a9..68182b7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ # unterscheidet sich zwischen Dev-/Live-Umgebung und gehört daher nicht ins Repo) /hifi/api/config/db.php +# Einmalpasswort für den Ersteinrichtungs-Assistenten (siehe setup.php.example) - +# pro Umgebung unterschiedlich und darf nicht öffentlich einsehbar sein +/hifi/api/config/setup.php + # Claude-Code-Tooling (Skills/lokale Einstellungen) - gehört nicht zum Website-Projekt /hifi/.claude/ /hifi/.agents/ diff --git a/hifi-src/src/App.jsx b/hifi-src/src/App.jsx index 8a168b7..81c3a9a 100644 --- a/hifi-src/src/App.jsx +++ b/hifi-src/src/App.jsx @@ -1,5 +1,6 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import ScrollToTop from './components/ScrollToTop.jsx'; +import SetupGate from './components/SetupGate.jsx'; import PublicLayout from './components/PublicLayout.jsx'; import AdminLayout from './components/AdminLayout.jsx'; import ProtectedRoute from './components/ProtectedRoute.jsx'; @@ -14,6 +15,7 @@ import ContactPage from './pages/public/ContactPage.jsx'; import Impressum from './pages/public/Impressum.jsx'; import Datenschutz from './pages/public/Datenschutz.jsx'; import AGB from './pages/public/AGB.jsx'; +import SetupWizard from './pages/public/SetupWizard.jsx'; import Login from './pages/admin/Login.jsx'; import Dashboard from './pages/admin/Dashboard.jsx'; @@ -37,7 +39,10 @@ export default function App() { return ( <> + + } /> + }> } /> } /> @@ -78,6 +83,7 @@ export default function App() { + ); } diff --git a/hifi-src/src/components/SetupGate.jsx b/hifi-src/src/components/SetupGate.jsx new file mode 100644 index 0000000..c5678f8 --- /dev/null +++ b/hifi-src/src/components/SetupGate.jsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { api } from '../api/client.js'; + +// Prüft bei jedem Seitenaufruf, ob die Ersteinrichtung (Datenbank, Struktur, erster +// Admin-Account) schon abgeschlossen ist. Falls nicht, wird - egal welche URL +// aufgerufen wurde - auf /setup umgeleitet. Ist die Einrichtung bereits +// abgeschlossen, kommt man umgekehrt nicht mehr auf /setup rein. +// +// Der Status wird zusammen mit dem Pfad gespeichert, für den er ermittelt wurde: +// direkt nach einem Pfadwechsel ist der zuletzt bekannte Stand noch der des ALTEN +// Pfads. Würde man den sofort auswerten, könnte das kurzzeitig zu einer falschen +// Umleitung führen (z.B. zurück auf /setup, obwohl man gerade fertig eingerichtet +// hat und eigentlich zu /admin/login navigiert). Deshalb wird erst wieder +// entschieden, wenn der Stand nachweislich für den aktuellen Pfad frisch ist. +export default function SetupGate({ children }) { + const [state, setState] = useState({ pathname: null, needsSetup: null }); + const location = useLocation(); + + useEffect(() => { + let cancelled = false; + api + .get('/setup/status') + .then((status) => { + if (!cancelled) setState({ pathname: location.pathname, needsSetup: status.needs_setup }); + }) + .catch(() => { + if (!cancelled) setState({ pathname: location.pathname, needsSetup: false }); + }); + return () => { + cancelled = true; + }; + }, [location.pathname]); + + if (state.pathname !== location.pathname || state.needsSetup === null) { + return
; + } + + if (state.needsSetup && location.pathname !== '/setup') { + return ; + } + if (!state.needsSetup && location.pathname === '/setup') { + return ; + } + + return children; +} diff --git a/hifi-src/src/pages/public/SetupWizard.jsx b/hifi-src/src/pages/public/SetupWizard.jsx new file mode 100644 index 0000000..b22e6ff --- /dev/null +++ b/hifi-src/src/pages/public/SetupWizard.jsx @@ -0,0 +1,341 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api/client.js'; +import usePageMeta from '../../hooks/usePageMeta.js'; +import logo from '../../assets/logo.png'; + +const emptyDbForm = { host: '', name: '', user: '', password: '', charset: 'utf8mb4' }; +const emptyAdminForm = { username: '', password: '', password_confirmation: '' }; + +export default function SetupWizard() { + usePageMeta({ title: 'Ersteinrichtung', path: '/setup' }); + + const [phase, setPhase] = useState('loading'); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const [password, setPassword] = useState(''); + const [dbForm, setDbForm] = useState(emptyDbForm); + const [adminForm, setAdminForm] = useState(emptyAdminForm); + const [migrateActions, setMigrateActions] = useState(null); + + // Wird NACH erfolgreicher Passwort-Prüfung (und nach jedem Schritt) aufgerufen, + // um zu bestimmen, bei welchem Schritt der Assistent weitermachen soll. + const loadStepStatus = () => + api.get('/setup/status').then((status) => { + if (!status.needs_setup) { + setPhase('locked'); + } else if (status.steps.admin.ok) { + setPhase('done'); + } else if (!status.steps.schema.ok) { + setPhase(status.steps.database.ok ? 'schema' : 'database'); + } else { + setPhase('admin'); + } + return status; + }); + + // Initialer Check beim Laden der Seite: nur ermitteln, ob überhaupt noch eine + // Einrichtung nötig ist (kein Passwort erforderlich) - Details gibt's erst danach. + useEffect(() => { + api + .get('/setup/status') + .then((status) => setPhase(status.needs_setup ? 'password' : 'locked')) + .catch(() => setPhase('locked')); + }, []); + + const handlePassword = async (e) => { + e.preventDefault(); + setError(''); + setBusy(true); + try { + await api.post('/setup/verify-password', { password }); + await loadStepStatus(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const handleDatabase = async (e) => { + e.preventDefault(); + setError(''); + setBusy(true); + try { + await api.post('/setup/database', dbForm); + await loadStepStatus(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const handleMigrate = async () => { + setError(''); + setBusy(true); + try { + const result = await api.post('/setup/migrate', {}); + setMigrateActions(result.actions); + if (result.status.steps.schema.ok) { + setPhase('admin'); + } + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const handleAdmin = async (e) => { + e.preventDefault(); + setError(''); + if (adminForm.password !== adminForm.password_confirmation) { + setError('Die Passwörter stimmen nicht überein'); + return; + } + setBusy(true); + try { + await api.post('/setup/admin', adminForm); + setPhase('done'); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const inputClass = + 'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900'; + const labelClass = 'mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300'; + + const steps = [ + { key: 'database', label: 'Datenbank' }, + { key: 'schema', label: 'Struktur' }, + { key: 'admin', label: 'Admin-Account' }, + ]; + const currentStepIndex = steps.findIndex((s) => s.key === phase); + + return ( +
+ HifiPlanet + +
+ {phase === 'loading' &&

Lädt…

} + + {phase === 'locked' && ( +
+

Bereits eingerichtet

+

+ Diese Seite wurde bereits vollständig eingerichtet. Der Assistent ist deshalb gesperrt. +

+ + Zum Login + +
+ )} + + {['database', 'schema', 'admin'].includes(phase) && ( +
+ {steps.map((s, i) => ( +
+ + {i < currentStepIndex ? '✓' : i + 1} + + {i < steps.length - 1 && } +
+ ))} +
+ )} + + {phase === 'password' && ( +
+

Ersteinrichtung

+

+ Gib das Einmalpasswort ein, das in config/setup.php hinterlegt ist. +

+
+ + setPassword(e.target.value)} + required + autoFocus + className={inputClass} + /> +
+ {error &&

{error}

} + +
+ )} + + {phase === 'database' && ( +
+

Datenbank-Zugangsdaten

+

+ Trage die Zugangsdaten der Datenbank auf diesem Server ein. Die Verbindung wird vor dem Speichern + getestet. +

+
+ + setDbForm({ ...dbForm, host: e.target.value })} + className={inputClass} + /> +
+
+ + setDbForm({ ...dbForm, name: e.target.value })} + className={inputClass} + /> +
+
+ + setDbForm({ ...dbForm, user: e.target.value })} + className={inputClass} + /> +
+
+ + setDbForm({ ...dbForm, password: e.target.value })} + className={inputClass} + /> +
+
+ + setDbForm({ ...dbForm, charset: e.target.value })} + className={inputClass} + /> +
+ {error &&

{error}

} + +
+ )} + + {phase === 'schema' && ( +
+

Datenbankstruktur

+

+ Die Datenbankverbindung funktioniert. Jetzt legen wir die benötigten Tabellen an. +

+ {migrateActions && ( +
    + {migrateActions.map((a, i) => ( +
  • {a}
  • + ))} +
+ )} + {error &&

{error}

} + +
+ )} + + {phase === 'admin' && ( +
+

Erster Admin-Account

+

+ Lege den ersten Super-Admin-Account an, mit dem du dich anschließend einloggen kannst. +

+
+ + setAdminForm({ ...adminForm, username: e.target.value })} + className={inputClass} + /> +
+
+ + setAdminForm({ ...adminForm, password: e.target.value })} + className={inputClass} + /> +
+
+ + setAdminForm({ ...adminForm, password_confirmation: e.target.value })} + className={inputClass} + /> +
+ {error &&

{error}

} + +
+ )} + + {phase === 'done' && ( +
+

Fertig!

+

+ Die Ersteinrichtung ist abgeschlossen. Du kannst dich jetzt mit deinem neuen Admin-Account anmelden. +

+ + Zum Login + +
+ )} +
+
+ ); +} diff --git a/hifi/api/config/config.php b/hifi/api/config/config.php index 002d74f..fe3c2b5 100644 --- a/hifi/api/config/config.php +++ b/hifi/api/config/config.php @@ -4,6 +4,9 @@ return [ // Ausgelagert, damit das Admin-Panel (Einstellungen -> Datenbank) diese Datei // gezielt neu schreiben kann, ohne den Rest dieser Konfiguration anzufassen. 'db' => require __DIR__ . '/db.php', + // Einmalpasswort für den Ersteinrichtungs-Assistenten unter /setup (siehe setup.php.example). + // Liegt in einer eigenen, nicht versionierten Datei, genau wie db.php. + 'setup_password' => is_file(__DIR__ . '/setup.php') ? require __DIR__ . '/setup.php' : null, 'mail' => [ // Vom Betreiber auszufüllen (z.B. eigenes Postfach oder Transactional-Mail-Anbieter). // Solange 'host' leer ist, wird der Mailversand übersprungen (Anfrage wird trotzdem in der DB gespeichert). diff --git a/hifi/api/config/setup.php.example b/hifi/api/config/setup.php.example new file mode 100644 index 0000000..6bdf391 --- /dev/null +++ b/hifi/api/config/setup.php.example @@ -0,0 +1,11 @@ +post('/settings/database', $perm('settings.manage', fn($p) => DatabaseC $router->get('/maintenance', fn($p) => MaintenanceController::status()); $router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update())); +// Ersteinrichtungs-Assistent (/setup) - bewusst ohne Login, da es ja noch keinen +// Admin gibt. Jeder schreibende Endpunkt prüft selbst, ob wirklich noch kein +// Admin-Account existiert (siehe SetupController::requireUnlocked). +$router->get('/setup/status', fn($p) => SetupController::status()); +$router->post('/setup/verify-password', fn($p) => SetupController::verifyPassword()); +$router->post('/setup/database', fn($p) => SetupController::saveDatabase()); +$router->post('/setup/migrate', fn($p) => SetupController::migrateSchema()); +$router->post('/setup/admin', fn($p) => SetupController::createAdmin()); + $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $apiBase = $config['app']['base_path'] . '/api'; $route = '/' . ltrim(substr($path, strlen($apiBase)), '/'); diff --git a/hifi/api/src/Controllers/SetupController.php b/hifi/api/src/Controllers/SetupController.php new file mode 100644 index 0000000..2e9e854 --- /dev/null +++ b/hifi/api/src/Controllers/SetupController.php @@ -0,0 +1,209 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 5, + ]); + } catch (\Throwable $e) { + return null; + } + } + + /** @return string[] */ + private static function existingTables(PDO $db): array + { + $stmt = $db->query('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE()'); + return $stmt->fetchAll(PDO::FETCH_COLUMN); + } + + private static function schemaOk(PDO $db): bool + { + $existingTables = self::existingTables($db); + foreach (array_keys(Schema::CREATE_STATEMENTS) as $table) { + if (!in_array($table, $existingTables, true)) { + return false; + } + } + return true; + } + + /** Ermittelt den Fortschritt, ohne dass irgendwo eine Exception nach außen dringt. */ + private static function buildStatus(): array + { + $db = self::tryConnect(); + $databaseOk = $db !== null; + $schemaOk = $databaseOk && self::schemaOk($db); + + $adminOk = false; + if ($schemaOk) { + try { + $adminOk = ((int) $db->query('SELECT COUNT(*) FROM admin_users')->fetchColumn()) > 0; + } catch (\Throwable $e) { + $adminOk = false; + } + } + + return [ + 'needs_setup' => !$adminOk, + 'has_setup_password' => self::configuredPassword() !== null, + 'steps' => [ + 'database' => ['ok' => $databaseOk], + 'schema' => ['ok' => $schemaOk], + 'admin' => ['ok' => $adminOk], + ], + ]; + } + + public static function status(): void + { + Http::send(self::buildStatus()); + } + + private static function requireUnlocked(): array + { + $status = self::buildStatus(); + if (!$status['needs_setup']) { + Http::error('Die Ersteinrichtung ist bereits abgeschlossen.', 403); + } + return $status; + } + + public static function verifyPassword(): void + { + self::requireUnlocked(); + + $configured = self::configuredPassword(); + $body = Http::jsonBody(); + $given = (string) ($body['password'] ?? ''); + + if ($configured === null || $given === '' || !hash_equals($configured, $given)) { + Http::error('Einmalpasswort falsch oder nicht konfiguriert.', 401); + } + + $_SESSION['setup_verified'] = true; + Http::send(['ok' => true]); + } + + private static function requireVerified(): void + { + self::requireUnlocked(); + if (empty($_SESSION['setup_verified'])) { + Http::error('Bitte zuerst das Einmalpasswort bestätigen.', 401); + } + } + + public static function saveDatabase(): void + { + self::requireVerified(); + + $body = Http::jsonBody(); + $host = trim($body['host'] ?? ''); + $name = trim($body['name'] ?? ''); + $user = trim($body['user'] ?? ''); + $pass = (string) ($body['password'] ?? ''); + $charset = trim($body['charset'] ?? '') ?: 'utf8mb4'; + + if ($host === '' || $name === '' || $user === '') { + Http::error('Host, Datenbankname und Benutzer sind erforderlich', 422); + } + + $newConfig = ['host' => $host, 'name' => $name, 'user' => $user, 'pass' => $pass, 'charset' => $charset]; + + if (self::tryConnect($newConfig) === null) { + Http::error('Verbindung mit diesen Zugangsdaten fehlgeschlagen.', 422); + } + + $php = " Datenbank) verwaltet.\n" + . "// Diese Datei wird bei Änderungen automatisch neu geschrieben.\n" + . "return " . var_export($newConfig, true) . ";\n"; + + if (@file_put_contents(__DIR__ . '/../../config/db.php', $php) === false) { + Http::error('Verbindung erfolgreich getestet, aber die Konfigurationsdatei konnte nicht geschrieben werden.', 500); + } + + Http::send(['ok' => true, 'status' => self::buildStatus()]); + } + + public static function migrateSchema(): void + { + self::requireVerified(); + + $db = self::tryConnect(); + if ($db === null) { + Http::error('Datenbankverbindung nicht möglich.', 422); + } + + $existingTables = self::existingTables($db); + $actions = []; + foreach (Schema::CREATE_STATEMENTS as $table => $createSql) { + if (!in_array($table, $existingTables, true)) { + try { + $db->exec($createSql); + $actions[] = "Tabelle `$table` angelegt"; + } catch (\Throwable $e) { + $actions[] = "FEHLER bei `$table`: " . $e->getMessage(); + } + } + } + + Http::send(['ok' => true, 'actions' => $actions, 'status' => self::buildStatus()]); + } + + public static function createAdmin(): void + { + self::requireVerified(); + $status = self::buildStatus(); + + if (!$status['steps']['schema']['ok']) { + Http::error('Bitte zuerst die Datenbankstruktur einrichten.', 422); + } + if ($status['steps']['admin']['ok']) { + Http::error('Es existiert bereits ein Admin-Account.', 403); + } + + $body = Http::jsonBody(); + $username = trim($body['username'] ?? ''); + $password = (string) ($body['password'] ?? ''); + + if ($username === '') { + Http::error('Benutzername erforderlich', 422); + } + if (strlen($password) < 8) { + Http::error('Das Passwort muss mindestens 8 Zeichen lang sein', 422); + } + + $db = self::tryConnect(); + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $db->prepare('INSERT INTO admin_users (username, password_hash, is_super_admin) VALUES (?, ?, 1)'); + $stmt->execute([$username, $hash]); + + unset($_SESSION['setup_verified']); + + Http::send(['ok' => true]); + } +}