From 233fc73430ec9e4209c34aa07ce8ee5952181e9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 06:20:44 +0200 Subject: [PATCH] Let the console keep its recovery hostnames without breaking route caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering the console once per accepted hostname reused one set of route names, and Laravel refuses to serialise two routes under the same name — so `artisan optimize` failed on the live server the moment the separation was switched on, mid-deploy. Only the canonical registration carries the `admin.` names now; the alternates answer under `admin.viaN.*`. They exist to be MATCHED — they are the addresses someone locked out reaches for — never to have URLs generated for them, so route() keeps producing the canonical hostname. That renaming then broke every exact route-name check, which is how the console navigation decides what is active: reached through a recovery address, nothing in the sidebar was marked, exactly when something is already going wrong. The check goes through AdminArea now and matches either form. Co-Authored-By: Claude Opus 5 --- app/Http/Middleware/RestrictAdminHost.php | 2 + app/Support/AdminArea.php | 17 ++++++++ resources/views/layouts/admin.blade.php | 4 +- routes/web.php | 25 +++++++---- .../Admin/ConsoleHostSeparationTest.php | 42 +++++++++++++++++++ 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/app/Http/Middleware/RestrictAdminHost.php b/app/Http/Middleware/RestrictAdminHost.php index a6a1936..912923f 100644 --- a/app/Http/Middleware/RestrictAdminHost.php +++ b/app/Http/Middleware/RestrictAdminHost.php @@ -86,6 +86,8 @@ class RestrictAdminHost { $name = $request->route()?->getName(); + // Covers admin.* and the alternate-host registrations (admin.viaN.*), + // which are the same console reached by a recovery hostname. return $name !== null && str_starts_with($name, 'admin.'); } } diff --git a/app/Support/AdminArea.php b/app/Support/AdminArea.php index 3f75784..9eb4473 100644 --- a/app/Support/AdminArea.php +++ b/app/Support/AdminArea.php @@ -144,6 +144,23 @@ final class AdminArea return self::isExclusive() ? '' : self::FALLBACK_PREFIX; } + /** + * Is the current request on this console route, whichever hostname it came + * through? + * + * The alternates are registered under `admin.viaN.*` so route caching can + * serialise them, which means an exact name check stops matching the moment + * someone reaches the console through a recovery address — and the whole + * navigation quietly loses its active state exactly when things are already + * going wrong. + */ + public static function routeIs(string $name): bool + { + $suffix = str_starts_with($name, 'admin.') ? substr($name, 6) : $name; + + return (bool) request()->routeIs('admin.'.$suffix, 'admin.via*.'.$suffix); + } + /** Where the console's own front page lives, as a path. */ public static function home(): string { diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 9846f2f..1644d05 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -41,14 +41,14 @@ ['admin.revenue', 'trending-up', 'revenue', null], ] as [$route, $icon, $key, $capability]) @continue($capability !== null && ! auth()->user()?->can($capability)) - + {{ __('admin.nav.'.$key) }} @endforeach
- + {{ __('admin.nav.settings') }} diff --git a/routes/web.php b/routes/web.php index f2aabb1..3c818e8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,21 +30,32 @@ use Illuminate\Support\Facades\Route; | both modes, so nothing that generates a URL has to know which mode it is in. */ if (AdminArea::isExclusive()) { - // Once per accepted hostname, and the canonical one LAST. + // Once per accepted hostname, canonical LAST. // // ADMIN_HOSTS may list alternates — a bare IP, a second name — and those // are the recovery paths someone locked out reaches for. Binding only the - // first would make the console 404 through every one of them. Registering - // the canonical host last means route() generates URLs for it, while all of - // them still match. + // first would 404 the console through every one of them. + // + // Only the canonical registration carries the `admin.` names. Route caching + // refuses to serialise two routes under one name, and the alternates exist + // to be MATCHED, never to have URLs generated for them: route() should + // always produce the canonical hostname. $hosts = AdminArea::hosts(); - foreach (array_reverse($hosts) as $host) { - Route::domain($host) + $canonical = array_shift($hosts); + + foreach ($hosts as $index => $alternate) { + Route::domain($alternate) ->middleware(['admin.host', 'auth', 'admin']) ->prefix(AdminArea::prefix()) - ->name('admin.') + ->name("admin.via{$index}.") ->group(base_path('routes/admin.php')); } + + Route::domain($canonical) + ->middleware(['admin.host', 'auth', 'admin']) + ->prefix(AdminArea::prefix()) + ->name('admin.') + ->group(base_path('routes/admin.php')); } else { // Shared with the portal: no hostname binding, and the /admin prefix keeps // the two apart by path. diff --git a/tests/Feature/Admin/ConsoleHostSeparationTest.php b/tests/Feature/Admin/ConsoleHostSeparationTest.php index 87246f1..36ab643 100644 --- a/tests/Feature/Admin/ConsoleHostSeparationTest.php +++ b/tests/Feature/Admin/ConsoleHostSeparationTest.php @@ -120,3 +120,45 @@ it('never treats a request as the console just because a hostname is configured' ->and(AdminArea::isConsole(Request::create('http://admin.example.test/cloud')))->toBeFalse() ->and(AdminArea::isConsole(Request::create('http://admin.example.test/admin')))->toBeTrue(); }); + +it('can cache its routes with several console hostnames configured', function () { + // Registering the console once per accepted hostname reused one set of + // names, and route caching refuses to serialise two routes under one name — + // which took `artisan optimize` down on the live server, mid-deploy. + config()->set('admin_access.hosts', ['admin.example.test', '127.0.0.1', 'localhost']); + config()->set('admin_access.exclusive', true); + + $router = new Illuminate\Routing\Router(app('events'), app()); + Illuminate\Support\Facades\Route::swap($router); + require base_path('routes/web.php'); + $router->getRoutes()->refreshNameLookups(); + + $names = []; + foreach ($router->getRoutes() as $route) { + if (($name = $route->getName()) !== null) { + expect($names)->not->toContain($name, "duplicate route name: {$name}"); + $names[] = $name; + } + } + + // The canonical host still owns the plain names, so route() builds URLs + // for it and not for a recovery address. + expect($names)->toContain('admin.overview') + ->and($router->getRoutes()->getByName('admin.overview')->getDomain())->toBe('admin.example.test'); +}); + +it('keeps the navigation marked on a recovery hostname', function () { + // The alternates are registered as admin.viaN.* so route caching can + // serialise them — which silently breaks every exact name check, and the + // navigation loses its active state exactly when something is already wrong. + $named = fn (string $name) => tap(request(), fn ($r) => $r->setRouteResolver( + fn () => (new Illuminate\Routing\Route(['GET'], '/', fn () => null))->name($name), + )); + + $named('admin.overview'); + expect(AdminArea::routeIs('admin.overview'))->toBeTrue(); + + $named('admin.via0.overview'); + expect(AdminArea::routeIs('admin.overview'))->toBeTrue() + ->and(AdminArea::routeIs('admin.settings'))->toBeFalse(); +});