Let the console keep its recovery hostnames without breaking route caching
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 <noreply@anthropic.com>feat/portal-design tested-20260727-0501-233fc73
parent
492b1925fb
commit
233fc73430
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,14 +41,14 @@
|
|||
['admin.revenue', 'trending-up', 'revenue', null],
|
||||
] as [$route, $icon, $key, $capability])
|
||||
@continue($capability !== null && ! auth()->user()?->can($capability))
|
||||
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
|
||||
<x-ui.nav-item :href="route($route)" :active="\App\Support\AdminArea::routeIs($route)">
|
||||
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
|
||||
{{ __('admin.nav.'.$key) }}
|
||||
</x-ui.nav-item>
|
||||
@endforeach
|
||||
</nav>
|
||||
<div class="mt-auto space-y-1 border-t border-line pt-4">
|
||||
<x-ui.nav-item :href="route('admin.settings')" :active="request()->routeIs('admin.settings')">
|
||||
<x-ui.nav-item :href="route('admin.settings')" :active="\App\Support\AdminArea::routeIs('admin.settings')">
|
||||
<x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
|
||||
{{ __('admin.nav.settings') }}
|
||||
</x-ui.nav-item>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue