feat(site): switch the public website and portal off from the console

While the product is still being built, the marketing site and the customer
portal should not be reachable — but they must stay reachable for us.

- A toggle in the console (site.manage, Owner/Admin) stored in a new
  app_settings table, because this has to be flippable without a deploy.
- Outsiders get a placeholder with 503 + noindex, not 200: a 200 invites search
  engines to index the placeholder as the site's content, which is far harder
  to undo than to prevent.
- Anyone on the management VPN, and any signed-in operator, sees the real site.
  The console, Livewire's endpoint, the Stripe webhook and the health check are
  always reachable — otherwise the switch could only be flipped once.
- robots.txt is generated by the app and follows the switch. It had to stop
  being a static file: nginx short-circuited it and Laravel's stock file said
  'Disallow:' with an empty value, so crawlers were never told anything.
- Settings reads fall back to the default when the table is unavailable. The
  gate reads one on every request, so a deploy running new code before migrate
  would otherwise answer the entire site with a 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:10:27 +02:00
parent 7f60f88542
commit e2b4cdbac4
18 changed files with 425 additions and 3 deletions

View File

@ -168,6 +168,10 @@ MONITORING_ATTEMPTS=2
# Erzeugen: head -c 32 /dev/urandom | base64
VPN_CONFIG_KEY=
# Netze, die als "wir" gelten, solange die Website versteckt ist
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1
# ── Nextcloud blueprint template ─────────────────────────────────────────

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Middleware;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Hides the marketing site and the customer portal while the product is still
* being built, without hiding it from us.
*
* Switched from the console (site.public). Anyone coming through the management
* VPN, and any signed-in operator, sees the real thing; everyone else crawlers
* included gets a placeholder.
*
* Answers 503 with Retry-After and X-Robots-Tag rather than 200: a 200 would
* invite search engines to index the placeholder as the site's content, and
* getting that out of an index again is much harder than keeping it out.
*/
class PublicSiteGate
{
/**
* Paths that must keep working regardless: the console itself (otherwise
* the switch could not be flipped back), Livewire's endpoint that drives it,
* Stripe's webhook, and the health check.
*/
private const ALWAYS_ALLOWED = ['admin', 'admin/*', 'livewire/*', 'webhooks/*', 'up', 'robots.txt'];
public function handle(Request $request, Closure $next): Response
{
if ($request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) {
return $next($request);
}
if ($this->fromManagementNetwork($request) || $request->user()?->isOperator()) {
return $next($request);
}
return response()->view('coming-soon', [], 503)->withHeaders([
'Retry-After' => 3600,
'X-Robots-Tag' => 'noindex, nofollow, noarchive',
'Cache-Control' => 'no-store',
]);
}
private function fromManagementNetwork(Request $request): bool
{
$ranges = (array) config('admin_access.trusted_ranges', []);
return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\User;
use App\Models\VpnPeer;
use App\Support\Settings as AppSettings;
use App\Provisioning\Jobs\ApplyVpnPeer;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
@ -50,6 +51,23 @@ class Settings extends Component
$this->email = auth()->user()->email;
}
/**
* Take the marketing site and the customer portal offline, or back online.
* The console keeps working either way otherwise this switch could only
* ever be flipped once.
*/
public function toggleSiteVisibility(): void
{
$this->authorize('site.manage');
$public = ! AppSettings::bool('site.public', true);
AppSettings::set('site.public', $public);
$this->dispatch('notify', message: $public
? __('admin_settings.site_now_public')
: __('admin_settings.site_now_hidden'));
}
public function saveAccount(): void
{
$user = auth()->user();
@ -233,6 +251,13 @@ class Settings extends Component
]);
return view('livewire.admin.settings', [
'sitePublic' => AppSettings::bool('site.public', true),
'canManageSite' => auth()->user()?->can('site.manage') ?? false,
// Shown so nobody has to guess why they still see the real site.
'viewerOnVpn' => \Symfony\Component\HttpFoundation\IpUtils::checkIp(
(string) request()->ip(),
(array) config('admin_access.trusted_ranges', []),
),
'staff' => $staff,
'roles' => User::OPERATOR_ROLES,
'canManageStaff' => auth()->user()->can('staff.manage'),

60
app/Support/Settings.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Cache;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Runtime settings an operator can change from the console.
*
* Cached because the public-site gate reads one of these on every single
* request; the cache is dropped on write, so a flip takes effect immediately
* rather than "within a minute".
*/
final class Settings
{
private const CACHE_PREFIX = 'app_setting:';
public static function get(string $key, mixed $default = null): mixed
{
$raw = Cache::rememberForever(self::CACHE_PREFIX.$key, function () use ($key) {
try {
$row = DB::table('app_settings')->where('key', $key)->value('value');
} catch (QueryException $e) {
// The gate reads a setting on every request, so an unreachable
// or not-yet-migrated table would take the whole site down —
// including during a deploy where new code runs before migrate.
// Fall back to the caller's default instead, and say so.
Log::warning('app_settings unavailable, using default', [
'key' => $key, 'error' => $e->getMessage(),
]);
return '__missing__';
}
// Sentinel: distinguishes "stored null" from "never stored", so a
// missing row does not get re-queried on every request.
return $row === null ? '__missing__' : $row;
});
return $raw === '__missing__' ? $default : json_decode($raw, true);
}
public static function set(string $key, mixed $value): void
{
DB::table('app_settings')->updateOrInsert(
['key' => $key],
['value' => json_encode($value), 'updated_at' => now(), 'created_at' => now()],
);
Cache::forget(self::CACHE_PREFIX.$key);
}
public static function bool(string $key, bool $default = false): bool
{
return (bool) self::get($key, $default);
}
}

View File

@ -24,6 +24,10 @@ return Application::configure(basePath: dirname(__DIR__))
// that an operator console is hosted here. Self-scoped to /admin*.
$middleware->prependToGroup('web', \App\Http\Middleware\RestrictAdminHost::class);
// Runs after the host guard: the console must stay reachable even while
// the public site is switched off.
$middleware->appendToGroup('web', \App\Http\Middleware\PublicSiteGate::class);
// TLS is terminated by the reverse proxy (Zoraxy on the private LAN), so
// without this Laravel sees plain http and builds http:// URLs into an
// https page — every asset and redirect breaks as mixed content.

View File

@ -33,4 +33,18 @@ return [
*/
'vpn_config_key' => env('VPN_CONFIG_KEY', ''),
/*
| Networks that count as "us" while the public site is hidden
| (PublicSiteGate). The WireGuard management subnet by default, so anyone on
| the VPN sees the real site while the outside world sees a placeholder.
|
| Note this is compared against the CLIENT address, which is only correct
| because TrustProxies is configured behind an untrusted proxy every
| request would look like it came from the proxy.
*/
'trusted_ranges' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('TRUSTED_RANGES', '10.66.0.0/24,127.0.0.1')),
))),
];

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Small key/value store for things an operator flips at runtime starting with
* whether the public site is visible at all. Deliberately not config/env: this
* has to be switchable from the console without a deploy or a container restart.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('app_settings', function (Blueprint $table) {
$table->string('key')->primary();
$table->text('value')->nullable(); // JSON-encoded
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('app_settings');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* `site.manage` take the public website and the customer portal offline or
* back online. Owner and Admin only: it is the switch that decides whether the
* business is reachable at all.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
Permission::findOrCreate('site.manage', 'web');
foreach (['Owner', 'Admin'] as $role) {
Role::findOrCreate($role, 'web')->givePermissionTo('site.manage');
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
public function down(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
Permission::query()->where('name', 'site.manage')->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
};

View File

@ -47,7 +47,8 @@ server {
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
# robots.txt is generated by the app (it changes with the site's visibility),
# so it must NOT be short-circuited to a file on disk.
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;

View File

@ -1,6 +1,18 @@
<?php
return [
'site_title' => 'Sichtbarkeit der Website',
'site_public' => 'Öffentlich',
'site_hidden' => 'Versteckt',
'site_public_body' => 'Website und Kundenportal sind für alle erreichbar und werden von Suchmaschinen erfasst.',
'site_hidden_body' => 'Besucher von außen sehen nur eine Platzhalterseite (Status 503, „noindex“). Über das VPN und als angemeldeter Mitarbeiter sehen Sie weiterhin alles. Die Konsole bleibt in jedem Fall erreichbar.',
'site_your_view' => 'Ihre Adresse :ip —',
'site_via_vpn' => 'über das VPN, Sie sehen immer die echte Seite.',
'site_not_vpn' => 'nicht im VPN. Als angemeldeter Mitarbeiter sehen Sie die echte Seite trotzdem.',
'site_hide' => 'Website verstecken',
'site_show' => 'Website veröffentlichen',
'site_now_public' => 'Website ist wieder öffentlich.',
'site_now_hidden' => 'Website ist jetzt versteckt.',
'title' => 'Einstellungen',
'subtitle' => 'Ihr Operator-Konto und die Verwaltung Ihres Teams.',
'save' => 'Speichern',

7
lang/de/coming_soon.php Normal file
View File

@ -0,0 +1,7 @@
<?php
return [
'title' => 'Wir bauen gerade.',
'body' => 'CluPilot ist noch nicht öffentlich. Managed Nextcloud aus deutschen und finnischen Rechenzentren — betreut, gesichert, aktuell gehalten.',
'contact' => 'Fragen? info@clupilot.com',
];

View File

@ -1,6 +1,18 @@
<?php
return [
'site_title' => 'Website visibility',
'site_public' => 'Public',
'site_hidden' => 'Hidden',
'site_public_body' => 'Website and customer portal are reachable by anyone and indexed by search engines.',
'site_hidden_body' => 'Outside visitors only see a placeholder (status 503, noindex). Through the VPN and as a signed-in staff member you still see everything. The console stays reachable either way.',
'site_your_view' => 'Your address :ip —',
'site_via_vpn' => 'through the VPN, you always see the real site.',
'site_not_vpn' => 'not on the VPN. As a signed-in staff member you still see the real site.',
'site_hide' => 'Hide website',
'site_show' => 'Publish website',
'site_now_public' => 'The website is public again.',
'site_now_hidden' => 'The website is hidden now.',
'title' => 'Settings',
'subtitle' => 'Your operator account and team management.',
'save' => 'Save',

7
lang/en/coming_soon.php Normal file
View File

@ -0,0 +1,7 @@
<?php
return [
'title' => 'We are still building.',
'body' => 'CluPilot is not public yet. Managed Nextcloud from German and Finnish datacentres — operated, backed up, kept current.',
'contact' => 'Questions? info@clupilot.com',
];

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow:

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Belt and braces with the X-Robots-Tag header: some crawlers read only one. --}}
<meta name="robots" content="noindex, nofollow, noarchive">
<title>{{ config('app.name') }}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
@vite(['resources/css/app.css'])
</head>
<body class="grid min-h-full place-items-center bg-canvas px-6 py-16 text-center">
<main class="max-w-md">
<div class="mx-auto w-fit">
<x-ui.logo class="h-9" />
</div>
<h1 class="mt-8 text-2xl font-semibold tracking-tight text-ink">{{ __('coming_soon.title') }}</h1>
<p class="mt-3 text-sm leading-relaxed text-muted">{{ __('coming_soon.body') }}</p>
<p class="mt-8 text-xs text-faint">{{ __('coming_soon.contact') }}</p>
</main>
</body>
</html>

View File

@ -5,6 +5,36 @@
</div>
{{-- My account --}}
@if ($canManageSite)
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('admin_settings.site_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $sitePublic ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $sitePublic ? __('admin_settings.site_public') : __('admin_settings.site_hidden') }}
</span>
</div>
<p class="mt-1.5 max-w-xl text-sm text-muted">
{{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}
</p>
<p class="mt-2 text-xs text-faint">
{{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }}
<span class="{{ $viewerOnVpn ? 'text-success' : '' }}">
{{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }}
</span>
</p>
</div>
<x-ui.button :variant="$sitePublic ? 'secondary' : 'primary'" wire:click="toggleSiteVisibility" wire:loading.attr="disabled">
<x-ui.icon :name="$sitePublic ? 'lock' : 'unlock'" class="size-4" />
{{ $sitePublic ? __('admin_settings.site_hide') : __('admin_settings.site_show') }}
</x-ui.button>
</div>
</div>
@endif
<form wire:submit="saveAccount" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">

View File

@ -16,6 +16,16 @@ use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('landing'))->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');
// Stripe webhook — paid order → customer provisioning run (CSRF-exempt, signed).
Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe');

View File

@ -0,0 +1,99 @@
<?php
use App\Models\User;
use App\Support\Settings;
it('serves the site normally while it is public', function () {
Settings::set('site.public', true);
$this->get('/')->assertOk();
$this->get('/login')->assertOk();
});
it('shows outsiders a placeholder while the site is hidden', function () {
Settings::set('site.public', false);
$response = $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])->get('/');
// 503, not 200: a 200 invites search engines to index the placeholder as
// the site's content, and that is much harder to undo than to prevent.
$response->assertStatus(503)
->assertSee(__('coming_soon.title'))
->assertHeader('X-Robots-Tag', 'noindex, nofollow, noarchive')
->assertHeader('Retry-After', 3600);
// The portal is hidden too, not just the marketing page.
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])->get('/login')->assertStatus(503);
});
it('lets the management VPN through', function () {
Settings::set('site.public', false);
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
$this->withServerVariables(['REMOTE_ADDR' => '10.66.0.4'])->get('/')->assertOk();
$this->withServerVariables(['REMOTE_ADDR' => '10.66.9.4'])->get('/')->assertStatus(503);
});
it('lets a signed-in operator see the real site from anywhere', function () {
Settings::set('site.public', false);
$this->actingAs(operator('Support'))
->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])
->get('/')
->assertOk();
// A customer account is not staff — the portal stays hidden for them.
$this->actingAs(User::factory()->create())
->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])
->get('/')
->assertStatus(503);
});
it('keeps the console and the webhook reachable while hidden', function () {
Settings::set('site.public', false);
// Otherwise the switch could only ever be flipped once.
$this->actingAs(operator('Owner'))
->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])
->get(route('admin.settings'))
->assertOk();
// Stripe must still be able to deliver a paid order: whatever the webhook
// makes of an empty body, the gate must not be what answers.
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])
->post('/webhooks/stripe', [])
->assertStatus(200);
});
it('tells crawlers to stay away while hidden', function () {
Settings::set('site.public', false);
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.50'])
->get('/robots.txt')
->assertOk()
->assertSee('Disallow: /');
Settings::set('site.public', true);
$this->get('/robots.txt')->assertOk()->assertSee('Allow: /');
});
it('flips visibility from the console, and only with the capability', function () {
Settings::set('site.public', true);
Livewire\Livewire::actingAs(operator('Support'))->test(App\Livewire\Admin\Settings::class)
->call('toggleSiteVisibility')
->assertForbidden();
expect(Settings::bool('site.public', true))->toBeTrue();
Livewire\Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Settings::class)
->call('toggleSiteVisibility');
expect(Settings::bool('site.public', true))->toBeFalse();
});
it('keeps serving when the settings table is unavailable', function () {
// A deploy that runs new code before migrations, or a database blip: the
// gate must fall back to "public", not take the whole site down with a 500.
Illuminate\Support\Facades\Schema::drop('app_settings');
$this->get('/')->assertOk();
});