Files
WebAPP/app/Http/Controllers/InstallerController.php
Rhino 2e24a40d68 Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill
- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er)
- Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link
- Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest
- Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen)
- Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:30:37 +01:00

687 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers;
use App\Enums\UserRole;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class InstallerController extends Controller
{
/**
* Check if app is already installed.
*/
public static function isInstalled(): bool
{
return file_exists(storage_path('installed'));
}
// ─── Step 1: System Requirements ───────────────────────
public function requirements()
{
if (self::isInstalled()) {
return redirect()->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', '<p><em>' . e($slogan) . '</em></p>');
}
// 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' => '<p>Hallo {name},</p><p>du hast eine Passwort-Zuruecksetzung fuer dein Konto bei {app_name} angefordert. Klicke auf den Button unten, um ein neues Passwort zu vergeben.</p><p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>',
'en' => '<p>Hello {name},</p><p>You requested a password reset for your account at {app_name}. Click the button below to set a new password.</p><p>If you did not request this, you can safely ignore this email.</p>',
'pl' => '<p>Witaj {name},</p><p>Otrzymalismy prosbe o zresetowanie hasla do Twojego konta w {app_name}. Kliknij przycisk ponizej, aby ustawic nowe haslo.</p><p>Jesli nie prosiles o zmiane hasla, zignoruj te wiadomosc.</p>',
'ru' => '<p>Здравствуйте {name},</p><p>Вы запросили сброс пароля для вашей учетной записи в {app_name}. Нажмите кнопку ниже, чтобы установить новый пароль.</p><p>Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо.</p>',
'ar' => '<p>مرحباً {name}،</p><p>لقد تلقينا طلباً لإعادة تعيين كلمة المرور لحسابك في {app_name}. انقر على الزر أدناه لتعيين كلمة مرور جديدة.</p><p>إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني.</p>',
'tr' => '<p>Merhaba {name},</p><p>{app_name} hesabiniz icin sifre sifirlama talebinde bulundunuz. Yeni bir sifre belirlemek icin asagidaki butona tiklayin.</p><p>Bu talebi siz yapmadiysan, bu e-postayi goerurmezden gelebilirsiniz.</p>',
];
}
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);
}
}