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,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class ActiveUserMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user() && (!$request->user()->is_active || $request->user()->trashed())) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')
->with('error', __('auth_ui.account_deactivated'));
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->canAccessAdminPanel()) {
abort(403, 'Zugriff verweigert.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminOnlyMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isAdmin()) {
abort(403, 'Zugriff verweigert.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class DsgvoConsentMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user && $user->isDsgvoRestricted()) {
return back()->with('error', __('ui.dsgvo_restricted'));
}
return $next($request);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class InstallerMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Auto-create .env from .env.example if missing (first-time deployment)
$envPath = base_path('.env');
if (! file_exists($envPath) && file_exists(base_path('.env.example'))) {
copy(base_path('.env.example'), $envPath);
}
$isInstalled = file_exists(storage_path('installed'));
$isInstallerRoute = $request->is('install') || $request->is('install/*');
// Not installed + not on installer routes → redirect to installer
if (! $isInstalled && ! $isInstallerRoute) {
// Allow static assets and health check through
if ($request->is('favicon.ico', 'images/*', 'up', 'manifest.json', 'sw.js', 'storage/*')) {
return $next($request);
}
return redirect('/install');
}
// Already installed + on installer routes → redirect to login
if ($isInstalled && $isInstallerRoute) {
return redirect('/login');
}
// Setup-Token-Schutz: Installer nur mit gültigem Token erreichbar
if (! $isInstalled && $isInstallerRoute) {
$tokenFile = storage_path('setup-token');
// Token-Datei beim ersten Zugriff generieren
if (! file_exists($tokenFile)) {
$token = bin2hex(random_bytes(16));
file_put_contents($tokenFile, $token);
chmod($tokenFile, 0600);
// Nur Token-Hash loggen (Klartext in Datei storage/setup-token)
logger()->warning("Installer Setup-Token generiert (SHA256: " . hash('sha256', $token) . ")");
logger()->warning("Token befindet sich in: storage/setup-token");
}
$expectedToken = trim(file_get_contents($tokenFile));
$providedToken = $request->query('token');
// Session ist ggf. noch nicht gestartet (Middleware laeuft vor StartSession)
$sessionTokenHash = $request->hasSession() ? $request->session()->get('setup_token_hash') : null;
if ($providedToken && hash_equals($expectedToken, $providedToken)) {
// Token-Hash in Session speichern — kein Klartext in Session (V11)
if ($request->hasSession()) {
$request->session()->put('setup_token_hash', hash('sha256', $expectedToken));
}
} elseif ($sessionTokenHash && hash_equals(hash('sha256', $expectedToken), $sessionTokenHash)) {
// Gültiges Token via Session-Hash
} elseif (! $request->is('install')) {
// Nur die Startseite ohne Token erlauben (zeigt Token-Eingabe)
abort(403, 'Ungültiges Setup-Token.');
}
}
// Force file-based session/cache during installation (DB may not exist yet).
// Fixed cookie name prevents session loss when APP_NAME changes in .env mid-install.
if (! $isInstalled && $isInstallerRoute) {
config([
'session.driver' => 'file',
'cache.default' => 'file',
'session.cookie' => 'handball_installer_session',
]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeadersMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Server-Fingerprinting verhindern
header_remove('X-Powered-By');
$response->headers->remove('X-Powered-By');
$response->headers->remove('Server');
// Content Security Policy — erlaubt CDN-Quellen für Tailwind, Alpine, Quill, Leaflet
// 'unsafe-inline' benötigt von: Tailwind CDN (inline Styles), Alpine.js (Event-Handler)
// 'unsafe-eval' benötigt von: Tailwind CDN (JIT nutzt new Function())
// Entfernung nur möglich durch Wechsel auf self-hosted/kompilierte Assets
$cspDirectives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com https://cdn.quilljs.com",
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com https://cdn.quilljs.com",
"img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com",
"font-src 'self' https://cdn.jsdelivr.net https://cdn.quilljs.com",
"connect-src 'self' https://photon.komoot.io",
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
];
// upgrade-insecure-requests nur bei HTTPS — bricht sonst lokale HTTP-Entwicklung (Herd/artisan serve)
if ($request->secure()) {
$cspDirectives[] = "upgrade-insecure-requests";
}
$csp = implode('; ', $cspDirectives);
$response->headers->set('Content-Security-Policy', $csp);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self), payment=(), usb=(), bluetooth=(), autoplay=(), magnetometer=(), gyroscope=(), accelerometer=()');
// Cross-Origin Isolation Headers
$response->headers->set('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
// HSTS — HTTPS fuer 1 Jahr erzwingen (nur bei HTTPS-Requests aktiv)
if ($request->secure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use Carbon\Carbon;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SetLocaleMiddleware
{
private const SUPPORTED_LOCALES = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
public function handle(Request $request, Closure $next): Response
{
$locale = $this->resolveLocale($request);
app()->setLocale($locale);
Carbon::setLocale($locale);
return $next($request);
}
private function resolveLocale(Request $request): string
{
// 1. Eingeloggter User → DB-Präferenz
if ($request->user() && in_array($request->user()->locale, self::SUPPORTED_LOCALES)) {
return $request->user()->locale;
}
// 2. Session (für Gastseiten)
if (session()->has('locale') && in_array(session('locale'), self::SUPPORTED_LOCALES)) {
return session('locale');
}
// 3. Fallback
return 'de';
}
public static function supportedLocales(): array
{
return self::SUPPORTED_LOCALES;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class StaffMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isStaff()) {
abort(403, 'Zugriff verweigert.');
}
return $next($request);
}
}