CluPilotCloud/app/Livewire/CustomDomain.php

159 lines
5.5 KiB
PHP

<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Instance;
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
{
$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;
}
// 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_checked_at' => null,
'domain_error' => null, 'domain_failures' => 0,
])->save();
$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_checked_at' => null,
'domain_error' => null,
'domain_failures' => 0,
])->save();
}
$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;
}
$present = $verifier->proofPresent($instance);
$instance->forceFill([
'domain_checked_at' => now(),
'domain_verified_at' => $present ? ($instance->domain_verified_at ?? now()) : null,
'domain_error' => $present ? null : 'missing_txt',
'domain_failures' => $present ? 0 : $instance->domain_failures + 1,
])->save();
$this->justChecked = true;
$this->dispatch('notify', message: __($present ? 'domain.verified' : 'domain.not_found'));
}
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,
]);
}
}