From b2aede5963cf7a715d59bf31f8e84b5baffccba2 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 26 Jun 2026 16:59:19 +0000 Subject: [PATCH] Add opt-in custom domains with automatic SSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers can connect their own domain to a merchant page (storefront/event): add a domain, point an A record (apex + www) to the app server, click Verify — DNS is checked, then Ladill Domains' central SSL service issues + installs the Let's Encrypt cert and calls back to flip it live. The custom domain then serves the mapped page (host resolution on /). Feature-gated: only active when a Domains SSL API key is set, so this deploy is inert until wired. - custom_domains table + CustomDomain model - CustomDomainService (DNS verify, request cert), DomainsSslClient, DnsResolver - settings UI panel, signed SSL callback receiver, host resolution on / - feature tests (DNS verify/fail, signed callback, ownership) Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Api/SslCallbackController.php | 43 ++++++ .../Merchant/CustomDomainController.php | 54 ++++++++ .../Merchant/StorefrontController.php | 3 + app/Models/CustomDomain.php | 56 ++++++++ .../CustomDomain/CustomDomainService.php | 103 +++++++++++++++ app/Services/CustomDomain/DnsResolver.php | 17 +++ .../CustomDomain/DomainsSslClient.php | 52 ++++++++ config/customdomain.php | 27 ++++ ..._26_210000_create_custom_domains_table.php | 33 +++++ .../views/merchant/storefronts/show.blade.php | 46 +++++++ routes/api.php | 4 + routes/web.php | 20 ++- tests/Feature/CustomDomainTest.php | 124 ++++++++++++++++++ 13 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Api/SslCallbackController.php create mode 100644 app/Http/Controllers/Merchant/CustomDomainController.php create mode 100644 app/Models/CustomDomain.php create mode 100644 app/Services/CustomDomain/CustomDomainService.php create mode 100644 app/Services/CustomDomain/DnsResolver.php create mode 100644 app/Services/CustomDomain/DomainsSslClient.php create mode 100644 config/customdomain.php create mode 100644 database/migrations/2026_06_26_210000_create_custom_domains_table.php create mode 100644 tests/Feature/CustomDomainTest.php diff --git a/app/Http/Controllers/Api/SslCallbackController.php b/app/Http/Controllers/Api/SslCallbackController.php new file mode 100644 index 0000000..e8ebe87 --- /dev/null +++ b/app/Http/Controllers/Api/SslCallbackController.php @@ -0,0 +1,43 @@ +getContent(); + $signature = (string) $request->header('X-Ladill-Signature', ''); + + if ($secret === '' || ! hash_equals(hash_hmac('sha256', $body, $secret), $signature)) { + return response()->json(['error' => 'Invalid signature.'], 401); + } + + $payload = json_decode($body, true); + $data = (array) ($payload['data'] ?? []); + $host = (string) ($data['host'] ?? ''); + if ($host === '') { + return response()->json(['error' => 'Missing host.'], 422); + } + + $service->applyCallback( + $host, + (string) ($data['status'] ?? 'failed'), + $data['expires_at'] ?? null, + $data['last_error'] ?? null, + ); + + return response()->json(['status' => 'ok']); + } +} diff --git a/app/Http/Controllers/Merchant/CustomDomainController.php b/app/Http/Controllers/Merchant/CustomDomainController.php new file mode 100644 index 0000000..f4ccd3b --- /dev/null +++ b/app/Http/Controllers/Merchant/CustomDomainController.php @@ -0,0 +1,54 @@ +user_id === $request->user()->id, 403); + abort_unless($this->service->enabled(), 404); + + $data = $request->validate([ + 'host' => ['required', 'string', 'max:255', 'regex:/^(?!-)([a-z0-9-]+\.)+[a-z]{2,}$/i'], + 'include_www' => ['nullable', 'boolean'], + ]); + + $host = strtolower(preg_replace('/^www\./', '', trim($data['host']))); + + if (CustomDomain::where('host', $host)->exists()) { + return back()->withErrors(['host' => 'That domain is already connected.']); + } + + $this->service->attach($storefront, $host, (bool) ($data['include_www'] ?? true)); + + return back()->with('success', "Domain added. Point an A record for {$host} (and www) to ".config('customdomain.server_ip').', then click Verify.'); + } + + public function verify(Request $request, CustomDomain $customDomain): RedirectResponse + { + abort_unless($customDomain->user_id === $request->user()->id, 403); + + [$ok, $message] = $this->service->verifyAndProvision($customDomain); + + return back()->with($ok ? 'success' : 'error', $message); + } + + public function destroy(Request $request, CustomDomain $customDomain): RedirectResponse + { + abort_unless($customDomain->user_id === $request->user()->id, 403); + + $customDomain->delete(); + + return back()->with('success', 'Custom domain removed.'); + } +} diff --git a/app/Http/Controllers/Merchant/StorefrontController.php b/app/Http/Controllers/Merchant/StorefrontController.php index 3fffe71..4511f1c 100644 --- a/app/Http/Controllers/Merchant/StorefrontController.php +++ b/app/Http/Controllers/Merchant/StorefrontController.php @@ -129,6 +129,9 @@ class StorefrontController extends Controller 'qrCode' => $storefront->fresh(), 'previewDataUri' => $this->imageGenerator->previewDataUri($storefront), 'catalog' => $this->catalogProducts(), + 'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $storefront->id)->get(), + 'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(), + 'customDomainServerIp' => config('customdomain.server_ip'), ]); } diff --git a/app/Models/CustomDomain.php b/app/Models/CustomDomain.php new file mode 100644 index 0000000..f92cc91 --- /dev/null +++ b/app/Models/CustomDomain.php @@ -0,0 +1,56 @@ + 'boolean', + 'dns_verified_at' => 'datetime', + 'ssl_issued_at' => 'datetime', + 'ssl_expires_at' => 'datetime', + ]; + } + + public function qrCode(): BelongsTo + { + return $this->belongsTo(QrCode::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isLive(): bool + { + return $this->status === self::STATUS_ACTIVE && $this->ssl_status === self::STATUS_ACTIVE; + } + + protected static function booted(): void + { + static::saving(function (CustomDomain $d) { + $d->host = strtolower(trim((string) $d->host, " \t\n\r\0\x0B./")); + $d->host = preg_replace('/^www\./', '', $d->host) ?: $d->host; + }); + } +} diff --git a/app/Services/CustomDomain/CustomDomainService.php b/app/Services/CustomDomain/CustomDomainService.php new file mode 100644 index 0000000..e4c9d89 --- /dev/null +++ b/app/Services/CustomDomain/CustomDomainService.php @@ -0,0 +1,103 @@ +ssl->configured(); + } + + /** Attach a custom domain to a storefront (pending DNS). */ + public function attach(QrCode $storefront, string $host, bool $includeWww = true): CustomDomain + { + return CustomDomain::create([ + 'qr_code_id' => $storefront->id, + 'user_id' => $storefront->user_id, + 'host' => $host, + 'include_www' => $includeWww, + 'status' => CustomDomain::STATUS_PENDING, + 'ssl_status' => CustomDomain::STATUS_PENDING, + ]); + } + + /** + * Verify the domain's A record resolves to our app server, then ask Domains + * to issue the certificate. Returns [ok, message]. + * + * @return array{0:bool,1:string} + */ + public function verifyAndProvision(CustomDomain $domain): array + { + $serverIp = (string) config('customdomain.server_ip'); + $ips = $this->dns->aRecords($domain->host); + + if (! in_array($serverIp, $ips, true)) { + $domain->forceFill([ + 'last_error' => 'DNS not pointing to '.$serverIp.' yet (found: '.(implode(', ', $ips) ?: 'none').').', + ])->save(); + + return [false, 'DNS not verified yet. Add an A record for '.$domain->host.' pointing to '.$serverIp.', then try again.']; + } + + $domain->forceFill(['dns_verified_at' => Carbon::now(), 'last_error' => null])->save(); + + $requested = $this->ssl->requestCertificate( + $domain->host, + $domain->include_www, + route('api.ssl-callback'), + ); + + if (! $requested) { + $domain->forceFill(['last_error' => 'Could not reach the SSL service. Please try again shortly.'])->save(); + + return [false, 'Domain verified, but issuing the certificate failed. Please retry in a moment.']; + } + + return [true, 'Domain verified. Issuing your SSL certificate — this usually takes under a minute.']; + } + + /** Apply a signed SSL completion callback from Ladill Domains. */ + public function applyCallback(string $host, string $status, ?string $expiresAt, ?string $error): void + { + $domain = CustomDomain::where('host', strtolower(trim($host)))->first(); + if (! $domain) { + return; + } + + if ($status === 'active') { + $domain->forceFill([ + 'ssl_status' => CustomDomain::STATUS_ACTIVE, + 'status' => CustomDomain::STATUS_ACTIVE, + 'ssl_issued_at' => Carbon::now(), + 'ssl_expires_at' => $expiresAt ? Carbon::parse($expiresAt) : null, + 'last_error' => null, + ])->save(); + } else { + $domain->forceFill([ + 'ssl_status' => CustomDomain::STATUS_FAILED, + 'status' => CustomDomain::STATUS_FAILED, + 'last_error' => $error ?: 'Certificate issuance failed.', + ])->save(); + } + } + + /** Resolve an incoming host to a live storefront, if any. */ + public function resolveStorefront(string $host): ?QrCode + { + $host = preg_replace('/^www\./', '', strtolower(trim($host))); + $domain = CustomDomain::where('host', $host)->where('status', CustomDomain::STATUS_ACTIVE)->first(); + + return $domain?->qrCode; + } +} diff --git a/app/Services/CustomDomain/DnsResolver.php b/app/Services/CustomDomain/DnsResolver.php new file mode 100644 index 0000000..bacfde6 --- /dev/null +++ b/app/Services/CustomDomain/DnsResolver.php @@ -0,0 +1,17 @@ + the A-record IPs for a host */ + public function aRecords(string $host): array + { + $records = @dns_get_record($host, DNS_A); + if (! is_array($records)) { + return []; + } + + return array_values(array_filter(array_map(fn ($r) => $r['ip'] ?? null, $records))); + } +} diff --git a/app/Services/CustomDomain/DomainsSslClient.php b/app/Services/CustomDomain/DomainsSslClient.php new file mode 100644 index 0000000..93237ca --- /dev/null +++ b/app/Services/CustomDomain/DomainsSslClient.php @@ -0,0 +1,52 @@ +configured()) { + return false; + } + + try { + $res = Http::withToken((string) config('customdomain.ssl_api_key')) + ->acceptJson()->timeout(20) + ->post(config('customdomain.ssl_api_url').'/ssl/certificates', [ + 'host' => $host, + 'target' => 'app', + 'include_www' => $includeWww, + 'callback_url' => $callbackUrl, + 'metadata' => ['nginx_include' => config('customdomain.nginx_include')], + ]); + + if ($res->failed()) { + Log::warning('DomainsSslClient: request failed', ['host' => $host, 'status' => $res->status()]); + + return false; + } + + return true; + } catch (\Throwable $e) { + Log::warning('DomainsSslClient: request error', ['host' => $host, 'error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/config/customdomain.php b/config/customdomain.php new file mode 100644 index 0000000..3b577b6 --- /dev/null +++ b/config/customdomain.php @@ -0,0 +1,27 @@ + filter_var(env('CUSTOM_DOMAINS_ENABLED', true), FILTER_VALIDATE_BOOLEAN), + + // This app's own host(s) — requests on any other host are treated as a + // customer custom domain and resolved to the mapped storefront. + 'app_host' => $appHost, + + // Where customers point their A records (apex + www). + 'server_ip' => env('LADILL_APP_SERVER_IP', '161.97.138.149'), + + // Central SSL provisioning (Ladill Domains). + 'ssl_api_url' => rtrim(env('DOMAINS_SSL_API_URL', 'https://domains.ladill.com/api'), '/'), + 'ssl_api_key' => env('DOMAINS_API_KEY_MERCHANT', ''), + + // certbot issues + installs an nginx server block that includes this snippet + // (root + fastcgi for the Merchant app) so the custom domain serves this app. + 'nginx_include' => env('CUSTOM_DOMAINS_NGINX_INCLUDE', '/etc/nginx/snippets/ladill-merchant-app.conf'), + + // Shared secret to verify the signed completion callback from Domains. + 'callback_secret' => env('SSL_CALLBACK_SECRET', ''), +]; diff --git a/database/migrations/2026_06_26_210000_create_custom_domains_table.php b/database/migrations/2026_06_26_210000_create_custom_domains_table.php new file mode 100644 index 0000000..fb24cc6 --- /dev/null +++ b/database/migrations/2026_06_26_210000_create_custom_domains_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('qr_code_id')->constrained('qr_codes')->cascadeOnDelete(); // the storefront + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('host')->unique(); + $table->boolean('include_www')->default(true); + $table->string('status', 16)->default('pending'); // pending | active | failed + $table->string('ssl_status', 16)->default('pending'); // pending | active | failed + $table->timestamp('dns_verified_at')->nullable(); + $table->timestamp('ssl_issued_at')->nullable(); + $table->timestamp('ssl_expires_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + + $table->index('qr_code_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_domains'); + } +}; diff --git a/resources/views/merchant/storefronts/show.blade.php b/resources/views/merchant/storefronts/show.blade.php index 37385bc..8632beb 100644 --- a/resources/views/merchant/storefronts/show.blade.php +++ b/resources/views/merchant/storefronts/show.blade.php @@ -94,6 +94,52 @@ @endonce + @if(!empty($customDomainsEnabled)) +
+

Custom domain

+

Serve this storefront on your own domain with automatic SSL. Optional — your {{ $publicUrl }} link always works.

+ + @forelse($customDomains as $cd) +
+
+ {{ $cd->host }} + @php $live = $cd->status === 'active' && $cd->ssl_status === 'active'; @endphp + + {{ $live ? 'Live (SSL)' : ($cd->status === 'failed' ? 'Failed' : 'Pending') }} + +
+ @unless($live) +

Point an A record for {{ $cd->host }}@if($cd->include_www) and www.{{ $cd->host }}@endif to {{ $customDomainServerIp }}, then verify.

+ @if($cd->last_error)

{{ $cd->last_error }}

@endif + @endunless +
+ @unless($live) +
+ @csrf + +
+ @endunless +
+ @csrf @method('DELETE') + +
+
+
+ @empty +
+ @csrf +
+ + +
+ +
+ @error('host')

{{ $message }}

@enderror + @endforelse +
+ @endif +
@csrf @method('DELETE') diff --git a/routes/api.php b/routes/api.php index abcace7..0edf222 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,8 +2,12 @@ use App\Http\Controllers\Api\MeController; use App\Http\Controllers\Api\QrCodeController; +use App\Http\Controllers\Api\SslCallbackController; use Illuminate\Support\Facades\Route; +// Signed SSL completion callback from Ladill Domains (HMAC-verified, no session). +Route::post('/ssl-callback', SslCallbackController::class)->name('api.ssl-callback'); + Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () { Route::get('/me', MeController::class); diff --git a/routes/web.php b/routes/web.php index 58430b0..8d7874d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Merchant\OrdersController; use App\Http\Controllers\Merchant\OverviewController; use App\Http\Controllers\Merchant\PayoutsController; use App\Http\Controllers\Merchant\ProductController; +use App\Http\Controllers\Merchant\CustomDomainController; use App\Http\Controllers\Merchant\StorefrontController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\Public\BookingController; @@ -17,9 +18,17 @@ use App\Http\Controllers\Qr\TeamController; use App\Http\Controllers\SearchController; use Illuminate\Support\Facades\Route; -Route::get('/', fn () => auth()->check() - ? redirect()->route('merchant.dashboard') - : redirect()->route('sso.connect'))->name('merchant.root'); +Route::get('/', function (\Illuminate\Http\Request $request) { + // A customer's connected custom domain serves its mapped storefront here. + $storefront = app(\App\Services\CustomDomain\CustomDomainService::class)->resolveStorefront($request->getHost()); + if ($storefront) { + return app(QrScanController::class)->resolve($request, $storefront->short_code); + } + + return auth()->check() + ? redirect()->route('merchant.dashboard') + : redirect()->route('sso.connect'); +})->name('merchant.root'); Route::get('/login', [SsoLoginController::class, 'connect'])->name('login'); Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect'); @@ -64,6 +73,11 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/storefronts/{storefront}/preview.png', [StorefrontController::class, 'preview'])->name('merchant.storefronts.preview'); Route::get('/storefronts/{storefront}/download/{format}', [StorefrontController::class, 'download'])->name('merchant.storefronts.download')->whereIn('format', ['png', 'svg', 'pdf']); + // Custom domains (opt-in): connect a customer's own domain to a storefront. + Route::post('/storefronts/{storefront}/custom-domain', [CustomDomainController::class, 'store'])->name('merchant.custom-domain.store'); + Route::post('/custom-domains/{customDomain}/verify', [CustomDomainController::class, 'verify'])->name('merchant.custom-domain.verify'); + Route::delete('/custom-domains/{customDomain}', [CustomDomainController::class, 'destroy'])->name('merchant.custom-domain.destroy'); + Route::get('/products', [ProductController::class, 'index'])->name('merchant.products.index'); Route::get('/products/create', [ProductController::class, 'create'])->name('merchant.products.create'); Route::post('/products', [ProductController::class, 'store'])->name('merchant.products.store'); diff --git a/tests/Feature/CustomDomainTest.php b/tests/Feature/CustomDomainTest.php new file mode 100644 index 0000000..4f9d95a --- /dev/null +++ b/tests/Feature/CustomDomainTest.php @@ -0,0 +1,124 @@ + true, + 'customdomain.server_ip' => $this->serverIp, + 'customdomain.ssl_api_url' => 'https://domains.ladill.com/api', + 'customdomain.ssl_api_key' => 'merchant-ssl-key', + 'customdomain.callback_secret' => 'shared-secret', + ]); + } + + private function storefront(?User $owner = null): QrCode + { + $owner ??= User::factory()->create(); + + return QrCode::create([ + 'user_id' => $owner->id, + 'short_code' => 'sc'.uniqid(), + 'type' => QrCode::TYPE_SHOP, + 'label' => 'My Shop', + 'is_active' => true, + ]); + } + + private function fakeDns(array $ips): void + { + $this->app->bind(DnsResolver::class, fn () => new class($ips) extends DnsResolver { + public function __construct(private array $ips) {} + public function aRecords(string $host): array { return $this->ips; } + }); + } + + public function test_verify_requests_certificate_when_dns_points_to_app(): void + { + Http::fake(['*/ssl/certificates' => Http::response(['status' => 'pending'], 202)]); + $this->fakeDns([$this->serverIp]); + + $store = $this->storefront(); + $service = app(CustomDomainService::class); + $domain = $service->attach($store, 'brand.com', true); + + [$ok] = $service->verifyAndProvision($domain->fresh()); + + $this->assertTrue($ok); + $this->assertNotNull($domain->fresh()->dns_verified_at); + Http::assertSent(fn ($r) => str_contains($r->url(), '/ssl/certificates') + && $r['host'] === 'brand.com' && $r['target'] === 'app'); + } + + public function test_verify_fails_when_dns_not_pointing(): void + { + $this->fakeDns(['1.2.3.4']); + $store = $this->storefront(); + $service = app(CustomDomainService::class); + $domain = $service->attach($store, 'brand.com', true); + + [$ok, $msg] = $service->verifyAndProvision($domain->fresh()); + + $this->assertFalse($ok); + $this->assertStringContainsString('DNS', $msg); + $this->assertNull($domain->fresh()->dns_verified_at); + } + + public function test_signed_callback_marks_domain_live_and_resolves(): void + { + $store = $this->storefront(); + $domain = app(CustomDomainService::class)->attach($store, 'brand.com', true); + + $payload = json_encode(['event' => 'ssl.active', 'data' => [ + 'host' => 'brand.com', 'status' => 'active', 'expires_at' => now()->addDays(89)->toIso8601String(), + ]], JSON_UNESCAPED_SLASHES); + $sig = hash_hmac('sha256', $payload, 'shared-secret'); + + $this->call('POST', '/api/ssl-callback', [], [], [], [ + 'HTTP_X-Ladill-Signature' => $sig, + 'CONTENT_TYPE' => 'application/json', + ], $payload)->assertOk(); + + $domain->refresh(); + $this->assertTrue($domain->isLive()); + $resolved = app(CustomDomainService::class)->resolveStorefront('www.brand.com'); + $this->assertSame($store->id, $resolved?->id); + } + + public function test_callback_rejects_bad_signature(): void + { + app(CustomDomainService::class)->attach($this->storefront(), 'brand.com', true); + $payload = json_encode(['data' => ['host' => 'brand.com', 'status' => 'active']]); + + $this->call('POST', '/api/ssl-callback', [], [], [], [ + 'HTTP_X-Ladill-Signature' => 'wrong', 'CONTENT_TYPE' => 'application/json', + ], $payload)->assertStatus(401); + } + + public function test_store_requires_storefront_ownership(): void + { + $store = $this->storefront(); + $intruder = User::factory()->create(); + + $this->actingAs($intruder) + ->post(route('merchant.custom-domain.store', $store), ['host' => 'brand.com']) + ->assertForbidden(); + } +}