CluPilotCloud/app/Support/AdminArea.php

106 lines
3.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', []),
)));
}
/** 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
{
return self::isHostBound()
? 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 host-bound (the console is at the root of its own hostname),
* `admin` otherwise.
*/
public static function prefix(): string
{
return self::isHostBound() ? '' : self::FALLBACK_PREFIX;
}
/** Where the console's own front page lives, as a path. */
public static function home(): string
{
return '/'.self::prefix();
}
}