From 49a3ca2e338c7f97b149eb82a13d5d84a0316f21 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 00:26:22 +0200 Subject: [PATCH] Keep the shop window off the portal's front door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing page was registered host-agnostically, so app.clupilot.com served it: 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. SITE_HOST binds the website to its own hostname. Every other host — the portal, a bare IP — answers "/" with the product: the dashboard for somebody signed in, the sign-in page for everybody else. Never a 404 there: "/" is what people type from memory, and turning that into an error page to make a point about hostnames helps nobody. Empty keeps today's behaviour, which every development machine reached by IP depends on. The console is unaffected — its routes are registered first and win on their own host, the ordering the status page above already documents. 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. Also drops "just reply to this message" from two mails. A reply lands in the billing or provisioning mailbox rather than in the support queue somebody actually works through — so it now says to raise it in the portal, where it gets a place in that queue instead of getting lost. Co-Authored-By: Claude Opus 5 --- .env.example | 8 +++ config/admin_access.php | 19 +++++++ lang/de/orders.php | 4 +- lang/de/provisioning.php | 2 +- lang/en/orders.php | 4 +- lang/en/provisioning.php | 2 +- routes/web.php | 24 ++++++++- tests/Feature/PortalHostTest.php | 85 ++++++++++++++++++++++++++++++++ 8 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/PortalHostTest.php diff --git a/.env.example b/.env.example index 7ac9a47..62ff138 100644 --- a/.env.example +++ b/.env.example @@ -204,6 +204,14 @@ 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. +# +# 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. +SITE_HOST= + ADMIN_HOSTS=admin.dev.clupilot.com ADMIN_HOST_EXCLUSIVE=true diff --git a/config/admin_access.php b/config/admin_access.php index f6e2baf..3c95bae 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -43,6 +43,25 @@ return [ */ 'status_host' => strtolower(trim((string) env('STATUS_HOST', ''))), + /* + | Hostname the public website lives on, e.g. www.clupilot.com. + | + | Empty means the landing page answers on every host, which is what it did + | — including on the portal's own hostname. Somebody who typed + | 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 + | 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. + */ + 'site_host' => strtolower(trim((string) env('SITE_HOST', ''))), + /* | Key for VPN configs stored at rest (32 bytes, base64). Separate from | APP_KEY on purpose — see App\Services\Wireguard\ConfigVault. Empty means diff --git a/lang/de/orders.php b/lang/de/orders.php index f8d98e5..8b91bf4 100644 --- a/lang/de/orders.php +++ b/lang/de/orders.php @@ -14,5 +14,7 @@ return [ // eine zugesagte Minute, die verstreicht, ist schlimmer als keine Zusage. 'mail_next' => 'Sobald alles läuft, bekommen Sie eine zweite Nachricht mit der Adresse Ihrer Cloud. Bis dahin ist nichts zu tun.', 'mail_action' => 'Zu Ihren Rechnungen', - 'mail_help' => 'Fragen zur Bestellung? Antworten Sie einfach auf diese Nachricht.', + // Kein "antworten Sie einfach": eine Antwort landet im Rechnungspostfach + // und nicht in der Warteschlange, die jemand abarbeitet. + 'mail_help' => 'Stimmt etwas nicht? Melden Sie sich über den Support in Ihrem Kundenportal — dort landet Ihre Anfrage in der Warteschlange und geht nicht verloren.', ]; diff --git a/lang/de/provisioning.php b/lang/de/provisioning.php index 0f93bc6..dcebac7 100644 --- a/lang/de/provisioning.php +++ b/lang/de/provisioning.php @@ -28,7 +28,7 @@ return [ 'field_storage' => 'Speicher', 'field_location' => 'Standort', 'value_storage' => ':gb GB', - 'ready_help' => 'Fragen zur Einrichtung? Antworten Sie einfach auf diese Nachricht.', + 'ready_help' => 'Stimmt etwas nicht? Melden Sie sich über den Support in Ihrem Kundenportal.', 'ready_subject' => 'Ihre CluPilot Cloud ist bereit', 'ready_greeting' => 'Willkommen bei CluPilot!', 'ready_line' => 'Ihre Cloud ist eingerichtet und einsatzbereit.', diff --git a/lang/en/orders.php b/lang/en/orders.php index bdef97d..fce8320 100644 --- a/lang/en/orders.php +++ b/lang/en/orders.php @@ -14,5 +14,7 @@ return [ // a promised minute that slips is worse than no promise at all. 'mail_next' => 'You will get a second message with the address of your cloud once it is running. Nothing to do until then.', 'mail_action' => 'View your invoices', - 'mail_help' => 'Questions about the order? Just reply to this message.', + // No "just reply": a reply lands in the billing mailbox rather than in + // the queue somebody actually works through. + 'mail_help' => 'Something not right? Raise it through support in your portal — it lands in the queue there instead of getting lost.', ]; diff --git a/lang/en/provisioning.php b/lang/en/provisioning.php index a4d6ce0..7d4cd01 100644 --- a/lang/en/provisioning.php +++ b/lang/en/provisioning.php @@ -28,7 +28,7 @@ return [ 'field_storage' => 'Storage', 'field_location' => 'Location', 'value_storage' => ':gb GB', - 'ready_help' => 'Questions about the setup? Just reply to this message.', + 'ready_help' => 'Something not right? Raise it through support in your portal.', 'ready_subject' => 'Your CluPilot cloud is ready', 'ready_greeting' => 'Welcome to CluPilot!', 'ready_line' => 'Your cloud is set up and ready to use.', diff --git a/routes/web.php b/routes/web.php index 8d2bf54..7692754 100644 --- a/routes/web.php +++ b/routes/web.php @@ -152,7 +152,29 @@ if ($statusHost !== '') { Route::get('/status', StatusController::class)->name('status'); } -Route::get('/', LandingController::class)->name('home'); +// 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. +$siteHost = (string) config('admin_access.site_host'); + +if ($siteHost !== '') { + Route::domain($siteHost)->get('/', LandingController::class)->name('home'); + + // Every other host — the portal, a bare IP — answers with the product. + // 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. diff --git a/tests/Feature/PortalHostTest.php b/tests/Feature/PortalHostTest.php new file mode 100644 index 0000000..27ac763 --- /dev/null +++ b/tests/Feature/PortalHostTest.php @@ -0,0 +1,85 @@ +set('admin_access.site_host', $host ?? ''); + + $router = new Router(app('events'), app()); + Route::swap($router); + require base_path('routes/web.php'); + $router->getRoutes()->refreshNameLookups(); + + return $router; +} + +it('does not serve the website on the portal’s hostname', function () { + $router = routerWithSiteHost('www.example.test'); + + $action = fn (string $host) => $router->getRoutes() + ->match(Request::create("http://{$host}/", 'GET')) + ->getActionName(); + + expect($action('www.example.test'))->toContain('LandingController') + ->and($action('app.example.test'))->not->toContain('LandingController'); +}); + +it('sends a signed-out visitor on the portal host to the sign-in page', function () { + $router = routerWithSiteHost('www.example.test'); + + $response = $router->dispatch(Request::create('http://app.example.test/', 'GET')); + + expect($response->getStatusCode())->toBe(302) + ->and($response->headers->get('Location'))->toContain('/login'); +}); + +it('sends a signed-in customer on the portal host to their dashboard', function () { + $this->actingAs(User::factory()->create()); + + $router = routerWithSiteHost('www.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'); + + $action = fn (string $host) => $router->getRoutes() + ->match(Request::create("http://{$host}/legal/impressum", 'GET')) + ->getActionName(); + + expect($action('app.example.test'))->toBe($action('www.example.test')); +}); + +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); + + $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'); +});