diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index ff6958a7902..6905fc0c33e 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -150,6 +150,8 @@ public function boot() $this->registerElevatedSessionMacros(); + $this->registerCascadeHydrationForErrorViews(); + if (config('statamic.system.handle_scheduled_entries')) { $this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute(); } @@ -370,6 +372,45 @@ private function sitesAboutCommandInfo() return $sites->count().' ('.$summary.')'; } + // Statamic's own exceptions hydrate the cascade themselves via RendersHttpExceptions::contents(). + // A generic HTTP exception thrown outside Statamic's stack (e.g. Livewire's default 404) never + // goes through that path, so the error template's globals would be unresolved. This closes that gap. + private function registerCascadeHydrationForErrorViews() + { + $handler = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class); + + if (! method_exists($handler, 'renderable')) { + return; + } + + $handler->renderable( + function (\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e, Request $request) { + if ($request->expectsJson() || Statamic::isCpRoute() || Statamic::isApiRoute()) { + return null; + } + + $status = $e->getStatusCode(); + + if (! view()->exists('errors.'.$status)) { + return null; + } + + Facades\Cascade::hydrated(function ($cascade) use ($status) { + $cascade->set('response_code', $status); + }); + + $layouts = collect(['errors.layout', 'layouts.layout', config('statamic.system.layout', 'layout'), 'statamic::blank']); + $layout = $layouts->filter()->first(fn ($layout) => view()->exists($layout)); + + return response( + $this->app->make(\Statamic\View\View::class)->template('errors.'.$status)->layout($layout)->render(), + $status, + $e->getHeaders() + ); + } + ); + } + private function registerElevatedSessionMacros() { Request::macro('hasElevatedSession', function () { diff --git a/tests/FrontendTest.php b/tests/FrontendTest.php index ca02226c615..96cab11b05a 100644 --- a/tests/FrontendTest.php +++ b/tests/FrontendTest.php @@ -5,6 +5,7 @@ use Facades\Statamic\CP\LivePreview; use Facades\Statamic\Routing\ResolveRedirect; use Facades\Tests\Factories\EntryFactory; +use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Http\Response; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; @@ -17,9 +18,13 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\Cascade; use Statamic\Facades\Collection; +use Statamic\Facades\GlobalSet; use Statamic\Facades\User; use Statamic\Tags\Tags; use Statamic\View\Antlers\Language\Utilities\StringUtilities; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException as SymfonyNotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException as SymfonyUnauthorizedHttpException; class FrontendTest extends TestCase { @@ -715,6 +720,59 @@ public function a_404_does_not_leak_its_response_code_into_later_requests() $this->get('/about')->assertOk()->assertSee('Page 200'); } + #[Test] + public function it_hydrates_the_cascade_for_an_error_thrown_outside_of_statamics_own_exception_stack() + { + $this->throwFromGlobalMiddleware(new SymfonyNotFoundHttpException); + + $global = GlobalSet::make('site_settings')->save(); + $global->in('en')->data(['site_name' => 'Test Site'])->save(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('errors.404', 'Not found: {{ site_settings:site_name }}'); + + $this->get('/anything') + ->assertNotFound() + ->assertSee('Not found: Test Site'); + } + + #[Test] + public function it_keeps_the_exception_headers_when_rendering_an_error_view() + { + $this->throwFromGlobalMiddleware(new SymfonyUnauthorizedHttpException('Basic realm="Restricted"')); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('errors.401', 'Unauthorized'); + + $this->get('/anything') + ->assertUnauthorized() + ->assertSee('Unauthorized') + ->assertHeader('WWW-Authenticate', 'Basic realm="Restricted"'); + } + + // Mirrors the reported case: Livewire's RequireLivewireHeaders aborts from middleware, + // so Symfony's stock exception reaches the handler without passing through Statamic's + // own exception classes. Throwing from a route wouldn't reproduce it — Statamic's + // frontend catch-all matches every path, so its own NotFoundHttpException wins. See #14167. + private function throwFromGlobalMiddleware(HttpExceptionInterface $exception) + { + $this->app->instance('test-throwing-middleware', new class($exception) + { + public function __construct(private HttpExceptionInterface $exception) + { + } + + public function handle($request, $next) + { + throw $this->exception; + } + }); + + $this->app[HttpKernel::class]->pushMiddleware('test-throwing-middleware'); + } + #[Test] public function it_sets_the_translation_locale_based_on_site() {