Add opt-in custom domains with automatic SSL
Deploy Ladill Merchant / deploy (push) Successful in 29s
Deploy Ladill Merchant / deploy (push) Successful in 29s
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a4974098ee
commit
b2aede5963
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\CustomDomain\CustomDomainService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed completion callback from Ladill Domains' SSL service. Flips a custom
|
||||||
|
* domain to live (or failed) once the certificate is issued. Auth is the HMAC
|
||||||
|
* signature over the raw body (shared SSL_CALLBACK_SECRET), not a session.
|
||||||
|
*/
|
||||||
|
class SslCallbackController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, CustomDomainService $service): JsonResponse
|
||||||
|
{
|
||||||
|
$secret = (string) config('customdomain.callback_secret');
|
||||||
|
$body = $request->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Merchant;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CustomDomain;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\CustomDomain\CustomDomainService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CustomDomainController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CustomDomainService $service) {}
|
||||||
|
|
||||||
|
public function store(Request $request, QrCode $storefront): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($storefront->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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,9 @@ class StorefrontController extends Controller
|
|||||||
'qrCode' => $storefront->fresh(),
|
'qrCode' => $storefront->fresh(),
|
||||||
'previewDataUri' => $this->imageGenerator->previewDataUri($storefront),
|
'previewDataUri' => $this->imageGenerator->previewDataUri($storefront),
|
||||||
'catalog' => $this->catalogProducts(),
|
'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'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer's own domain (e.g. brand.com) pointed at one storefront (QrCode).
|
||||||
|
* Visiting the domain serves that storefront's public page. DNS is verified to
|
||||||
|
* resolve to the app, then Ladill Domains issues + installs the TLS cert.
|
||||||
|
*/
|
||||||
|
class CustomDomain extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'qr_code_id', 'user_id', 'host', 'include_www', 'status', 'ssl_status',
|
||||||
|
'dns_verified_at', 'ssl_issued_at', 'ssl_expires_at', 'last_error',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'include_www' => '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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\CustomDomain;
|
||||||
|
|
||||||
|
use App\Models\CustomDomain;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class CustomDomainService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DnsResolver $dns,
|
||||||
|
private readonly DomainsSslClient $ssl,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('customdomain.enabled', true) && $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\CustomDomain;
|
||||||
|
|
||||||
|
class DnsResolver
|
||||||
|
{
|
||||||
|
/** @return array<int,string> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\CustomDomain;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin client for Ladill Domains' central SSL API. Requests/queries certificates
|
||||||
|
* for a custom domain on the shared app server (target = app).
|
||||||
|
*/
|
||||||
|
class DomainsSslClient
|
||||||
|
{
|
||||||
|
public function configured(): bool
|
||||||
|
{
|
||||||
|
return (string) config('customdomain.ssl_api_key', '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask Domains to issue + install a certificate for $host, calling back when done.
|
||||||
|
*/
|
||||||
|
public function requestCertificate(string $host, bool $includeWww, string $callbackUrl): bool
|
||||||
|
{
|
||||||
|
if (! $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$appHost = parse_url((string) env('APP_URL', 'https://merchant.ladill.com'), PHP_URL_HOST) ?: 'merchant.ladill.com';
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Custom domains are opt-in; the feature only surfaces/provisions when a
|
||||||
|
// Domains SSL API key is configured.
|
||||||
|
'enabled' => 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', ''),
|
||||||
|
];
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('custom_domains', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -94,6 +94,52 @@
|
|||||||
</script>
|
</script>
|
||||||
@endonce
|
@endonce
|
||||||
|
|
||||||
|
@if(!empty($customDomainsEnabled))
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Custom domain</h3>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Serve this storefront on your own domain with automatic SSL. Optional — your <span class="break-all">{{ $publicUrl }}</span> link always works.</p>
|
||||||
|
|
||||||
|
@forelse($customDomains as $cd)
|
||||||
|
<div class="mt-3 rounded-xl border border-slate-200 p-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="break-all text-sm font-medium text-slate-900">{{ $cd->host }}</span>
|
||||||
|
@php $live = $cd->status === 'active' && $cd->ssl_status === 'active'; @endphp
|
||||||
|
<span class="shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium {{ $live ? 'bg-emerald-50 text-emerald-700' : ($cd->status === 'failed' ? 'bg-red-50 text-red-700' : 'bg-amber-50 text-amber-700') }}">
|
||||||
|
{{ $live ? 'Live (SSL)' : ($cd->status === 'failed' ? 'Failed' : 'Pending') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@unless($live)
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Point an <strong>A record</strong> for <strong>{{ $cd->host }}</strong>@if($cd->include_www) and <strong>www.{{ $cd->host }}</strong>@endif to <strong>{{ $customDomainServerIp }}</strong>, then verify.</p>
|
||||||
|
@if($cd->last_error)<p class="mt-1 text-xs text-red-600">{{ $cd->last_error }}</p>@endif
|
||||||
|
@endunless
|
||||||
|
<div class="mt-2 flex items-center gap-3">
|
||||||
|
@unless($live)
|
||||||
|
<form method="post" action="{{ route('merchant.custom-domain.verify', $cd) }}">
|
||||||
|
@csrf
|
||||||
|
<button class="text-xs font-semibold text-indigo-600 hover:text-indigo-500">Verify & enable SSL</button>
|
||||||
|
</form>
|
||||||
|
@endunless
|
||||||
|
<form method="post" action="{{ route('merchant.custom-domain.destroy', $cd) }}" onsubmit="return confirm('Remove this custom domain?');">
|
||||||
|
@csrf @method('DELETE')
|
||||||
|
<button class="text-xs font-medium text-slate-500 hover:text-red-600">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<form method="post" action="{{ route('merchant.custom-domain.store', $qrCode) }}" class="mt-3 flex items-end gap-2">
|
||||||
|
@csrf
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-slate-500">Your domain</label>
|
||||||
|
<input type="text" name="host" placeholder="brand.com" required
|
||||||
|
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
</div>
|
||||||
|
<button class="rounded-xl bg-slate-900 px-3.5 py-2 text-sm font-semibold text-white hover:bg-slate-800">Add</button>
|
||||||
|
</form>
|
||||||
|
@error('host')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<form method="post" action="{{ route('merchant.storefronts.destroy', $qrCode) }}"
|
<form method="post" action="{{ route('merchant.storefronts.destroy', $qrCode) }}"
|
||||||
onsubmit="return confirm('Delete this storefront? This cannot be undone.');">
|
onsubmit="return confirm('Delete this storefront? This cannot be undone.');">
|
||||||
@csrf @method('DELETE')
|
@csrf @method('DELETE')
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Api\MeController;
|
use App\Http\Controllers\Api\MeController;
|
||||||
use App\Http\Controllers\Api\QrCodeController;
|
use App\Http\Controllers\Api\QrCodeController;
|
||||||
|
use App\Http\Controllers\Api\SslCallbackController;
|
||||||
use Illuminate\Support\Facades\Route;
|
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::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () {
|
||||||
Route::get('/me', MeController::class);
|
Route::get('/me', MeController::class);
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -6,6 +6,7 @@ use App\Http\Controllers\Merchant\OrdersController;
|
|||||||
use App\Http\Controllers\Merchant\OverviewController;
|
use App\Http\Controllers\Merchant\OverviewController;
|
||||||
use App\Http\Controllers\Merchant\PayoutsController;
|
use App\Http\Controllers\Merchant\PayoutsController;
|
||||||
use App\Http\Controllers\Merchant\ProductController;
|
use App\Http\Controllers\Merchant\ProductController;
|
||||||
|
use App\Http\Controllers\Merchant\CustomDomainController;
|
||||||
use App\Http\Controllers\Merchant\StorefrontController;
|
use App\Http\Controllers\Merchant\StorefrontController;
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
use App\Http\Controllers\Public\BookingController;
|
use App\Http\Controllers\Public\BookingController;
|
||||||
@@ -17,9 +18,17 @@ use App\Http\Controllers\Qr\TeamController;
|
|||||||
use App\Http\Controllers\SearchController;
|
use App\Http\Controllers\SearchController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', fn () => auth()->check()
|
Route::get('/', function (\Illuminate\Http\Request $request) {
|
||||||
? redirect()->route('merchant.dashboard')
|
// A customer's connected custom domain serves its mapped storefront here.
|
||||||
: redirect()->route('sso.connect'))->name('merchant.root');
|
$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('/login', [SsoLoginController::class, 'connect'])->name('login');
|
||||||
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
|
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}/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']);
|
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', [ProductController::class, 'index'])->name('merchant.products.index');
|
||||||
Route::get('/products/create', [ProductController::class, 'create'])->name('merchant.products.create');
|
Route::get('/products/create', [ProductController::class, 'create'])->name('merchant.products.create');
|
||||||
Route::post('/products', [ProductController::class, 'store'])->name('merchant.products.store');
|
Route::post('/products', [ProductController::class, 'store'])->name('merchant.products.store');
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\CustomDomain;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\CustomDomain\CustomDomainService;
|
||||||
|
use App\Services\CustomDomain\DnsResolver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CustomDomainTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private string $serverIp = '161.97.138.149';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
config([
|
||||||
|
'customdomain.enabled' => 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user