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 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-05 14:06:12 +02:00
parent f3ab7d0816
commit fe6b463135
8 changed files with 631 additions and 0 deletions

4
.gitignore vendored
View file

@ -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/

View file

@ -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 (
<>
<ScrollToTop />
<SetupGate>
<Routes>
<Route path="/setup" element={<SetupWizard />} />
<Route element={<PublicLayout />}>
<Route path="/" element={<Home />} />
<Route path="/fahrzeuge" element={<VehicleSelect />} />
@ -78,6 +83,7 @@ export default function App() {
</Route>
</Route>
</Routes>
</SetupGate>
</>
);
}

View file

@ -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 <div className="min-h-screen bg-white dark:bg-slate-950" />;
}
if (state.needsSetup && location.pathname !== '/setup') {
return <Navigate to="/setup" replace />;
}
if (!state.needsSetup && location.pathname === '/setup') {
return <Navigate to="/" replace />;
}
return children;
}

View file

@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-slate-50 px-4 py-12 dark:bg-slate-950">
<img src={logo} alt="HifiPlanet" className="h-16 w-auto sm:h-20" />
<div className="w-full max-w-md rounded-xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
{phase === 'loading' && <p className="text-center text-sm text-slate-400">Lädt</p>}
{phase === 'locked' && (
<div className="text-center">
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Bereits eingerichtet</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Diese Seite wurde bereits vollständig eingerichtet. Der Assistent ist deshalb gesperrt.
</p>
<Link to="/admin/login" className="text-sm font-medium text-brand-600 hover:underline dark:text-brand-400">
Zum Login
</Link>
</div>
)}
{['database', 'schema', 'admin'].includes(phase) && (
<div className="mb-6 flex items-center justify-center gap-2">
{steps.map((s, i) => (
<div key={s.key} className="flex items-center gap-2">
<span
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold ${
i < currentStepIndex
? 'bg-green-500 text-white'
: i === currentStepIndex
? 'bg-brand-500 text-white'
: 'bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-400'
}`}
>
{i < currentStepIndex ? '✓' : i + 1}
</span>
{i < steps.length - 1 && <span className="h-px w-6 bg-slate-300 dark:bg-slate-700" />}
</div>
))}
</div>
)}
{phase === 'password' && (
<form onSubmit={handlePassword}>
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Ersteinrichtung</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Gib das Einmalpasswort ein, das in <code>config/setup.php</code> hinterlegt ist.
</p>
<div className="mb-4">
<label className={labelClass}>Einmalpasswort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className={inputClass}
/>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{busy ? 'Prüfe…' : 'Weiter'}
</button>
</form>
)}
{phase === 'database' && (
<form onSubmit={handleDatabase}>
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Datenbank-Zugangsdaten</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Trage die Zugangsdaten der Datenbank auf diesem Server ein. Die Verbindung wird vor dem Speichern
getestet.
</p>
<div className="mb-3">
<label className={labelClass}>Host *</label>
<input
required
value={dbForm.host}
onChange={(e) => setDbForm({ ...dbForm, host: e.target.value })}
className={inputClass}
/>
</div>
<div className="mb-3">
<label className={labelClass}>Datenbankname *</label>
<input
required
value={dbForm.name}
onChange={(e) => setDbForm({ ...dbForm, name: e.target.value })}
className={inputClass}
/>
</div>
<div className="mb-3">
<label className={labelClass}>Benutzer *</label>
<input
required
value={dbForm.user}
onChange={(e) => setDbForm({ ...dbForm, user: e.target.value })}
className={inputClass}
/>
</div>
<div className="mb-3">
<label className={labelClass}>Passwort</label>
<input
type="password"
value={dbForm.password}
onChange={(e) => setDbForm({ ...dbForm, password: e.target.value })}
className={inputClass}
/>
</div>
<div className="mb-6">
<label className={labelClass}>Zeichensatz</label>
<input
value={dbForm.charset}
onChange={(e) => setDbForm({ ...dbForm, charset: e.target.value })}
className={inputClass}
/>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{busy ? 'Teste Verbindung…' : 'Verbindung testen & speichern'}
</button>
</form>
)}
{phase === 'schema' && (
<div>
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Datenbankstruktur</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Die Datenbankverbindung funktioniert. Jetzt legen wir die benötigten Tabellen an.
</p>
{migrateActions && (
<ul className="mb-4 space-y-1 rounded-md bg-slate-50 p-3 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{migrateActions.map((a, i) => (
<li key={i}>{a}</li>
))}
</ul>
)}
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={handleMigrate}
disabled={busy}
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{busy ? 'Richte ein…' : 'Datenbankstruktur einrichten'}
</button>
</div>
)}
{phase === 'admin' && (
<form onSubmit={handleAdmin}>
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Erster Admin-Account</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Lege den ersten Super-Admin-Account an, mit dem du dich anschließend einloggen kannst.
</p>
<div className="mb-3">
<label className={labelClass}>Benutzername</label>
<input
required
autoFocus
value={adminForm.username}
onChange={(e) => setAdminForm({ ...adminForm, username: e.target.value })}
className={inputClass}
/>
</div>
<div className="mb-3">
<label className={labelClass}>Passwort</label>
<input
type="password"
required
minLength={8}
value={adminForm.password}
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
className={inputClass}
/>
</div>
<div className="mb-6">
<label className={labelClass}>Passwort wiederholen</label>
<input
type="password"
required
minLength={8}
value={adminForm.password_confirmation}
onChange={(e) => setAdminForm({ ...adminForm, password_confirmation: e.target.value })}
className={inputClass}
/>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{busy ? 'Lege an…' : 'Admin-Account anlegen'}
</button>
</form>
)}
{phase === 'done' && (
<div className="text-center">
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Fertig!</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Die Ersteinrichtung ist abgeschlossen. Du kannst dich jetzt mit deinem neuen Admin-Account anmelden.
</p>
<Link
to="/admin/login"
className="inline-block w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600"
>
Zum Login
</Link>
</div>
)}
</div>
</div>
);
}

View file

@ -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).

View file

@ -0,0 +1,11 @@
<?php
// Kopiere diese Datei zu setup.php und trage ein eigenes, nur dir bekanntes
// Einmalpasswort ein - am besten BEVOR du die Seite auf einen neuen Server hochlädst.
// Damit kannst du beim allerersten Aufruf der Seite die Ersteinrichtung
// (Datenbank-Zugangsdaten, Datenbankstruktur, erster Admin-Account) über einen
// geführten Assistenten unter /setup durchführen.
//
// Sobald der erste Admin-Account angelegt wurde, sperrt sich der Assistent
// automatisch selbst - das Passwort funktioniert dann nicht mehr.
return 'CHANGE-ME-BEFORE-UPLOAD';

View file

@ -17,6 +17,7 @@ use App\Controllers\ProductController;
use App\Controllers\SchemaController;
use App\Controllers\ServiceController;
use App\Controllers\SettingsController;
use App\Controllers\SetupController;
use App\Controllers\TwoFactorController;
use App\Controllers\UploadController;
use App\Middleware\AuthMiddleware;
@ -149,6 +150,15 @@ $router->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)), '/');

View file

@ -0,0 +1,209 @@
<?php
namespace App\Controllers;
use App\Support\Http;
use App\Support\Schema;
use PDO;
class SetupController
{
// Öffentliche Endpunkte (es gibt ja noch keinen Admin) - daher prüft jede
// schreibende Aktion zusätzlich selbst per requireUnlocked(), ob wirklich noch
// kein Admin-Account existiert. Sobald einer existiert, sperrt sich der
// komplette Assistent dauerhaft selbst, unabhängig vom Einmalpasswort.
private static function configuredPassword(): ?string
{
$config = require __DIR__ . '/../../config/config.php';
$pw = $config['setup_password'] ?? null;
return ($pw !== null && $pw !== '') ? $pw : null;
}
private static function tryConnect(?array $overrideConfig = null): ?PDO
{
$db = $overrideConfig ?? require __DIR__ . '/../../config/db.php';
try {
$dsn = "mysql:host={$db['host']};dbname={$db['name']};charset=" . ($db['charset'] ?? 'utf8mb4');
return new PDO($dsn, $db['user'], $db['pass'], [
PDO::ATTR_ERRMODE => 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 = "<?php\n\n// Wird über das Admin-Panel (Einstellungen -> 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]);
}
}