12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class BasicAuthMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// Skip Basic Auth für Livewire-Requests komplett
// Diese sind bereits durch Laravel Session/CSRF geschützt
$path = $request->path();
if (
str_starts_with($path, 'livewire/') ||
str_contains($path, '/livewire/') ||
$request->is('livewire/*') ||
$request->is('*/livewire/*')
) {
return $next($request);
}
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
if (str_starts_with($path, 'flux/')) {
return $next($request);
}
// Skip Basic Auth für API und Short-Links; API-Zugriff wird per Sanctum geschützt.
if ($request->is('api/*') || $request->is('_cabinet/*')) {
return $next($request);
}
// Credentials from .env file
$user = config('auth.basic.user');
$pass = config('auth.basic.password');
if ($request->getUser() != $user || $request->getPassword() != $pass) {
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
}
return $next($request);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;
class EnsureApiTokenRateLimit
{
private const MAX_ATTEMPTS = 60;
private const DECAY_SECONDS = 60;
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$key = $this->rateLimitKey($request);
if (RateLimiter::tooManyAttempts($key, self::MAX_ATTEMPTS)) {
$retryAfter = RateLimiter::availableIn($key);
return response()->json([
'message' => 'API rate limit exceeded.',
], 429, [
'Retry-After' => (string) $retryAfter,
'X-RateLimit-Limit' => (string) self::MAX_ATTEMPTS,
'X-RateLimit-Remaining' => '0',
]);
}
RateLimiter::hit($key, self::DECAY_SECONDS);
$response = $next($request);
$response->headers->set('X-RateLimit-Limit', (string) self::MAX_ATTEMPTS);
$response->headers->set('X-RateLimit-Remaining', (string) RateLimiter::remaining($key, self::MAX_ATTEMPTS));
return $response;
}
private function rateLimitKey(Request $request): string
{
$bearerToken = $request->bearerToken();
if ($bearerToken !== null && str_contains($bearerToken, '|')) {
[$tokenId] = explode('|', $bearerToken, 2);
if (ctype_digit($tokenId)) {
return 'api-v1:token:'.$tokenId;
}
}
if ($bearerToken !== null) {
return 'api-v1:bearer:'.hash('sha256', $bearerToken);
}
$token = $request->user()?->currentAccessToken();
if (is_object($token) && method_exists($token, 'getKey') && $token->getKey() !== null) {
return 'api-v1:token:'.$token->getKey();
}
return 'api-v1:user:'.($request->user()?->getAuthIdentifier() ?? $request->ip());
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureApiUserIsActive
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->is_active) {
return response()->json([
'message' => 'API access is disabled for inactive users.',
], 403);
}
return $next($request);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Actions\Admin\UserImpersonation;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && $user->is_active && ! $user->is_super_admin) {
$user->loadMissing('roles');
}
if (app(UserImpersonation::class)->isActive()) {
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
return redirect()->route('me.dashboard');
}
abort(403, 'Während der Benutzer-Impersonation ist der Admin-Bereich gesperrt.');
}
if (! $user?->canAccessAdmin()) {
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
}
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 EnsureUserIsCustomer
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->canAccessCustomer()) {
abort(403, 'Kein Zugriff auf das Kundenportal.');
}
return $next($request);
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Http\Middleware;
use App\Models\ApiUsageLog;
use Closure;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
class LogApiUsage
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$startedAt = microtime(true);
$response = $next($request);
$this->writeLog($request, $response, $startedAt);
return $response;
}
private function writeLog(Request $request, Response $response, float $startedAt): void
{
$token = $request->user()?->currentAccessToken();
$tokenId = $token instanceof PersonalAccessToken && (int) $token->getKey() > 0
? (int) $token->getKey()
: null;
ApiUsageLog::query()->create([
'user_id' => $request->user()?->id,
'personal_access_token_id' => $tokenId,
'method' => $request->method(),
'path' => '/'.$request->path(),
'route_name' => $request->route()?->getName(),
'status_code' => $response->getStatusCode(),
'ip_address' => $request->ip(),
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
'requested_at' => now(),
]);
}
}

View file

@ -0,0 +1,88 @@
<?php
namespace App\Http\Middleware;
use App\Services\Admin\AdminRequestPerformanceMetrics;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
class LogSlowAdminRequests
{
public function __construct(private AdminRequestPerformanceMetrics $metrics) {}
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! config('admin_performance.slow_requests.enabled', true)) {
return $next($request);
}
$startedAt = microtime(true);
$this->metrics->start();
try {
$response = $next($request);
} catch (\Throwable $exception) {
$this->metrics->stop();
throw $exception;
}
$snapshot = $this->metrics->snapshot();
$this->metrics->stop();
$durationMs = (int) round((microtime(true) - $startedAt) * 1000);
if ($this->shouldLog($durationMs, $snapshot['database_time_ms'], $snapshot['query_count'])) {
$this->logger()->warning('Slow admin request detected.', [
'method' => $request->method(),
'path' => '/'.$request->path(),
'route_name' => $request->route()?->getName(),
'status_code' => $response->getStatusCode(),
'user_id' => $request->user()?->id,
'duration_ms' => $durationMs,
'database_time_ms' => $snapshot['database_time_ms'],
'query_count' => $snapshot['query_count'],
'slow_queries' => $snapshot['slow_queries'],
]);
}
return $response;
}
private function shouldLog(int $durationMs, float $databaseTimeMs, int $queryCount): bool
{
return $durationMs >= $this->durationThresholdMs()
|| $databaseTimeMs >= $this->databaseThresholdMs()
|| $queryCount >= $this->queryCountThreshold();
}
private function logger(): LoggerInterface
{
$channel = config('admin_performance.slow_requests.channel') ?: config('logging.default');
return Log::channel(is_string($channel) && $channel !== '' ? $channel : 'stack');
}
private function durationThresholdMs(): int
{
return (int) config('admin_performance.slow_requests.duration_threshold_ms', 750);
}
private function databaseThresholdMs(): int
{
return (int) config('admin_performance.slow_requests.database_threshold_ms', 250);
}
private function queryCountThreshold(): int
{
return (int) config('admin_performance.slow_requests.query_count_threshold', 100);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RejectLegacyApiKeys
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if ($request->has('api_key') || filled($request->header('X-Api-Key'))) {
return response()->json([
'message' => 'Legacy API keys are no longer supported.',
'migration_url' => url('/customer/tokens'),
'docs_url' => url('/docs/api/v1'),
], 410);
}
return $next($request);
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Portal;
use App\Services\CurrentPortalContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Setzt den Portal-Kontext für den aktuellen Request.
*
* Reihenfolge der Auflösung:
* 1. Admin-Session-Override: Ein angemeldeter Admin kann über die Session ein
* bestimmtes Portal forcieren (für die Filteransicht im Admin-Bereich).
* 2. Domain-Konfiguration: Das aktive Theme (gesetzt vom ThemeServiceProvider)
* bestimmt das Portal über den config('app.theme')-Wert.
* 3. Kein Kontext: Portal-Scope filtert nicht (z.B. CLI, Tests).
*
* Theme Portal-Mapping:
* 'presseecho' Portal::Presseecho
* 'businessportal24' Portal::Businessportal24
* 'main' / andere null (Admin-Domain; Super-Admin sieht alles)
*/
class SetCurrentPortal
{
public function handle(Request $request, Closure $next): Response
{
$portal = $this->resolvePortal($request);
CurrentPortalContext::set($portal);
return $next($request);
}
private function resolvePortal(Request $request): ?Portal
{
// Admin-Session-Override hat höchste Priorität
if ($request->hasSession() && $request->session()->has('admin_portal_filter')) {
$overrideValue = $request->session()->get('admin_portal_filter');
$override = Portal::tryFrom((string) $overrideValue);
if ($override !== null) {
return $override;
}
}
// Domain-basierte Auflösung via ThemeServiceProvider
$theme = config('app.theme', 'main');
return match ($theme) {
'presseecho' => Portal::Presseecho,
'businessportal24' => Portal::Businessportal24,
default => null, // Admin/Portal-Domain → kein automatischer Filter
};
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware um die URL-Konfiguration basierend auf der aktuellen Domain zu setzen.
*
* Diese Middleware muss sehr früh im Request-Lifecycle ausgeführt werden,
* um sicherzustellen, dass url() und asset() die richtige Domain verwenden.
*/
class SetDomainUrl
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost();
// Suche nach der Domain-Konfiguration
$domainConfig = null;
$domains = config('domains.domains', []);
foreach ($domains as $name => $config) {
if (is_array($config) && isset($config['domain_name']) && $config['domain_name'] === $host) {
$domainConfig = $config;
break;
}
}
// Wenn eine Domain-Konfiguration gefunden wurde, setze die URL
if ($domainConfig && isset($domainConfig['url'])) {
$domainUrl = $domainConfig['url'];
// URL-Generator konfigurieren
URL::forceRootUrl($domainUrl);
URL::forceScheme(parse_url($domainUrl, PHP_URL_SCHEME) ?: 'https');
// Asset-Root setzen
/** @var UrlGenerator $urlGenerator */
$urlGenerator = app('url');
$urlGenerator->useAssetOrigin($domainUrl);
// Config aktualisieren
config([
'app.url' => $domainUrl,
'app.asset_url' => $domainUrl,
]);
}
return $next($request);
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ThemeMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost();
$path = $request->path();
// Theme-Switching über Subdomains
if (str_contains($host, 'b2in')) {
config(['app.theme' => 'b2in']);
} elseif (str_contains($host, 'b2a') || str_contains($host, 'bridges2america')) {
config(['app.theme' => 'b2a']);
} elseif (str_contains($host, 'stileigentum')) {
config(['app.theme' => 'stileigentum']);
} elseif (str_contains($host, 'style2own')) {
config(['app.theme' => 'style2own']);
}
// Theme-Switching über URL-Parameter (für Testing)
if ($request->has('theme')) {
$theme = $request->get('theme');
if (in_array($theme, ['b2in', 'b2a', 'stileigentum', 'style2own'])) {
config(['app.theme' => $theme]);
}
}
// Theme-Switching über Pfade (für lokale Entwicklung ohne Domain-Setup)
if (str_starts_with($path, 'b2in/')) {
config(['app.theme' => 'b2in']);
$request->server->set('REQUEST_URI', '/'.substr($path, 5)); // Entferne 'b2in/' vom Pfad
} elseif (str_starts_with($path, 'b2a/') || str_starts_with($path, 'bridges2america/')) {
config(['app.theme' => 'b2a']);
$request->server->set('REQUEST_URI', '/'.substr($path, 4)); // Entferne 'b2a/' vom Pfad
} elseif (str_starts_with($path, 'stileigentum/')) {
config(['app.theme' => 'stileigentum']);
$request->server->set('REQUEST_URI', '/'.substr($path, 13)); // Entferne 'stileigentum/' vom Pfad
} elseif (str_starts_with($path, 'style2own/')) {
config(['app.theme' => 'style2own']);
$request->server->set('REQUEST_URI', '/'.substr($path, 10)); // Entferne 'style2own/' vom Pfad
}
return $next($request);
}
}