Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 () {
Expand Down
58 changes: 58 additions & 0 deletions tests/FrontendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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()
{
Expand Down
Loading