diff --git a/.env.example b/.env.example index 62ff138..d438c2a 100644 --- a/.env.example +++ b/.env.example @@ -205,11 +205,19 @@ CLUPILOT_TAX_PERCENT=20 # Ohne Eintrag gilt false — dann zeigt https://admin.…/ das PORTAL, nicht die # Konsole, und das sieht wie ein Fehler aus, obwohl es die Vorgabe ist. # +# APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist +# JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst — +# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf. +# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber +# eine blanke IP braucht). +APP_HOST= + # SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die # Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt # unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall, -# und dann liefert app.clupilot.com die Website aus. -# Das Impressum bleibt absichtlich ueberall erreichbar. +# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite, +# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum') +# auch aus einer Mail heraus eine www-Adresse. SITE_HOST= ADMIN_HOSTS=admin.dev.clupilot.com diff --git a/VERSION b/VERSION index f0bb29e..3a3cd8c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 +1.3.1 diff --git a/config/admin_access.php b/config/admin_access.php index 3c95bae..e00769a 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -43,6 +43,24 @@ return [ */ 'status_host' => strtolower(trim((string) env('STATUS_HOST', ''))), + /* + | Hostname the customer portal lives on, e.g. app.clupilot.com. + | + | Empty means the portal answers on every host, which is the default and + | what every development machine reached by a bare IP depends on. + | + | Set, every portal route — sign-in, registration, the dashboard, Fortify's + | own POST actions through fortify.domain — is bound to this host and does + | not exist anywhere else. www.clupilot.com/dashboard is then a 404 rather + | than a working page, which is the point: a route belongs to a hostname, + | and a route that is not registered for the host being asked cannot answer. + | + | The first attempt at this bound only the landing page and left the portal + | host-agnostic — so the website vanished from the portal, and the portal + | stayed reachable from the website. Half a separation is no separation. + */ + 'app_host' => strtolower(trim((string) env('APP_HOST', ''))), + /* | Hostname the public website lives on, e.g. www.clupilot.com. | @@ -51,14 +69,15 @@ return [ | app.clupilot.com got the shop window: marketing copy, a pricing table and | a "sign up" call to action, at the address where their servers are. | - | Set, the website is bound to this host alone, and every OTHER host answers - | "/" with the product: the dashboard for somebody signed in, the sign-in + | Set, the website — landing page, robots.txt and the legal pages — is bound + | to this host and exists nowhere else. Every other host answers "/" with + | the product instead: the dashboard for somebody signed in, the sign-in | page for everybody else. The console keeps its own "/" — its routes are | registered first and win. | - | The legal pages are deliberately NOT bound. An imprint has to be reachable - | from wherever the reader is standing, and a mail footer links to it from - | whichever host sent the mail. + | Binding the legal pages is also what makes route('legal.impressum') + | produce a www URL from inside a queued mail, where there is no request to + | take a hostname from. */ 'site_host' => strtolower(trim((string) env('SITE_HOST', ''))), diff --git a/config/fortify.php b/config/fortify.php index 9e971b0..cc7c717 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -88,7 +88,7 @@ return [ 'prefix' => '', - 'domain' => null, + 'domain' => strtolower(trim((string) env('APP_HOST', ''))) ?: null, /* |-------------------------------------------------------------------------- diff --git a/routes/web.php b/routes/web.php index 7692754..fe8d268 100644 --- a/routes/web.php +++ b/routes/web.php @@ -152,99 +152,134 @@ if ($statusHost !== '') { Route::get('/status', StatusController::class)->name('status'); } -// The website, and what stands at "/" everywhere else. -// -// The landing page was host-agnostic, so app.clupilot.com served the shop -// window: marketing copy and a "sign up" button, at the address where a -// customer's servers live. Reported exactly that way. -// -// Registered in this order for the reason the status page above already -// documents: a host-agnostic route registered earlier beats a domain-bound one -// registered later, so the bound one has to come first. +/* +| 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)->get('/', LandingController::class)->name('home'); + Route::domain($siteHost)->group($publicSite); - // Every other host — the portal, a bare IP — answers with the product. - // Never a 404: "/" is what somebody types from memory, and turning that + // "/" 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'); -} else { - 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"; +if ($appHost === '') { + $portal(); +} - return response($body, 200, ['Content-Type' => 'text/plain']); -})->name('robots'); +if ($siteHost === '') { + $publicSite(); +} -// Stripe webhook — paid order → customer provisioning run (CSRF-exempt, signed). +// 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'); - -// Public legal pages (placeholders — replace with real content before launch). -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. Through route() rather than a literal - // path: once the page has its own hostname, a relative redirect would land - // on the host the visitor is already on, where it no longer answers. - Route::get('/status', fn () => redirect()->to(route('status'), 301))->name('status'); -}); - -// Guest auth pages — full-page class-based Livewire components (R1/R2). Fortify -// handles the POST actions (login.store, two-factor.login.store) with views off. -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 no limiter). - 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 it is 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'); -}); diff --git a/tests/Feature/PortalHostTest.php b/tests/Feature/PortalHostTest.php index 27ac763..f52bfaf 100644 --- a/tests/Feature/PortalHostTest.php +++ b/tests/Feature/PortalHostTest.php @@ -6,19 +6,27 @@ use Illuminate\Routing\Router; use Illuminate\Support\Facades\Route; /** - * The portal's hostname shows the product, never the shop window. + * The website and the portal each answer on their own hostname, and nowhere + * else. * - * The landing page was registered host-agnostically, so app.clupilot.com served - * marketing copy, a pricing table and a "sign up" call to action — at the - * address where a customer's servers live. Reported exactly that way. + * A route belongs to a host. One that is not registered for the host being + * asked cannot answer, and the honest reply is 404 — that is the whole + * mechanism, and Route::domain() is all of it. + * + * The first attempt bound the landing page alone and left everything else + * host-agnostic. The website duly vanished from the portal, and the portal + * stayed reachable from the website: www.clupilot.com/dashboard kept working. + * Half a separation is no separation, which is why these tests check BOTH + * directions for every route rather than only the one that was reported. * * Routes are built once at boot, so changing config alone changes nothing. - * These re-register them against a fresh router, which is the idiom - * WelcomeTest already uses for the status host. + * These re-register them against a fresh router — the idiom WelcomeTest already + * uses for the status host. */ -function routerWithSiteHost(?string $host): Router +function routerWithHosts(?string $siteHost, ?string $appHost = null): Router { - config()->set('admin_access.site_host', $host ?? ''); + config()->set('admin_access.site_host', $siteHost ?? ''); + config()->set('admin_access.app_host', $appHost ?? ''); $router = new Router(app('events'), app()); Route::swap($router); @@ -28,19 +36,44 @@ function routerWithSiteHost(?string $host): Router return $router; } -it('does not serve the website on the portal’s hostname', function () { - $router = routerWithSiteHost('www.example.test'); +/** Does this host answer this path at all? */ +function answers(Router $router, string $host, string $path): bool +{ + try { + $router->getRoutes()->match(Request::create("http://{$host}{$path}", 'GET')); - $action = fn (string $host) => $router->getRoutes() - ->match(Request::create("http://{$host}/", 'GET')) - ->getActionName(); + return true; + } catch (Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { + return false; + } +} - expect($action('www.example.test'))->toContain('LandingController') - ->and($action('app.example.test'))->not->toContain('LandingController'); +it('does not serve the portal on the website’s hostname', function () { + // The half that was missing, and the one that was reported back. + $router = routerWithHosts('www.example.test', 'app.example.test'); + + foreach (['/dashboard', '/cloud', '/invoices', '/settings', '/login', '/register'] as $path) { + expect(answers($router, 'app.example.test', $path))->toBeTrue("app should answer {$path}") + ->and(answers($router, 'www.example.test', $path))->toBeFalse("www must not answer {$path}"); + } }); -it('sends a signed-out visitor on the portal host to the sign-in page', function () { - $router = routerWithSiteHost('www.example.test'); +it('does not serve the website on the portal’s hostname', function () { + $router = routerWithHosts('www.example.test', 'app.example.test'); + + foreach (['/robots.txt', '/legal/impressum', '/legal/agb'] as $path) { + expect(answers($router, 'www.example.test', $path))->toBeTrue("www should answer {$path}") + ->and(answers($router, 'app.example.test', $path))->toBeFalse("app must not answer {$path}"); + } + + expect($router->getRoutes()->match(Request::create('http://www.example.test/', 'GET'))->getActionName()) + ->toContain('LandingController'); +}); + +it('answers "/" on the portal host with the product, never a 404', function () { + // "/" is what somebody types from memory. Turning that into an error page + // to make a point about hostnames helps nobody. + $router = routerWithHosts('www.example.test', 'app.example.test'); $response = $router->dispatch(Request::create('http://app.example.test/', 'GET')); @@ -51,35 +84,39 @@ it('sends a signed-out visitor on the portal host to the sign-in page', function it('sends a signed-in customer on the portal host to their dashboard', function () { $this->actingAs(User::factory()->create()); - $router = routerWithSiteHost('www.example.test'); - + $router = routerWithHosts('www.example.test', 'app.example.test'); $response = $router->dispatch(Request::create('http://app.example.test/', 'GET')); expect($response->getStatusCode())->toBe(302) ->and($response->headers->get('Location'))->toContain('/dashboard'); }); -it('leaves the imprint reachable from wherever the reader is standing', function () { - // Deliberately not bound to the website's host: an imprint is a legal - // requirement, and a mail footer links to it from whichever host sent the - // mail. - $router = routerWithSiteHost('www.example.test'); +it('keeps the Stripe webhook reachable on any host', function () { + // Stripe posts to whichever URL it was configured with, and a hostname + // mismatch there loses payments rather than showing a 404 to a person. + $router = routerWithHosts('www.example.test', 'app.example.test'); - $action = fn (string $host) => $router->getRoutes() - ->match(Request::create("http://{$host}/legal/impressum", 'GET')) - ->getActionName(); + $matches = function (string $host) use ($router) { + try { + $router->getRoutes()->match(Request::create("http://{$host}/webhooks/stripe", 'POST')); - expect($action('app.example.test'))->toBe($action('www.example.test')); + return true; + } catch (Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { + return false; + } + }; + + expect($matches('www.example.test'))->toBeTrue() + ->and($matches('app.example.test'))->toBeTrue() + ->and($matches('anything.example.test'))->toBeTrue(); }); -it('keeps the landing page on every host while no website host is configured', function () { - // The installed base, and every development machine reached by IP. - $router = routerWithSiteHost(null); +it('binds nothing at all while no hostnames are configured', function () { + // The installed base, and every development machine reached by a bare IP. + // Switching this on by default would lock those out of everything at once. + $router = routerWithHosts(null, null); - $action = fn (string $host) => $router->getRoutes() - ->match(Request::create("http://{$host}/", 'GET')) - ->getActionName(); - - expect($action('app.example.test'))->toContain('LandingController') - ->and($action('anything.example.test'))->toContain('LandingController'); + foreach (['/', '/dashboard', '/legal/impressum'] as $path) { + expect(answers($router, 'anything.example.test', $path))->toBeTrue("everything should answer {$path}"); + } });