286 lines
14 KiB
PHP
286 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\ImpersonationController;
|
|
use App\Http\Controllers\LandingController;
|
|
use App\Http\Controllers\StatusController;
|
|
use App\Http\Controllers\StripeWebhookController;
|
|
use App\Livewire\Admin;
|
|
use App\Livewire\Billing;
|
|
use App\Livewire\Auth\Login;
|
|
use App\Livewire\Auth\TwoFactorChallenge;
|
|
use App\Livewire\Backups;
|
|
use App\Livewire\Cloud;
|
|
use App\Livewire\Dashboard;
|
|
use App\Livewire\Invoices;
|
|
use App\Livewire\Support;
|
|
use App\Livewire\Users;
|
|
use App\Support\AdminArea;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
/*
|
|
| The operator console, registered BEFORE anything else in this file.
|
|
|
|
|
| Once the console has a hostname to itself it sits at the ROOT of that host —
|
|
| where `/`, `/settings` and `/customers` also exist further down for the
|
|
| public site and the customer portal. Laravel takes the first matching route,
|
|
| so the order of these two blocks is the separation.
|
|
|
|
|
| Where it mounts is AdminArea's call: `/` on the console hostname where it has
|
|
| one to itself, `/admin` on any host otherwise. The names stay `admin.*` in
|
|
| both modes, so nothing that generates a URL has to know which mode it is in.
|
|
|
|
|
| Two groups, not one: a guest group (routes/admin-guest.php — /login and
|
|
| /two-factor) reachable BEFORE 'auth' runs, and the authenticated group
|
|
| (routes/admin.php) behind it. In exclusive mode the guest group is bound to
|
|
| every accepted hostname exactly like the authenticated one below — canonical
|
|
| AND alternates — because signing in is exactly what someone locked out on a
|
|
| recovery hostname needs to do. This also makes Laravel's OWN redirect-to-
|
|
| login self-correct across hosts: Authenticate::redirectTo() always calls
|
|
| route('login') by name, never route('admin.login'), but that name is bound
|
|
| to no domain, so it resolves against whatever host the current request is
|
|
| on — landing back on THIS host's own /login, which these domain-bound
|
|
| registrations make the console's, not the portal's.
|
|
|
|
|
| The authenticated group checks 'auth:operator,web', not bare 'auth'. Bare
|
|
| 'auth' resolves whichever guard is CURRENTLY the default (config('auth.
|
|
| defaults.guard'), 'web' unless something changes it) — and signing in here
|
|
| only touches the 'operator' guard, never 'web'. An operator who just
|
|
| completed OperatorLogin would fail a bare 'auth' check and bounce straight
|
|
| back to the login they just used.
|
|
|
|
|
| 'operator' is listed FIRST, not merely included: Authenticate::authenticate()
|
|
| calls Auth::shouldUse() on whichever guard matches first, and a person can
|
|
| genuinely hold both a portal AND a console session at once in the same
|
|
| browser (config/auth.php — separate session keys, by design). On a CONSOLE
|
|
| route their operator identity has to be the one every bare auth()->user() or
|
|
| Gate::authorize() call resolves for the rest of the request, not whichever
|
|
| guard happened to be checked first. 'web' stays in the list behind it purely
|
|
| so a web-only customer at the console door still passes 'auth' (as
|
|
| themselves) and reaches EnsureAdmin's proper 403 — dropping 'web' entirely
|
|
| would turn that into a redirect instead. (Tests never caught either gap:
|
|
| actingAs($x, 'operator') flips the default guard via Auth::shouldUse()
|
|
| regardless of list order, which masks it. Found by mutation-testing
|
|
| ConfirmsPassword's guard resolution, not by a failing test.)
|
|
*/
|
|
if (AdminArea::isExclusive()) {
|
|
// 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 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();
|
|
$canonical = array_shift($hosts);
|
|
|
|
foreach ($hosts as $index => $alternate) {
|
|
// Guest FIRST: /login has to resolve to the console's own sign-in
|
|
// before 'auth' ever runs, on every recovery hostname as much as on
|
|
// the canonical one — that IS the recovery path.
|
|
Route::domain($alternate)
|
|
->middleware(['admin.host', 'guest:operator'])
|
|
->prefix(AdminArea::prefix())
|
|
->name("admin.via{$index}.")
|
|
->group(base_path('routes/admin-guest.php'));
|
|
|
|
Route::domain($alternate)
|
|
->middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa'])
|
|
->prefix(AdminArea::prefix())
|
|
->name("admin.via{$index}.")
|
|
->group(base_path('routes/admin.php'));
|
|
}
|
|
|
|
Route::domain($canonical)
|
|
->middleware(['admin.host', 'guest:operator'])
|
|
->prefix(AdminArea::prefix())
|
|
->name('admin.')
|
|
->group(base_path('routes/admin-guest.php'));
|
|
|
|
Route::domain($canonical)
|
|
->middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa'])
|
|
->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.
|
|
Route::middleware(['admin.host', 'guest:operator'])
|
|
->prefix(AdminArea::prefix())
|
|
->name('admin.')
|
|
->group(base_path('routes/admin-guest.php'));
|
|
|
|
Route::middleware(['admin.host', 'auth:operator,web', 'admin', 'operator.2fa'])
|
|
->prefix(AdminArea::prefix())
|
|
->name('admin.')
|
|
->group(base_path('routes/admin.php'));
|
|
}
|
|
|
|
// Old console addresses, once the console has moved off /admin. Permanent,
|
|
// because it really has moved, and only on the console's own host so this
|
|
// never hands a stranger a redirect that confirms a console exists.
|
|
if (AdminArea::isExclusive()) {
|
|
Route::domain(AdminArea::host())
|
|
->get('/admin/{rest?}', fn (?string $rest = null) => redirect('/'.($rest ?? ''), 301))
|
|
->where('rest', '.*')
|
|
->name('admin.legacy');
|
|
}
|
|
|
|
// The service status page. Its own address: it used to sit under /legal beside
|
|
// the imprint and the terms, and nothing about the current health of the
|
|
// platform is a legal document.
|
|
//
|
|
// Bound to its own hostname when one is configured, and every other host
|
|
// redirects there rather than 404ing — a status page is the one address people
|
|
// keep in a bookmark and reach for when something is already wrong.
|
|
$statusHost = (string) config('admin_access.status_host');
|
|
|
|
if ($statusHost !== '') {
|
|
// At the ROOT of its own hostname. "status.clupilot.com/status" says the
|
|
// same word twice and reads like a mistake, because it is one.
|
|
//
|
|
// Registered BEFORE the landing page below: `/` exists on both, Laravel
|
|
// takes the first match, and a host-agnostic route registered earlier wins
|
|
// over a domain-bound one registered later.
|
|
Route::domain($statusHost)->get('/', StatusController::class)->name('status');
|
|
// The old path on that host keeps working rather than 404ing.
|
|
Route::domain($statusHost)->get('/status', fn () => redirect()->to('/', 301));
|
|
Route::get('/status', fn () => redirect()->away('https://'.$statusHost, 301))->name('status.elsewhere');
|
|
} else {
|
|
Route::get('/status', StatusController::class)->name('status');
|
|
}
|
|
|
|
/*
|
|
| The public website and the customer portal, each on its own hostname.
|
|
|
|
|
| Bound with Route::domain(), which is the whole mechanism: a route registered
|
|
| for one host does not exist on another, so www.clupilot.com/dashboard is a
|
|
| 404 rather than a working page. The first attempt at this bound only the
|
|
| landing page and left everything else host-agnostic — the website vanished
|
|
| from the portal, and the portal stayed reachable from the website. Half a
|
|
| separation is no separation.
|
|
|
|
|
| Both are opt-in. Empty means nothing is bound and everything answers
|
|
| everywhere, which is what every development machine reached by a bare IP
|
|
| depends on.
|
|
|
|
|
| Deliberately NOT bound: the Stripe webhook (Stripe posts to whichever URL it
|
|
| was given), /up, and Livewire's own /livewire/update, which both the console
|
|
| and the portal post component actions to.
|
|
*/
|
|
$siteHost = (string) config('admin_access.site_host');
|
|
$appHost = (string) config('admin_access.app_host');
|
|
|
|
$publicSite = function () {
|
|
Route::get('/', LandingController::class)->name('home');
|
|
|
|
// Generated, not a static file: while the site is hidden this has to say
|
|
// so, and a crawler that gets a 404 here simply crawls anyway.
|
|
Route::get('/robots.txt', function () {
|
|
$body = App\Support\Settings::bool('site.public', true)
|
|
? "User-agent: *\nAllow: /\n"
|
|
: "User-agent: *\nDisallow: /\n";
|
|
|
|
return response($body, 200, ['Content-Type' => 'text/plain']);
|
|
})->name('robots');
|
|
|
|
// Legal pages. Bound with the rest of the website rather than left
|
|
// everywhere: an imprint belongs to the company's site, and binding it is
|
|
// what makes route('legal.impressum') generate a www URL from a queued
|
|
// mail, where there is no request to take a hostname from.
|
|
Route::prefix('legal')->name('legal.')->group(function () {
|
|
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
|
Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
|
|
Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb');
|
|
// Kept so existing links and bookmarks do not 404 — permanently,
|
|
// because the page has genuinely moved.
|
|
Route::get('/status', fn () => redirect()->to(route('status'), 301))->name('status');
|
|
});
|
|
};
|
|
|
|
$portal = function () {
|
|
// Guest auth pages — full-page class-based Livewire components (R1/R2).
|
|
// Fortify handles the POST actions with views off; those are bound to the
|
|
// same host through fortify.domain, or a sign-in form on the website's
|
|
// hostname would post to a route that answers there.
|
|
Route::middleware('guest')->group(function () {
|
|
Route::get('/login', Login::class)->name('login');
|
|
Route::get('/register', \App\Livewire\Auth\Register::class)->name('register');
|
|
// Registration POST goes through Fortify's controller but with our own
|
|
// registration-scoped throttle (Fortify's built-in route has none).
|
|
Route::post('/register', [\Laravel\Fortify\Http\Controllers\RegisteredUserController::class, 'store'])
|
|
->middleware('throttle:registration')
|
|
->name('register.store');
|
|
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
|
|
});
|
|
|
|
// Signed and single-use; see ImpersonationController. Deliberately not
|
|
// behind `auth`: following the link is what creates the session.
|
|
Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter'])
|
|
->name('impersonate.enter');
|
|
|
|
// Signed in, but the address has not been confirmed yet. Fortify would own
|
|
// this page, but 'views' => false — it keeps the verify and resend actions
|
|
// and the app renders its own screens (R1/R2).
|
|
Route::get('/email/verify', \App\Livewire\Auth\VerifyEmail::class)
|
|
->middleware('auth')
|
|
->name('verification.notice');
|
|
|
|
// Customer portal — each sidebar tab is a full-page class-based Livewire
|
|
// component (R1/R2); paths are English (R13).
|
|
//
|
|
// `verified` sits on the whole group rather than on the pages that spend
|
|
// money. An unconfirmed address is not a billing problem to be caught at
|
|
// checkout: it is an account that may not belong to the person holding it,
|
|
// and everything behind here — the servers, the users, the backups — is
|
|
// worth as much to somebody who typed a stranger's address as to its owner.
|
|
Route::middleware(['auth', 'verified', 'customer.active'])->group(function () {
|
|
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
|
Route::get('/cloud', Cloud::class)->name('cloud');
|
|
Route::get('/users', Users::class)->name('users');
|
|
Route::get('/backups', Backups::class)->name('backups');
|
|
Route::get('/invoices', Invoices::class)->name('invoices');
|
|
Route::get('/billing', Billing::class)->name('billing');
|
|
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
|
|
Route::get('/support', Support::class)->name('support');
|
|
|
|
// Return from an admin impersonation session (accessible as the
|
|
// customer user). POST so Laravel's CSRF middleware protects the
|
|
// identity change.
|
|
Route::post('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave');
|
|
});
|
|
};
|
|
|
|
// Domain-bound FIRST, host-agnostic last: a host-agnostic route registered
|
|
// earlier beats a bound one registered later, which is the same ordering the
|
|
// status page above already documents.
|
|
if ($appHost !== '') {
|
|
Route::domain($appHost)->group($portal);
|
|
}
|
|
|
|
if ($siteHost !== '') {
|
|
Route::domain($siteHost)->group($publicSite);
|
|
|
|
// "/" on a host that is neither: the portal's own hostname, or a bare IP.
|
|
// Never a 404 — "/" is what somebody types from memory, and turning that
|
|
// into an error page to make a point about hostnames helps nobody.
|
|
Route::get('/', function () {
|
|
return redirect()->route(auth()->check() ? 'dashboard' : 'login');
|
|
})->name('portal.home');
|
|
}
|
|
|
|
if ($appHost === '') {
|
|
$portal();
|
|
}
|
|
|
|
if ($siteHost === '') {
|
|
$publicSite();
|
|
}
|
|
|
|
// Stripe webhook — paid order → customer provisioning run (CSRF-exempt,
|
|
// signed). Host-agnostic on purpose: Stripe posts to whichever URL it was
|
|
// configured with, and a hostname mismatch there loses payments.
|
|
Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe');
|