170 lines
6.3 KiB
PHP
170 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
/**
|
|
* Where the operator console lives, and how to tell that a request is for it.
|
|
*
|
|
* Three guards need this answer — the hostname guard, the network allowlist and
|
|
* the public-site switch — and until now each decided for itself by testing the
|
|
* path against `admin/*`. That was survivable while the console sat under
|
|
* /admin. It stops being survivable the moment the console moves to the root of
|
|
* its own hostname: PublicSiteGate would no longer recognise it, and with the
|
|
* public site hidden the console would answer 503 to the person trying to sign
|
|
* in and turn it back on. That is the lock-out this class exists to prevent.
|
|
*
|
|
* Two modes, and only two:
|
|
*
|
|
* - A console hostname is configured. The console IS that host: it answers at
|
|
* the root, and every request to that host is a console request.
|
|
* - Nothing is configured (a fresh checkout, a development machine). The
|
|
* console stays under /admin on any host, exactly as before, so upgrading
|
|
* cannot lock anyone out of a system that was working.
|
|
*
|
|
* Read from config on every call rather than cached in a static: `config:cache`
|
|
* already freezes the value at deploy time, and a second layer of staleness in
|
|
* a long-lived worker is the kind of thing that is only discovered from the
|
|
* wrong side of a locked door.
|
|
*/
|
|
final class AdminArea
|
|
{
|
|
/** The path prefix used when no console hostname is configured. */
|
|
public const FALLBACK_PREFIX = 'admin';
|
|
|
|
/**
|
|
* The single hostname the console answers on, or null when unconfigured.
|
|
*
|
|
* ADMIN_HOSTS may list several names — an operator who reaches the console
|
|
* by IP as well as by name, for instance. The FIRST is the canonical one
|
|
* and the one URLs are generated for; the rest keep working because
|
|
* covers() accepts any of them. Dropping the alternates would remove
|
|
* exactly the recovery paths someone locked out would reach for.
|
|
*/
|
|
public static function host(): ?string
|
|
{
|
|
return self::hosts()[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Every hostname that reaches the console.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function hosts(): array
|
|
{
|
|
return array_values(array_filter(array_map(
|
|
fn ($host) => strtolower(trim((string) $host)),
|
|
(array) config('admin_access.hosts', []),
|
|
)));
|
|
}
|
|
|
|
/**
|
|
* May ONLY the console answer on the console's hostname?
|
|
*
|
|
* Separate from being host-bound, and off by default, because the two are
|
|
* not the same thing. A development machine lists its own IP and localhost
|
|
* in ADMIN_HOSTS so the console is reachable without DNS — and on that
|
|
* machine the portal and the public site have to keep working. Turning
|
|
* exclusivity on there would 404 everything that is not the console, which
|
|
* is exactly what it did the first time this shipped.
|
|
*
|
|
* It belongs with the routing change that moves the console to the root of
|
|
* its own hostname: until then a console host legitimately serves both.
|
|
*/
|
|
public static function isExclusive(): bool
|
|
{
|
|
return self::isHostBound() && (bool) config('admin_access.exclusive', false);
|
|
}
|
|
|
|
/** Whether the console is bound to a hostname at all. */
|
|
public static function isHostBound(): bool
|
|
{
|
|
return self::hosts() !== [];
|
|
}
|
|
|
|
/**
|
|
* Is this request addressed to the console?
|
|
*
|
|
* The one question the guards ask. When the console is host-bound the whole
|
|
* host is the console — including its sign-in page, which is precisely what
|
|
* the path test used to miss.
|
|
*/
|
|
public static function isConsole(Request $request): bool
|
|
{
|
|
// Keyed on exclusivity, not on merely having a hostname: where the
|
|
// console shares its host with the portal it still lives under /admin,
|
|
// and "every request to this host is the console" would be false.
|
|
return self::isExclusive()
|
|
? self::covers($request->getHost())
|
|
: $request->is(self::FALLBACK_PREFIX, self::FALLBACK_PREFIX.'/*');
|
|
}
|
|
|
|
/**
|
|
* Does this URL lead into the console?
|
|
*
|
|
* Needed because the sign-in POST does not go to a console address in
|
|
* shared mode — everyone posts to /login — so the request alone cannot say
|
|
* whether the console is where this person was heading. The intended URL
|
|
* can, and without it a non-operator turned back at /admin keeps the
|
|
* session that was just created for them.
|
|
*/
|
|
public static function pointsAtConsole(?string $url): bool
|
|
{
|
|
if ($url === null || $url === '') {
|
|
return false;
|
|
}
|
|
|
|
if (self::isExclusive()) {
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|
|
|
return is_string($host) && self::covers($host);
|
|
}
|
|
|
|
$path = trim((string) parse_url($url, PHP_URL_PATH), '/');
|
|
|
|
return $path === self::FALLBACK_PREFIX || str_starts_with($path, self::FALLBACK_PREFIX.'/');
|
|
}
|
|
|
|
/** Is this hostname one the console answers on? */
|
|
public static function covers(string $host): bool
|
|
{
|
|
return in_array(strtolower($host), self::hosts(), true);
|
|
}
|
|
|
|
/**
|
|
* The path prefix console routes are registered under.
|
|
*
|
|
* Empty when the console has its hostname to itself — it is then the root
|
|
* of that host. `admin` otherwise.
|
|
*/
|
|
public static function prefix(): string
|
|
{
|
|
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
|
|
{
|
|
return '/'.self::prefix();
|
|
}
|
|
}
|