CluPilotCloud/app/Support/AdminArea.php

127 lines
4.7 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.'/*');
}
/** 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;
}
/** Where the console's own front page lives, as a path. */
public static function home(): string
{
return '/'.self::prefix();
}
}