rootPath . '/storage/installed.lock'); } public function install(array $input): void { $required = [ 'app_url', 'db_host', 'db_port', 'db_name', 'db_user', 'admin_name', 'admin_email', 'admin_password', ]; foreach ($required as $field) { if (trim((string) ($input[$field] ?? '')) === '') { throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.'); } } if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('Die Admin-E-Mail ist ungueltig.'); } if (mb_strlen($input['admin_password']) < 12) { throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.'); } $this->resolveMailFrom($input, $this->normalizeAppUrl((string) $input['app_url'])); $config = [ 'host' => trim((string) $input['db_host']), 'port' => (int) $input['db_port'], 'name' => trim((string) $input['db_name']), 'user' => trim((string) $input['db_user']), 'pass' => (string) ($input['db_pass'] ?? ''), 'charset' => 'utf8mb4', ]; $dsn = sprintf( 'mysql:host=%s;port=%d;dbname=%s;charset=%s', $config['host'], $config['port'], $config['name'], $config['charset'] ); $pdo = new PDO($dsn, $config['user'], $config['pass'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); $schema = file_get_contents($this->rootPath . '/database/schema.sql'); if (!is_string($schema) || $schema === '') { throw new RuntimeException('Das Datenbankschema konnte nicht geladen werden.'); } $pdo->exec($schema); $statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); $statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]); $existing = $statement->fetchColumn(); if (!$existing) { $insert = $pdo->prepare( 'INSERT INTO users (full_name, email, password_hash, platform_role) VALUES (:full_name, :email, :password_hash, "platform_admin")' ); $insert->execute([ 'full_name' => trim((string) $input['admin_name']), 'email' => mb_strtolower((string) $input['admin_email']), 'password_hash' => secure_password_hash((string) $input['admin_password']), ]); } $envContent = $this->buildEnvFile($input); if (file_put_contents($this->rootPath . '/.env', $envContent) === false) { throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.'); } $lockContent = json_encode([ 'installed_at' => gmdate(DATE_ATOM), 'app_url' => trim((string) $input['app_url']), ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent); } private function buildEnvFile(array $input): string { $appUrl = $this->normalizeAppUrl((string) $input['app_url']); $mailFrom = $this->resolveMailFrom($input, $appUrl); $appKey = bin2hex(random_bytes(32)); $rfidSecret = bin2hex(random_bytes(24)); $lines = [ 'APP_NAME="Kaffeekasse SaaS"', 'APP_ENV=production', 'APP_DEBUG=0', 'APP_URL=' . $appUrl, 'APP_TIMEZONE=Europe/Berlin', 'APP_KEY=' . $appKey, 'ALLOW_PUBLIC_REGISTRATION=1', 'RFID_INGEST_ENABLED=0', '', 'DB_HOST=' . trim((string) $input['db_host']), 'DB_PORT=' . (int) $input['db_port'], 'DB_NAME=' . trim((string) $input['db_name']), 'DB_USER=' . trim((string) $input['db_user']), 'DB_PASS=' . (string) ($input['db_pass'] ?? ''), '', 'MAIL_FROM=' . $mailFrom, 'MAIL_REPLY_TO=' . $mailFrom, 'MAIL_SUBJECT_PREFIX="[Kaffeekasse] "', 'MAIL_LOG_FALLBACK=1', 'MAIL_LOG_FILE=storage/logs/mail.log', '', 'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}', 'PASSWORD_RESET_TTL_MINUTES=60', 'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3', 'PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen"', '', 'RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5', 'RATE_LIMIT_LOGIN_WINDOW_SECONDS=900', 'RATE_LIMIT_LOGIN_BLOCK_SECONDS=900', 'RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3', 'RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600', 'RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600', '', 'RFID_SHARED_SECRET=' . $rfidSecret, ]; return implode(PHP_EOL, $lines) . PHP_EOL; } private function normalizeAppUrl(string $appUrl): string { return rtrim(trim($appUrl), '/'); } private function resolveMailFrom(array $input, string $appUrl): string { $mailFrom = mb_strtolower(trim((string) ($input['mail_from'] ?? ''))); if ($mailFrom === '' || $mailFrom === 'noreply@example.com') { return $this->defaultMailFrom($appUrl); } if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('Die Mail-Absenderadresse ist ungueltig.'); } return $mailFrom; } private function defaultMailFrom(string $appUrl): string { $host = parse_url($appUrl, PHP_URL_HOST); if (!is_string($host)) { return 'noreply@example.com'; } $host = preg_replace('/^www\./i', '', mb_strtolower(trim($host))) ?? ''; if ($host === '' || !str_contains($host, '.')) { return 'noreply@example.com'; } return 'noreply@' . $host; } }