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
parent
7f60f88542
commit
e2b4cdbac4
|
|
@ -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 ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
))),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue