diff --git a/app/Http/Errors/ErrorPages.php b/app/Http/Errors/ErrorPages.php new file mode 100644 index 0000000..8787b6f --- /dev/null +++ b/app/Http/Errors/ErrorPages.php @@ -0,0 +1,50 @@ +respond()` in + * bootstrap/app.php. + * + * The page receives only the status code, never the exception, so no + * message, trace or path reaches the browser (NFR-SEC-07). With APP_DEBUG + * on, a 500 keeps Laravel's debug page for developers. + */ +final class ErrorPages +{ + public const STATUSES = [403, 404, 419, 500, 503]; + + public function __invoke(Response $response, Throwable $exception, Request $request): Response + { + $status = $response->getStatusCode(); + + if (! in_array($status, self::STATUSES, true) + || $response instanceof JsonResponse + || ($status === 500 && config('app.debug'))) { + return $response; + } + + // A 404 with no matching route never ran the `locale:` middleware; + // the URL prefix is authoritative, so take the locale from it. + if (Locale::current() === Locale::fallback()) { + app()->setLocale(Locale::fromPath($request->path())->value); + } + + return (new ExceptionResponse($exception, $request, $response, app(Router::class), app(Kernel::class))) + ->render('Errors/ErrorPage', ['status' => $status]) + ->usingMiddleware(HandleInertiaRequests::class) + ->withSharedData() + ->toResponse($request); + } +} diff --git a/app/Http/Localization/Locale.php b/app/Http/Localization/Locale.php index f2d1236..5243d60 100644 --- a/app/Http/Localization/Locale.php +++ b/app/Http/Localization/Locale.php @@ -24,6 +24,20 @@ public static function current(): self return self::tryFrom(app()->getLocale()) ?? self::fallback(); } + /** The locale whose prefix starts `$path` (e.g. "ar/products"), else the fallback. */ + public static function fromPath(string $path): self + { + $segment = explode('/', trim($path, '/'), 2)[0]; + + foreach (self::cases() as $locale) { + if ($locale->prefix() !== '' && $locale->prefix() === $segment) { + return $locale; + } + } + + return self::fallback(); + } + public function direction(): string { return $this === self::Arabic ? 'rtl' : 'ltr'; diff --git a/app/Http/Localization/LocalizedRoutes.php b/app/Http/Localization/LocalizedRoutes.php index 4298e65..c2d34e8 100644 --- a/app/Http/Localization/LocalizedRoutes.php +++ b/app/Http/Localization/LocalizedRoutes.php @@ -13,7 +13,11 @@ */ final class LocalizedRoutes { - public static function group(Closure $routes): void + /** + * @param bool $redirectEnglishPrefix false for a second group (routes/local.php): + * the /en redirect is registered once, by routes/web.php. + */ + public static function group(Closure $routes, bool $redirectEnglishPrefix = true): void { // /en/... is never canonical: send it to the unprefixed URL. Registered // first so a later catch-all storefront route cannot shadow it. @@ -22,12 +26,14 @@ public static function group(Closure $routes): void // the redirect is always absolute and same-host. Without that, // /en//evil.com or /en/%2Fevil.com became "//evil.com", a // protocol-relative open redirect. - Route::get('en/{path?}', function (Request $request, ?string $path = null) { - $target = url('/'.ltrim($path ?? '', '/')); - $query = $request->getQueryString(); + if ($redirectEnglishPrefix) { + Route::get('en/{path?}', function (Request $request, ?string $path = null) { + $target = url('/'.ltrim($path ?? '', '/')); + $query = $request->getQueryString(); - return redirect($target.($query === null ? '' : '?'.$query), 301); - })->where('path', '.*'); + return redirect($target.($query === null ? '' : '?'.$query), 301); + })->where('path', '.*'); + } foreach (Locale::cases() as $locale) { Route::middleware('locale:'.$locale->value) diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 1ff4bbc..eb1ea7d 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Http\Localization\Locale; +use App\Http\Localization\LocalizedRoutes; use Illuminate\Http\Request; use Inertia\Middleware; @@ -31,6 +32,10 @@ public function share(Request $request): array ...parent::share($request), 'locale' => fn (): string => Locale::current()->value, 'direction' => fn (): string => Locale::current()->direction(), + // hreflang alternates for the head and the locale switcher. Sent + // on every visit so client-side navigation never keeps the + // previous page's links (M3 review). + 'alternates' => fn (): array => LocalizedRoutes::alternates($request), ]; } } diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..93f2fe9 --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,95 @@ +headers->add([ + 'Content-Security-Policy' => $this->contentSecurityPolicy($nonce), + 'X-Content-Type-Options' => 'nosniff', + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + // Legacy twin of frame-ancestors 'none' for older browsers. + 'X-Frame-Options' => 'DENY', + 'Cross-Origin-Opener-Policy' => 'same-origin', + 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()', + ]); + + // HTTPS only, and never locally: a pinned HSTS entry for a *.test + // host would break plain-HTTP development. + if ($request->isSecure() && ! app()->isLocal()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000'); + } + + return $response; + } + + private function contentSecurityPolicy(string $nonce): string + { + $script = ["'self'", "'nonce-{$nonce}'"]; + $style = ["'self'", "'nonce-{$nonce}'"]; + $connect = ["'self'"]; + $assets = ["'self'"]; + + // `npm run dev`: scripts, styles and HMR come from the Vite server. + // Vite injects