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>
This commit is contained in:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

View File

@@ -0,0 +1,686 @@
<?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);
}
}