route('login'); } $checks = $this->runRequirementChecks(); return view('installer.steps.requirements', [ 'currentStep' => 1, 'checks' => $checks, 'allPassed' => collect($checks)->where('required', true)->every(fn ($c) => $c['passed']), ]); } // ─── Step 2: Database ────────────────────────────────── public function database() { if (self::isInstalled()) { return redirect()->route('login'); } return view('installer.steps.database', [ 'currentStep' => 2, 'dbDriver' => old('db_driver', session('installer.db_driver', 'sqlite')), ]); } public function storeDatabase(Request $request) { if (self::isInstalled()) { return redirect()->route('login'); } $driver = $request->input('db_driver', 'sqlite'); if ($driver === 'mysql') { $request->validate([ 'db_host' => 'required|string', 'db_port' => 'required|integer|min:1|max:65535', 'db_database' => 'required|string', 'db_username' => 'required|string', 'db_password' => 'nullable|string', ]); // Test MySQL connection before writing config $testResult = $this->testMysqlConnection( $request->input('db_host'), (int) $request->input('db_port'), $request->input('db_database'), $request->input('db_username'), $request->input('db_password', ''), ); if ($testResult !== true) { Log::error('Installer: DB connection failed', ['error' => $testResult]); return back()->withInput() ->with('error', 'Datenbankverbindung fehlgeschlagen. Bitte Zugangsdaten pruefen.'); } } // Write DB config to .env $this->updateEnvValues($this->buildDbEnvValues($driver, $request)); // For SQLite: ensure database file exists with secure permissions if ($driver === 'sqlite') { $dbPath = database_path('database.sqlite'); if (! file_exists($dbPath)) { touch($dbPath); } chmod($dbPath, 0640); } // Clear config cache so new .env values take effect Artisan::call('config:clear'); // Set the runtime DB config for this request (since .env was just written) if ($driver === 'sqlite') { config([ 'database.default' => 'sqlite', 'database.connections.sqlite.database' => database_path('database.sqlite'), ]); } else { config([ 'database.default' => 'mysql', 'database.connections.mysql.host' => $request->input('db_host', '127.0.0.1'), 'database.connections.mysql.port' => $request->input('db_port', '3306'), 'database.connections.mysql.database' => $request->input('db_database'), 'database.connections.mysql.username' => $request->input('db_username'), 'database.connections.mysql.password' => $request->input('db_password', ''), ]); } // Run migrations try { Artisan::call('migrate', ['--force' => true]); } catch (\Exception $e) { Log::error('Installer: Migration failed', ['error' => $e->getMessage()]); return back()->withInput() ->with('error', 'Migration fehlgeschlagen. Details im Laravel-Log.'); } // Generate APP_KEY now (modifies .env — must happen before finalize) if (empty(config('app.key')) || config('app.key') === 'base64:') { Artisan::call('key:generate', ['--force' => true]); } // Store state in session session(['installer.db_driver' => $driver]); session(['installer.db_configured' => true]); return redirect()->route('install.app'); } // ─── Step 3: App Configuration ───────────────────────── public function app() { if (self::isInstalled()) { return redirect()->route('login'); } if (! session('installer.db_configured')) { return redirect()->route('install.database') ->with('error', 'Bitte zuerst die Datenbank konfigurieren.'); } return view('installer.steps.app', [ 'currentStep' => 3, ]); } public function storeApp(Request $request) { if (self::isInstalled()) { return redirect()->route('login'); } $request->validate([ 'app_name' => 'required|string|max:100', 'app_slogan' => 'nullable|string|max:255', 'app_url' => 'required|url', 'admin_name' => 'required|string|max:255', 'admin_email' => 'required|email|max:255', 'admin_password' => ['required', 'string', \Illuminate\Validation\Rules\Password::min(8)->letters()->numbers(), 'confirmed'], ]); // Write APP_NAME + APP_URL to .env now (triggers dev-server restart — // safe here because we redirect immediately after) $appName = $request->input('app_name'); $this->updateEnvValues([ 'APP_NAME' => '"' . str_replace('"', '\\"', $appName) . '"', 'APP_URL' => $request->input('app_url'), ]); session([ 'installer.app_name' => $appName, 'installer.app_slogan' => $request->input('app_slogan'), 'installer.app_url' => $request->input('app_url'), 'installer.admin_name' => $request->input('admin_name'), 'installer.admin_email' => $request->input('admin_email'), // Passwort sofort hashen (nicht Klartext in Session speichern). // Der 'hashed' Cast im User-Model erkennt via Hash::isHashed() // dass der Wert bereits gehasht ist und hasht NICHT doppelt. 'installer.admin_password_hash' => Hash::make($request->input('admin_password')), 'installer.app_configured' => true, ]); return redirect()->route('install.mail'); } // ─── Step 4: E-Mail Configuration ─────────────────────── public function mail() { if (self::isInstalled()) { return redirect()->route('login'); } if (! session('installer.app_configured')) { return redirect()->route('install.app') ->with('error', 'Bitte zuerst die App-Einstellungen konfigurieren.'); } $defaults = $this->getDefaultPasswordResetTexts(); return view('installer.steps.mail', [ 'currentStep' => 4, 'defaultPwResetDe' => $defaults['de'], ]); } public function storeMail(Request $request) { if (self::isInstalled()) { return redirect()->route('login'); } $mailMode = $request->input('mail_mode', 'log'); if ($mailMode === 'smtp') { $request->validate([ 'mail_host' => 'required|string|max:255', 'mail_port' => 'required|integer|min:1|max:65535', 'mail_username' => 'required|string|max:255', 'mail_password' => 'required|string|max:255', 'mail_from_address' => 'required|email|max:255', 'mail_from_name' => 'nullable|string|max:255', 'mail_encryption' => 'required|in:tls,ssl,none', ]); session([ 'installer.mail_mode' => 'smtp', 'installer.mail_host' => $request->input('mail_host'), 'installer.mail_port' => $request->input('mail_port'), 'installer.mail_username' => $request->input('mail_username'), 'installer.mail_password' => $request->input('mail_password'), 'installer.mail_from_address' => $request->input('mail_from_address'), 'installer.mail_from_name' => $request->input('mail_from_name', ''), 'installer.mail_encryption' => $request->input('mail_encryption'), ]); } else { session(['installer.mail_mode' => 'log']); } session([ 'installer.password_reset_email_de' => $request->input('password_reset_email_de', ''), 'installer.mail_configured' => true, ]); return redirect()->route('install.finalize'); } public function testMail(Request $request): \Illuminate\Http\JsonResponse { if (self::isInstalled()) { return response()->json(['success' => false, 'message' => 'Bereits installiert.']); } $request->validate([ 'mail_host' => 'required|string|max:255', 'mail_port' => 'required|integer|min:1|max:65535', 'mail_username' => 'required|string|max:255', 'mail_password' => 'required|string|max:255', 'mail_encryption' => 'required|in:tls,ssl,none', ]); try { $encryption = $request->input('mail_encryption'); $tls = ($encryption !== 'none'); $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( $request->input('mail_host'), (int) $request->input('mail_port'), $tls, ); $transport->setUsername($request->input('mail_username')); $transport->setPassword($request->input('mail_password')); $transport->start(); $transport->stop(); return response()->json(['success' => true, 'message' => 'SMTP-Verbindung erfolgreich!']); } catch (\Throwable $e) { return response()->json(['success' => false, 'message' => $e->getMessage()]); } } // ─── Step 5: Finalize ────────────────────────────────── public function finalize() { if (self::isInstalled()) { return redirect()->route('login'); } if (! session('installer.mail_configured')) { return redirect()->route('install.mail') ->with('error', 'Bitte zuerst die E-Mail-Einstellungen konfigurieren.'); } return view('installer.steps.finalize', [ 'currentStep' => 5, 'appName' => session('installer.app_name'), 'appSlogan' => session('installer.app_slogan'), 'adminEmail' => session('installer.admin_email'), 'adminName' => session('installer.admin_name'), 'dbDriver' => session('installer.db_driver', 'sqlite'), 'installed' => false, ]); } public function storeFinalize(Request $request) { if (self::isInstalled()) { return redirect()->route('login'); } $installDemo = $request->boolean('install_demo'); // Pruefen ob alle Session-Daten vorhanden sind $requiredSessionKeys = [ 'installer.admin_email', 'installer.admin_name', 'installer.admin_password_hash', 'installer.app_name', ]; foreach ($requiredSessionKeys as $key) { if (empty(session($key))) { return back()->with('error', "Session-Daten verloren ('{$key}' fehlt). Bitte die Installation erneut ab Schritt 2 durchfuehren."); } } // Datenbankverbindung sicherstellen (wurde in Schritt 2 konfiguriert via .env) try { \Illuminate\Support\Facades\DB::connection()->getPdo(); } catch (\Exception $e) { return back()->with('error', 'Datenbankverbindung fehlgeschlagen: ' . $e->getMessage()); } try { $appName = session('installer.app_name'); // 1. Create admin user (guaranteed ID 1 on fresh DB) $admin = User::updateOrCreate( ['email' => session('installer.admin_email')], [ 'name' => session('installer.admin_name'), 'password' => session('installer.admin_password_hash'), ] ); $admin->is_active = true; $admin->role = UserRole::Admin; $admin->save(); // 2. Run required seeders (Settings + FileCategories) Artisan::call('db:seed', [ '--class' => 'Database\\Seeders\\SettingsSeeder', '--force' => true, ]); Artisan::call('db:seed', [ '--class' => 'Database\\Seeders\\FileCategorySeeder', '--force' => true, ]); // 3. Override settings with installer values Setting::set('app_name', $appName); $slogan = session('installer.app_slogan'); if ($slogan) { Setting::set('app_slogan', '
' . e($slogan) . '
'); } // 4. Mail-Konfiguration in .env schreiben $mailMode = session('installer.mail_mode', 'log'); if ($mailMode === 'smtp') { $mailEncryption = session('installer.mail_encryption', 'tls'); $this->updateEnvValues([ 'MAIL_MAILER' => 'smtp', 'MAIL_HOST' => session('installer.mail_host'), 'MAIL_PORT' => session('installer.mail_port'), 'MAIL_USERNAME' => session('installer.mail_username'), 'MAIL_PASSWORD' => session('installer.mail_password'), 'MAIL_FROM_ADDRESS' => session('installer.mail_from_address'), 'MAIL_FROM_NAME' => session('installer.mail_from_name', $appName), 'MAIL_SCHEME' => $mailEncryption === 'none' ? '' : $mailEncryption, ]); } else { $this->updateEnvValues([ 'MAIL_MAILER' => 'log', ]); } // 5. Passwort-Reset E-Mail-Texte setzen $customDe = session('installer.password_reset_email_de', ''); $defaults = $this->getDefaultPasswordResetTexts(); // DE: Benutzer-Text aus Installer oder Default $deText = (strip_tags($customDe) !== '') ? $customDe : $defaults['de']; Setting::set('password_reset_email_de', $deText); // Andere Sprachen: Default-Texte setzen foreach (['en', 'pl', 'ru', 'ar', 'tr'] as $locale) { Setting::set('password_reset_email_' . $locale, $defaults[$locale]); } // 6. Optionally run DemoDataSeeder if ($installDemo) { Artisan::call('db:seed', [ '--class' => 'Database\\Seeders\\DemoDataSeeder', '--force' => true, ]); } // 7. Create storage symlink try { Artisan::call('storage:link'); } catch (\Exception $e) { // May already exist } // 8. Clear all caches Artisan::call('config:clear'); Artisan::call('cache:clear'); Artisan::call('view:clear'); Artisan::call('route:clear'); try { Setting::clearCache(); } catch (\Exception $e) { // Cache may already be cleared } // 9. Mark as installed $installedPath = storage_path('installed'); file_put_contents($installedPath, json_encode([ 'installed_at' => now()->toIso8601String(), 'version' => config('app.version'), 'php_version' => PHP_VERSION, 'db_driver' => session('installer.db_driver', 'sqlite'), ], JSON_PRETTY_PRINT)); chmod($installedPath, 0600); // 9b. Opt-in registration with support backend if ($request->boolean('register_installation')) { try { $supportService = app(\App\Services\SupportApiService::class); $supportService->register([ 'app_name' => $appName, 'app_url' => session('installer.app_url'), 'app_version' => config('app.version'), 'php_version' => PHP_VERSION, 'db_driver' => session('installer.db_driver', 'sqlite'), 'installed_at' => now()->toIso8601String(), ]); } catch (\Exception $e) { Log::warning('Installation registration failed: ' . $e->getMessage()); } } // 9c. Store license key if provided $licenseKey = $request->input('license_key'); if ($licenseKey) { Setting::set('license_key', trim($licenseKey)); } // 10. Store completion info in session, then clean up installer data $completionData = [ 'installed' => true, 'install_demo' => $installDemo, 'admin_email' => session('installer.admin_email'), 'admin_name' => session('installer.admin_name'), ]; session()->forget([ 'installer.db_driver', 'installer.db_configured', 'installer.app_name', 'installer.app_slogan', 'installer.app_url', 'installer.admin_name', 'installer.admin_email', 'installer.admin_password_hash', 'installer.app_configured', 'installer.mail_mode', 'installer.mail_host', 'installer.mail_port', 'installer.mail_username', 'installer.mail_password', 'installer.mail_from_address', 'installer.mail_from_name', 'installer.mail_encryption', 'installer.password_reset_email_de', 'installer.mail_configured', ]); session(['installer.completed' => $completionData]); return redirect()->route('install.complete'); } catch (\Exception $e) { Log::error('Installer: Installation failed', [ 'error' => $e->getMessage(), 'file' => $e->getFile() . ':' . $e->getLine(), 'trace' => $e->getTraceAsString(), ]); // Waehrend der Installation ist kein Laravel-Log per FTP leicht zugaenglich. // Daher zeigen wir die Fehlermeldung direkt an. $errorDetail = $e->getMessage(); $errorFile = basename($e->getFile()) . ':' . $e->getLine(); return back()->with('error', "Installation fehlgeschlagen: {$errorDetail} (in {$errorFile})"); } } // ─── Completion Page ─────────────────────────────────── public function complete() { $data = session('installer.completed'); if (! $data) { return redirect('/login'); } // Clear the completion data so this page can't be revisited session()->forget('installer.completed'); return view('installer.steps.finalize', [ 'currentStep' => 5, 'installed' => true, 'installDemo' => $data['install_demo'] ?? false, 'adminEmail' => $data['admin_email'] ?? '', 'adminName' => $data['admin_name'] ?? '', 'appName' => null, 'dbDriver' => null, ]); } // ─── Private Helpers ─────────────────────────────────── private function runRequirementChecks(): array { $checks = []; // PHP version $checks[] = [ 'name' => 'PHP Version >= 8.2', 'current' => PHP_VERSION, 'passed' => version_compare(PHP_VERSION, '8.2.0', '>='), 'required' => true, ]; // Required PHP extensions foreach (['pdo', 'pdo_sqlite', 'mbstring', 'openssl', 'tokenizer', 'xml', 'ctype', 'fileinfo', 'dom'] as $ext) { $checks[] = [ 'name' => "PHP Extension: {$ext}", 'current' => extension_loaded($ext) ? 'Geladen' : 'Fehlt', 'passed' => extension_loaded($ext), 'required' => true, ]; } // Optional: pdo_mysql $checks[] = [ 'name' => 'PHP Extension: pdo_mysql (nur für MySQL)', 'current' => extension_loaded('pdo_mysql') ? 'Geladen' : 'Fehlt', 'passed' => extension_loaded('pdo_mysql'), 'required' => false, ]; // Directory permissions $dirs = [ 'storage/' => storage_path(), 'storage/app/' => storage_path('app'), 'storage/framework/cache/' => storage_path('framework/cache'), 'storage/framework/sessions/' => storage_path('framework/sessions'), 'storage/framework/views/' => storage_path('framework/views'), 'storage/logs/' => storage_path('logs'), 'bootstrap/cache/' => base_path('bootstrap/cache'), 'database/' => database_path(), ]; foreach ($dirs as $label => $path) { $checks[] = [ 'name' => "Schreibberechtigung: {$label}", 'current' => is_writable($path) ? 'Schreibbar' : 'Nicht schreibbar', 'passed' => is_writable($path), 'required' => true, ]; } // .env file $checks[] = [ 'name' => '.env Datei', 'current' => file_exists(base_path('.env')) ? 'Vorhanden' : 'Fehlt', 'passed' => file_exists(base_path('.env')), 'required' => true, ]; return $checks; } private function testMysqlConnection(string $host, int $port, string $database, string $username, string $password): true|string { try { new \PDO( "mysql:host={$host};port={$port};dbname={$database}", $username, $password, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_TIMEOUT => 5] ); return true; } catch (\PDOException $e) { return $e->getMessage(); } } private function buildDbEnvValues(string $driver, Request $request): array { if ($driver === 'sqlite') { return [ 'DB_CONNECTION' => 'sqlite', 'DB_HOST' => '', 'DB_PORT' => '', 'DB_DATABASE' => '', 'DB_USERNAME' => '', 'DB_PASSWORD' => '', ]; } $password = $request->input('db_password', ''); return [ 'DB_CONNECTION' => 'mysql', 'DB_HOST' => $request->input('db_host', '127.0.0.1'), 'DB_PORT' => $request->input('db_port', '3306'), 'DB_DATABASE' => $request->input('db_database'), 'DB_USERNAME' => $request->input('db_username'), 'DB_PASSWORD' => $password !== '' ? '"' . str_replace('"', '\\"', $password) . '"' : '', ]; } private function getDefaultPasswordResetTexts(): array { return [ 'de' => 'Hallo {name},
du hast eine Passwort-Zuruecksetzung fuer dein Konto bei {app_name} angefordert. Klicke auf den Button unten, um ein neues Passwort zu vergeben.
Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.
', 'en' => 'Hello {name},
You requested a password reset for your account at {app_name}. Click the button below to set a new password.
If you did not request this, you can safely ignore this email.
', 'pl' => 'Witaj {name},
Otrzymalismy prosbe o zresetowanie hasla do Twojego konta w {app_name}. Kliknij przycisk ponizej, aby ustawic nowe haslo.
Jesli nie prosiles o zmiane hasla, zignoruj te wiadomosc.
', 'ru' => 'Здравствуйте {name},
Вы запросили сброс пароля для вашей учетной записи в {app_name}. Нажмите кнопку ниже, чтобы установить новый пароль.
Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо.
', 'ar' => 'مرحباً {name}،
لقد تلقينا طلباً لإعادة تعيين كلمة المرور لحسابك في {app_name}. انقر على الزر أدناه لتعيين كلمة مرور جديدة.
إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني.
', 'tr' => 'Merhaba {name},
{app_name} hesabiniz icin sifre sifirlama talebinde bulundunuz. Yeni bir sifre belirlemek icin asagidaki butona tiklayin.
Bu talebi siz yapmadiysan, bu e-postayi goerurmezden gelebilirsiniz.
', ]; } private function updateEnvValues(array $values): void { $envPath = base_path('.env'); $envContent = file_get_contents($envPath); foreach ($values as $key => $value) { // Empty values: comment out the line if ($value === '' || $value === null) { $pattern = "/^{$key}=.*/m"; if (preg_match($pattern, $envContent)) { $envContent = preg_replace($pattern, "# {$key}=", $envContent); } continue; } // Newline-Injection verhindern und Werte quoten (T07) $value = str_replace(["\n", "\r", "\0"], '', $value); if (!preg_match('/^".*"$/', $value)) { $value = '"' . str_replace('"', '\\"', $value) . '"'; } $replacement = "{$key}={$value}"; $pattern = "/^{$key}=.*/m"; if (preg_match($pattern, $envContent)) { $envContent = preg_replace($pattern, $replacement, $envContent); } else { // Also check for commented-out version $commentPattern = "/^#\s*{$key}=.*/m"; if (preg_match($commentPattern, $envContent)) { $envContent = preg_replace($commentPattern, $replacement, $envContent); } else { $envContent .= "\n{$replacement}"; } } } file_put_contents($envPath, $envContent); } }