Keep the shop window off the portal's front door
tests / pest (push) Failing after 7m35s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-29 00:26:22 +02:00
parent 7a5a71dc89
commit 49a3ca2e33
8 changed files with 143 additions and 5 deletions

View File

@ -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

View File

@ -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

View File

@ -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.',
];

View File

@ -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.',

View File

@ -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.',
];

View File

@ -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.',

View File

@ -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.

View File

@ -0,0 +1,85 @@
<?php
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Route;
/**
* The portal's hostname shows the product, never the shop window.
*
* 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.
*
* 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.
*/
function routerWithSiteHost(?string $host): Router
{
config()->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 portals 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');
});