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'); +});