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. */ $siteHosts = (array) config('admin_access.site_hosts', []); $siteHost = $siteHosts[0] ?? ''; // canonical: the one that serves $siteAliases = array_slice($siteHosts, 1); // every other name redirects to it $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. // Reachable WITHOUT an account, deliberately: somebody who has just typed // their password into a copy of our sign-in form is not signed in anywhere, // and the page they need cannot be behind the thing they lost. Route::get('/security', fn () => view('security'))->name('security'); // The German path this shipped under for two releases. R13 says paths are // English and this one was not — mine. Kept as a permanent redirect // because it has already gone out in mail footers. Route::get('/sicherheit', fn () => redirect()->route('security', status: 301)); 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 () { // R13: paths are English, headings are not. The page still says // "Impressum" — that is the legal term an Austrian company must use — // but the address is not part of the copy. Route::get('/imprint', fn () => view('legal', ['title' => 'Impressum']))->name('impressum'); Route::get('/privacy', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz'); // The AGB has a text now (resources/views/legal/terms.blade.php): the // order page requires accepting it, and it is where the withdrawal // right, the immediate start and the account deletion deadlines are // regulated rather than repeated in a checkbox nobody can read. Route::get('/terms', fn () => view('legal', ['title' => 'AGB', 'page' => 'terms']))->name('agb'); // The German paths these shipped under. Permanent, because they have // been in mail footers and in the site footer for weeks. foreach (['impressum' => 'impressum', 'datenschutz' => 'datenschutz', 'agb' => 'agb'] as $old => $to) { Route::get('/'.$old, fn () => redirect()->route('legal.'.$to, status: 301)); } // 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'); // Fortify registers the two POST endpoints; with views off it // registers no GET routes at all, so the pages are ours under its // names — every framework redirect and the reset mail resolve // `password.request` and `password.reset`. Route::get('/forgot-password', \App\Livewire\Auth\ForgotPassword::class)->name('password.request'); Route::get('/reset-password/{token}', \App\Livewire\Auth\ResetPassword::class)->name('password.reset'); 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('/domain', \App\Livewire\CustomDomain::class)->name('domain'); Route::get('/users', Users::class)->name('users'); Route::get('/backups', Backups::class)->name('backups'); Route::get('/invoices', Invoices::class)->name('invoices'); // The customer's own invoice as a PDF, rendered on demand from the frozen // document — the same renderer the console uses, because there is only one // version of a document that has been issued. // // A plain route rather than a Livewire action: a download is a download, // and routing one through a component round-trip only adds a way for it to // fail. The ownership check is HERE, in the query, and not in the list that // links to it — a URL anybody can type is not made private by not showing // it. firstOrFail() on a query that already carries `customer_id` gives a // 404 for somebody else's document, which is also the right answer: it // tells a stranger nothing about whether the number exists. Route::get('/invoices/{uuid}/pdf', function (string $uuid) { $customer = \App\Models\Customer::forUser(auth()->user()); abort_if($customer === null, 404); $invoice = \App\Models\Invoice::query() ->where('customer_id', $customer->id) ->where('uuid', $uuid) ->firstOrFail(); return response()->streamDownload( fn () => print (app(\App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)), $invoice->number.'.pdf', ['Content-Type' => 'application/pdf'], ); })->name('invoices.pdf'); Route::get('/billing', Billing::class)->name('billing'); Route::get('/settings', \App\Livewire\Settings::class)->name('settings'); Route::get('/support', Support::class)->name('support'); // Buying a package. The page is a page like any other; the POST leaves // for Stripe and comes back through the webhook, which is the only // place a payment is believed. // // Behind `verified` with everything else, deliberately: an unconfirmed // address is not a billing detail to be caught at checkout, it is an // account that may not belong to the person holding it — and the // credentials for the finished cloud are sent to that address. Route::get('/order', \App\Livewire\Order::class)->name('order'); Route::post('/checkout', [\App\Http\Controllers\CheckoutController::class, 'start']) // Throttled: each attempt opens a session on somebody else's API. ->middleware('throttle:10,1') ->name('checkout.start'); Route::get('/checkout/done', [\App\Http\Controllers\CheckoutController::class, 'done']) ->name('checkout.done'); // 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); // Every other name for the same site — an apex beside its www — answers // with a permanent redirect to the canonical one, path and query intact. // Serving both without picking one splits search rankings and cookies // between two names for one thing. // // Registered as a catch-all on those hosts alone, so it cannot swallow a // path on any other host. It is deliberately not a middleware: a redirect // that only some routes get is the half-measure this file already made // once. foreach ($siteAliases as $index => $alias) { Route::domain($alias) // The injected request, not the request() helper: this closure also // runs in tests that dispatch through a router of their own, where // the helper resolves whatever happens to be bound in the container // rather than the request being answered. ->get('/{path?}', function (Illuminate\Http\Request $request, ?string $path = null) use ($siteHost) { $target = 'https://'.$siteHost.'/'.ltrim((string) $path, '/'); $query = $request->getQueryString(); return redirect()->away($query ? $target.'?'.$query : $target, 301); }) ->where('path', '.*') ->name("site.alias{$index}"); } // "/" 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');