{{ __('coming_soon.title') }}
+{{ __('coming_soon.body') }}
+ +{{ __('coming_soon.contact') }}
+diff --git a/.env.example b/.env.example index dcc5069..529feba 100644 --- a/.env.example +++ b/.env.example @@ -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 ───────────────────────────────────────── diff --git a/app/Http/Middleware/PublicSiteGate.php b/app/Http/Middleware/PublicSiteGate.php new file mode 100644 index 0000000..ced7323 --- /dev/null +++ b/app/Http/Middleware/PublicSiteGate.php @@ -0,0 +1,55 @@ +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); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index e9d2cfd..967c26c 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -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'), diff --git a/app/Support/Settings.php b/app/Support/Settings.php new file mode 100644 index 0000000..55ea628 --- /dev/null +++ b/app/Support/Settings.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 0a2ab22..5199197 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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. diff --git a/config/admin_access.php b/config/admin_access.php index bd86eda..c113118 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -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')), + ))), + ]; diff --git a/database/migrations/2026_07_25_220000_create_app_settings_table.php b/database/migrations/2026_07_25_220000_create_app_settings_table.php new file mode 100644 index 0000000..93c2698 --- /dev/null +++ b/database/migrations/2026_07_25_220000_create_app_settings_table.php @@ -0,0 +1,27 @@ +string('key')->primary(); + $table->text('value')->nullable(); // JSON-encoded + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_settings'); + } +}; diff --git a/database/migrations/2026_07_25_220001_add_site_manage_capability.php b/database/migrations/2026_07_25_220001_add_site_manage_capability.php new file mode 100644 index 0000000..5564eab --- /dev/null +++ b/database/migrations/2026_07_25_220001_add_site_manage_capability.php @@ -0,0 +1,33 @@ +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(); + } +}; diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 70cb343..e9112a3 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -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; diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 162691e..ebc0e5b 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -1,6 +1,18 @@ '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', diff --git a/lang/de/coming_soon.php b/lang/de/coming_soon.php new file mode 100644 index 0000000..de64057 --- /dev/null +++ b/lang/de/coming_soon.php @@ -0,0 +1,7 @@ + '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', +]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 0e90c10..c32fde0 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -1,6 +1,18 @@ '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', diff --git a/lang/en/coming_soon.php b/lang/en/coming_soon.php new file mode 100644 index 0000000..05a1b43 --- /dev/null +++ b/lang/en/coming_soon.php @@ -0,0 +1,7 @@ + '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', +]; diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index eb05362..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/resources/views/coming-soon.blade.php b/resources/views/coming-soon.blade.php new file mode 100644 index 0000000..dfb3d5b --- /dev/null +++ b/resources/views/coming-soon.blade.php @@ -0,0 +1,24 @@ + + +
+ + + {{-- Belt and braces with the X-Robots-Tag header: some crawlers read only one. --}} + +{{ __('coming_soon.body') }}
+ +{{ __('coming_soon.contact') }}
++ {{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }} +
++ {{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }} + + {{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }} + +
+