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/Events/CustomDomainController.php b/app/Http/Controllers/Events/CustomDomainController.php new file mode 100644 index 0000000..741eed5 --- /dev/null +++ b/app/Http/Controllers/Events/CustomDomainController.php @@ -0,0 +1,54 @@ +authorize('update', $event); + 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($event, $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 + { + $this->authorize('update', $customDomain->qrCode); + + [$ok, $message] = $this->service->verifyAndProvision($customDomain); + + return back()->with($ok ? 'success' : 'error', $message); + } + + public function destroy(Request $request, CustomDomain $customDomain): RedirectResponse + { + $this->authorize('update', $customDomain->qrCode); + + $customDomain->delete(); + + return back()->with('success', 'Custom domain removed.'); + } +} diff --git a/app/Http/Controllers/Qr/QrCodeController.php b/app/Http/Controllers/Qr/QrCodeController.php index 879af1b..a0fa514 100644 --- a/app/Http/Controllers/Qr/QrCodeController.php +++ b/app/Http/Controllers/Qr/QrCodeController.php @@ -226,6 +226,9 @@ class QrCodeController extends Controller 'minTopup' => QrWallet::minTopupGhs(), 'ladillWalletBalance' => $ladillWalletBalance, 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', + 'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->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..04b2d6e --- /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 event page. + '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_EVENTS', ''), + + // certbot issues + installs an nginx server block that includes this snippet + // (root + fastcgi for the Events app) so the custom domain serves this app. + 'nginx_include' => env('CUSTOM_DOMAINS_NGINX_INCLUDE', '/etc/nginx/snippets/ladill-events-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/qr-codes/show.blade.php b/resources/views/qr-codes/show.blade.php index b13bae2..35b51a9 100644 --- a/resources/views/qr-codes/show.blade.php +++ b/resources/views/qr-codes/show.blade.php @@ -56,6 +56,52 @@ + @if(!empty($customDomainsEnabled)) +
+

Custom domain

+

Serve this event page on your own domain with automatic SSL. Optional — your {{ $qrCode->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 + {{-- Main 2-column layout --}}
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 613b6dd..6ef10a8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,14 +12,23 @@ use App\Http\Controllers\Public\QrScanController; use App\Http\Controllers\Qr\AccountController; use App\Http\Controllers\Qr\AfiaController; use App\Http\Controllers\Qr\DeveloperController; +use App\Http\Controllers\Events\CustomDomainController; use App\Http\Controllers\Qr\QrCodeController; use App\Http\Controllers\Qr\TeamController; use App\Http\Controllers\SearchController; use Illuminate\Support\Facades\Route; -Route::get('/', fn () => auth()->check() - ? redirect()->route('events.dashboard') - : redirect()->route('sso.connect'))->name('events.root'); +Route::get('/', function (\Illuminate\Http\Request $request) { + // A customer's connected custom domain serves its mapped event page here. + $event = app(\App\Services\CustomDomain\CustomDomainService::class)->resolveStorefront($request->getHost()); + if ($event) { + return app(QrScanController::class)->resolve($request, $event->short_code); + } + + return auth()->check() + ? redirect()->route('events.dashboard') + : redirect()->route('sso.connect'); +})->name('events.root'); Route::get('/login', [SsoLoginController::class, 'connect'])->name('login'); Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect'); @@ -57,6 +66,11 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/events/style-preview', [QrCodeController::class, 'stylePreview'])->name('events.style-preview'); Route::get('/events/{event}', [QrCodeController::class, 'show'])->name('events.show'); Route::patch('/events/{event}', [QrCodeController::class, 'update'])->name('events.update'); + + // Custom domains (opt-in): connect a customer's own domain to an event page. + Route::post('/events/{event}/custom-domain', [CustomDomainController::class, 'store'])->name('events.custom-domain.store'); + Route::post('/custom-domains/{customDomain}/verify', [CustomDomainController::class, 'verify'])->name('events.custom-domain.verify'); + Route::delete('/custom-domains/{customDomain}', [CustomDomainController::class, 'destroy'])->name('events.custom-domain.destroy'); Route::post('/events/{event}/canonical-image', [QrCodeController::class, 'storeCanonicalImage'])->name('events.canonical-image'); Route::get('/events/{event}/preview.png', [QrCodeController::class, 'preview'])->name('events.preview'); Route::get('/events/{event}/download/{format}', [QrCodeController::class, 'download'])->name('events.download')->whereIn('format', ['png', 'svg', 'pdf']); diff --git a/tests/Feature/CustomDomainTest.php b/tests/Feature/CustomDomainTest.php new file mode 100644 index 0000000..50cda45 --- /dev/null +++ b/tests/Feature/CustomDomainTest.php @@ -0,0 +1,107 @@ + true, + 'customdomain.server_ip' => $this->serverIp, + 'customdomain.ssl_api_url' => 'https://domains.ladill.com/api', + 'customdomain.ssl_api_key' => 'events-ssl-key', + 'customdomain.callback_secret' => 'shared-secret', + ]); + } + + private function event(?User $owner = null): QrCode + { + $owner ??= User::factory()->create(); + + return QrCode::create([ + 'user_id' => $owner->id, + 'short_code' => 'sc'.uniqid(), + 'type' => QrCode::TYPE_CHURCH, + 'label' => 'My Event', + '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]); + + $service = app(CustomDomainService::class); + $domain = $service->attach($this->event(), 'myevent.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'] === 'myevent.com'); + } + + public function test_verify_fails_when_dns_not_pointing(): void + { + $this->fakeDns(['1.2.3.4']); + $service = app(CustomDomainService::class); + $domain = $service->attach($this->event(), 'myevent.com', true); + + [$ok, $msg] = $service->verifyAndProvision($domain->fresh()); + + $this->assertFalse($ok); + $this->assertStringContainsString('DNS', $msg); + } + + public function test_signed_callback_marks_domain_live_and_resolves(): void + { + $event = $this->event(); + app(CustomDomainService::class)->attach($event, 'myevent.com', true); + + $payload = json_encode(['event' => 'ssl.active', 'data' => [ + 'host' => 'myevent.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(); + + $resolved = app(CustomDomainService::class)->resolveStorefront('www.myevent.com'); + $this->assertSame($event->id, $resolved?->id); + } + + public function test_store_requires_event_ownership(): void + { + $event = $this->event(); + $intruder = User::factory()->create(); + + $this->actingAs($intruder) + ->post(route('events.custom-domain.store', $event), ['host' => 'myevent.com']) + ->assertForbidden(); + } +}