CluPilotCloud/app/Livewire/CustomDomain.php

210 lines
8.0 KiB
PHP

<?php
namespace App\Livewire;
use App\Actions\ReapplyInstanceAddress;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Instance;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Domains\DomainVerifier;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Str;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The customer's own domain, and the proof that it is theirs.
*
* Two things happen here and they are deliberately separate: pointing the
* domain at us (an A record, which we cannot check for them because it may be
* behind a CDN) and proving they own it (a TXT record, which we can). Only the
* second one gates anything.
*
* A domain can be changed at any time. It was suggested that it should be
* fixed once set — it should not: adding or moving a domain is a proxy entry, a
* certificate and one line in trusted_domains, and a customer who wants to move
* their address after three months is a customer who is still here.
*/
class CustomDomain extends Component
{
use ResolvesCustomer;
#[Validate('nullable|string|max:253')]
public string $domain = '';
public bool $justChecked = false;
public function mount(): void
{
// Here rather than as route middleware: a Livewire component answers
// POSTs to /livewire/update on its own, so a guard that only sits on
// the page route leaves every action on this one reachable to a plan
// that may not have a domain at all. CustomDomainAccess is the single
// place the rule lives — plan feature or booked module, never both
// asked separately.
abort_unless(app(CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);
$this->domain = (string) ($this->instance()?->custom_domain ?? '');
}
private function instance(): ?Instance
{
// The shared resolver, not `auth()->user()->customer`: accounts created
// before the explicit link exists are matched by address, and this page
// must not be the one place that forgets that.
return $this->customer()?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
}
/**
* Save the domain and mint a fresh token.
*
* A NEW token every time the domain changes. Reusing the old one would let
* a customer who once verified example.com claim any other domain later
* without touching its DNS — the record for the first domain would still be
* sitting there, and the value is all that is compared.
*/
public function save(DomainVerifier $verifier): void
{
$this->validate();
$instance = $this->instance();
if ($instance === null) {
return;
}
$domain = Str::lower(trim($this->domain));
$domain = preg_replace('#^https?://#', '', $domain);
$domain = rtrim((string) $domain, '/.');
// A hostname, not a URL and not a path. Loose on purpose — the DNS
// lookup is the real test, and a pattern strict enough to be a
// specification would reject somebody's perfectly valid domain.
if ($domain !== '' && ! preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/', $domain)) {
$this->addError('domain', __('domain.invalid'));
return;
}
// Whether anything is being SERVED under the old domain right now. A
// change here can only ever take a verified domain away — the new one
// starts unproven — so this is the one question that decides whether
// the proxy and Nextcloud have to be told.
$wasVerified = $instance->domainIsVerified();
// Clearing the field takes the domain away entirely, including its
// verification — the instance falls back to its platform address.
if ($domain === '') {
$instance->forceFill([
'custom_domain' => null, 'domain_token' => null,
'domain_verified_at' => null, 'domain_cert_ok' => false,
'domain_checked_at' => null,
'domain_error' => null, 'domain_failures' => 0,
])->save();
$this->stopOrStartServing($instance, $wasVerified);
$this->domain = '';
$this->dispatch('notify', message: __('domain.removed'));
return;
}
if ($domain !== $instance->custom_domain) {
$instance->forceFill([
'custom_domain' => $domain,
'domain_token' => $verifier->newToken(),
'domain_verified_at' => null,
'domain_cert_ok' => false,
'domain_checked_at' => null,
'domain_error' => null,
'domain_failures' => 0,
])->save();
$this->stopOrStartServing($instance, $wasVerified);
}
$this->domain = $domain;
$this->dispatch('notify', message: __('domain.saved'));
}
/**
* Look now, rather than waiting for tonight.
*
* The nightly run is what keeps a domain honest; this button is only so
* that somebody who has just created the record does not have to wait until
* the morning to find out whether they typed it correctly.
*/
public function checkNow(DomainVerifier $verifier): void
{
$instance = $this->instance();
if ($instance === null || blank($instance->custom_domain)) {
return;
}
$wasVerified = $instance->domainIsVerified();
$wasServed = $instance->domainIsServed();
$present = $verifier->proofPresent($instance);
$instance->forceFill([
'domain_checked_at' => now(),
'domain_verified_at' => $present ? ($instance->domain_verified_at ?? now()) : null,
'domain_cert_ok' => $present ? $instance->domain_cert_ok : false,
'domain_error' => $present ? null : 'missing_txt',
'domain_failures' => $present ? 0 : $instance->domain_failures + 1,
])->save();
// This button flips verification both ways, so it is a place the
// address changes and has to be re-applied like any other. It also
// re-applies a domain that is proven but still answering nothing —
// which is what somebody is doing here when they add the A record
// after the TXT one and press it again, and it is the only way they
// have of asking us to look now rather than at the next change.
if ($present !== $wasVerified || ($present && ! $wasServed)) {
app(ReapplyInstanceAddress::class)($instance);
}
$this->justChecked = true;
$this->dispatch('notify', message: __($present ? 'domain.verified' : 'domain.not_found'));
}
/**
* Tell the proxy and Nextcloud that the address has moved.
*
* Only when something WAS being served: an unproven domain never reached a
* router or a trusted_domains entry, so replacing one with another changes
* nothing that has to be un-done, and starting a run for it would be remote
* work on a live machine for no reason at all.
*/
private function stopOrStartServing(Instance $instance, bool $wasVerified): void
{
if ($wasVerified) {
app(ReapplyInstanceAddress::class)($instance);
}
}
public function render()
{
$instance = $this->instance();
$verifier = app(DomainVerifier::class);
return view('livewire.custom-domain', [
'instance' => $instance,
'platformAddress' => $instance?->subdomain
? $instance->subdomain.'.'.ProvisioningSettings::dnsZone()
: null,
'recordName' => $instance && filled($instance->custom_domain)
? $verifier->recordName($instance->custom_domain)
: null,
'recordValue' => $instance && filled($instance->domain_token)
? $verifier->recordValue($instance)
: null,
]);
}
}