presseportale/bootstrap/app.php
Kevin Adametz 35db30e019 mail: adressbezogene Abmeldung fuer Empfaenger ohne Konto (Roadmap 1d.3)
Der Abmeldelink in API-Benachrichtigungen zeigte auf das Konto des
Einlieferers - ein Klick des Pressekontakts haette connektar abgemeldet.
Jetzt traegt der Link die Empfaengeradresse (signiert), der Zustand liegt
als Unsubscribed in der Sperrliste. Anmeldelinks, die die Person selbst
anfordert, gehen weiter durch; harte Bounces sperren alles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:35:47 +00:00

133 lines
5.7 KiB
PHP

<?php
use App\Http\Middleware\BasicAuthMiddleware;
use App\Http\Middleware\LogApiUsage;
use App\Http\Middleware\RecordMailLinkClick;
use App\Http\Middleware\RedirectWwwToApex;
use App\Http\Middleware\RejectLegacyApiKeys;
use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetCurrentPortal;
use App\Http\Middleware\SetEdition;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/domains.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function (): void {
// Legacy-API-Aliasse (/service/… ohne /api-Präfix) in der
// api-Middleware-Gruppe — Übernahme bestehender Integrationen.
Route::middleware('api')->group(__DIR__.'/../routes/legacy-api.php');
},
)
->withMiddleware(function (Middleware $middleware) {
// Kanonische Hosts: www.<domain> → 301 auf die Apex-Domain, so früh
// wie möglich (vor Session/Theme), damit www-Requests gar nicht erst
// gerendert werden.
$middleware->prepend(RedirectWwwToApex::class);
// Security-Header auf allen Antworten (Web + API), Proxy-unabhängig.
$middleware->append(SecurityHeaders::class);
// Portal-Kontext nach dem Theme-Provider setzen (liest config('app.theme'))
$middleware->append(SetCurrentPortal::class);
// Stufe 1 der Konversionsleiter: Klick aus einer unserer Mails
// (`?src=`). Bewusst global statt je Route — die Mail-Links zeigen auf
// Hub, Spokes und öffentliche Detailseiten gleichermaßen, und eine
// vergessene Route wäre eine unsichtbare Lücke in der Messung.
$middleware->append(RecordMailLinkClick::class);
// Ausgabe/Sprache (de|en) aus dem URL-Präfix ableiten und URL-Defaults setzen.
$middleware->append(SetEdition::class);
// Wohin eingeloggte User von Gast-Routen (/login, /register) gelenkt
// werden: rollen- und verifizierungsbewusst statt fix auf /dashboard,
// sonst landet ein Customer dort im 403 und sitzt fest.
$middleware->redirectUsersTo(function (Request $request) {
$user = $request->user();
if ($user && ! $user->hasVerifiedEmail()) {
return route('verification.notice');
}
if ($user?->canAccessAdmin()) {
return route('dashboard');
}
return $user?->canAccessCustomer() ? route('me.dashboard') : '/';
});
$middleware->api(prepend: [
LogApiUsage::class,
RejectLegacyApiKeys::class,
]);
// One-Click-Unsubscribe (RFC 8058): Mail-Clients POSTen ohne Session/
// CSRF-Token auf die signierte Abmelde-URL — die Signatur ersetzt den
// CSRF-Schutz.
$middleware->validateCsrfTokens(except: [
'e-mail-benachrichtigungen/abmelden/*',
'e-mail-benachrichtigungen/abmelden-adresse',
]);
$middleware->alias([
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
// BasicAuth ganz am Ende, nach Session-Middleware. Immer anhängen;
// ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand
// von config('auth.basic.*') (greift auch unter `config:cache`).
$middleware->append(BasicAuthMiddleware::class);
// Trust all proxies (for Traefik/Docker setup)
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO
);
// Host-Whitelist: Da allen Proxies inkl. X-Forwarded-Host vertraut wird,
// muss die App fremde Host-Header verwerfen — sonst kann ein Client per
// X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify-
// Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten
// Domains (und deren Subdomains) sowie lokale Aufrufe (Health-Checks per
// localhost/IP) sind zulässig. Laravel erzwingt dies nur außerhalb von
// `local`/Tests (siehe TrustHosts::shouldSpecifyTrustedHosts).
$middleware->trustHosts(at: function (): array {
$domainPatterns = array_map(
static fn (string $host): string => '^(.+\.)?'.preg_quote($host, '#').'$',
array_filter([
config('domains.domain_portal'),
config('domains.domain_presseecho'),
config('domains.domain_businessportal'),
], static fn (?string $host): bool => $host !== null && $host !== ''),
);
// Health-Checks/Uptime-Monitore erreichen den Container oft per
// localhost oder direkter IP (kein DNS-Name) — diese dürfen keinen
// 400 bekommen. Ein IP-/localhost-Host kann keine Phishing-Domain in
// signierte Links schleusen, daher unbedenklich.
$localPatterns = [
'^localhost$',
'^\d{1,3}(\.\d{1,3}){3}$',
'^\[?[0-9a-fA-F:]+\]?$',
];
return array_values(array_merge($domainPatterns, $localPatterns));
}, subdomains: false);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();