12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
51
app/Http/Middleware/BasicAuthMiddleware.php
Normal file
51
app/Http/Middleware/BasicAuthMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
71
app/Http/Middleware/EnsureApiTokenRateLimit.php
Normal file
71
app/Http/Middleware/EnsureApiTokenRateLimit.php
Normal 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());
|
||||
}
|
||||
}
|
||||
26
app/Http/Middleware/EnsureApiUserIsActive.php
Normal file
26
app/Http/Middleware/EnsureApiUserIsActive.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
34
app/Http/Middleware/EnsureUserIsAdmin.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureUserIsCustomer.php
Normal file
19
app/Http/Middleware/EnsureUserIsCustomer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
48
app/Http/Middleware/LogApiUsage.php
Normal file
48
app/Http/Middleware/LogApiUsage.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
88
app/Http/Middleware/LogSlowAdminRequests.php
Normal file
88
app/Http/Middleware/LogSlowAdminRequests.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
app/Http/Middleware/RejectLegacyApiKeys.php
Normal file
28
app/Http/Middleware/RejectLegacyApiKeys.php
Normal 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);
|
||||
}
|
||||
}
|
||||
56
app/Http/Middleware/SetCurrentPortal.php
Normal file
56
app/Http/Middleware/SetCurrentPortal.php
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
59
app/Http/Middleware/SetDomainUrl.php
Normal file
59
app/Http/Middleware/SetDomainUrl.php
Normal 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);
|
||||
}
|
||||
}
|
||||
57
app/Http/Middleware/ThemeMiddleware.php
Normal file
57
app/Http/Middleware/ThemeMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue