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)) +
Serve this storefront on your own domain with automatic SSL. Optional — your {{ $publicUrl }} link always works.
+ + @forelse($customDomains as $cd) +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 +{{ $message }}
@enderror + @endforelse +