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'])); // Prüfe verfügbare PDO-Treiber und verwende entsprechende Datenbank $availableDrivers = PDO::getAvailableDrivers(); $useMySQL = in_array('mysql', $availableDrivers) && trim((string) $input['db_host']) !== 'sqlite'; if ($useMySQL) { $pdo = $this->setupMySQLDatabase($input); } else { $pdo = $this->setupSQLiteDatabase(); } $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, $useMySQL); 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']), 'database_type' => $useMySQL ? 'mysql' : 'sqlite', ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent); } private function setupMySQLDatabase(array $input): PDO { $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'] ); try { $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 MySQL-Datenbankschema konnte nicht geladen werden.'); } $pdo->exec($schema); return $pdo; } catch (\PDOException $e) { throw new RuntimeException('MySQL-Datenbankverbindung fehlgeschlagen: ' . $e->getMessage() . '. Bitte prüfen Sie Ihre Datenbankeinstellungen oder verwenden Sie SQLite als Alternative.'); } } private function setupSQLiteDatabase(): PDO { $dbPath = $this->rootPath . '/storage/database.sqlite'; $dbDir = dirname($dbPath); if (!is_dir($dbDir) && !mkdir($dbDir, 0775, true) && !is_dir($dbDir)) { throw new RuntimeException('Das Datenbank-Verzeichnis konnte nicht erstellt werden: ' . $dbDir); } try { $pdo = new PDO( 'sqlite:' . $dbPath, null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ] ); $pdo->exec('PRAGMA journal_mode=WAL'); $pdo->exec('PRAGMA foreign_keys=ON'); $pdo->exec('PRAGMA busy_timeout=5000'); $schema = file_get_contents($this->rootPath . '/database/schema.sqlite.sql'); if (!is_string($schema) || $schema === '') { throw new RuntimeException('Das SQLite-Datenbankschema konnte nicht geladen werden.'); } $pdo->exec($schema); return $pdo; } catch (\PDOException $e) { throw new RuntimeException('SQLite-Datenbankverbindung fehlgeschlagen: ' . $e->getMessage()); } } private function buildEnvFile(array $input, bool $useMySQL = true): 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', '', ]; // Datenbankeinstellungen je nach verwendetem System if ($useMySQL) { $lines = array_merge($lines, [ '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'] ?? ''), ]); } else { // SQLite-Konfiguration $lines = array_merge($lines, [ 'DB_HOST=sqlite', 'DB_PORT=0', 'DB_NAME=' . $this->rootPath . '/storage/database.sqlite', 'DB_USER=', 'DB_PASS=', ]); } $lines = array_merge($lines, [ '', '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; } }