Expand Link app with analytics, settings, and custom domains.
Deploy Ladill Link / deploy (push) Successful in 37s

Add Bitly-style branded domain support via Ladill Domains SSL API,
account analytics dashboard, settings page with default domain picker,
and fix SSO/dashboard issues from QR Plus template leftovers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 12:16:34 +00:00
co-authored by Cursor
parent d9c91ad7d8
commit 9516fcb9f3
38 changed files with 1263 additions and 70 deletions
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Services\CustomDomain;
class DnsResolver
{
/** @return array<int,string> */
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,76 @@
<?php
namespace App\Services\CustomDomain;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DomainsSslClient
{
public function configured(): bool
{
return (string) config('customdomain.ssl_api_key', '') !== '';
}
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;
}
}
public function registerConnected(string $host, string $ownerPublicId, ?string $label = null, ?string $linkUrl = null): void
{
if (! $this->configured() || $ownerPublicId === '') {
return;
}
try {
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
->post(config('customdomain.ssl_api_url').'/connected-domains', array_filter([
'host' => $host,
'owner_public_id' => $ownerPublicId,
'label' => $label,
'link_url' => $linkUrl,
]));
} catch (\Throwable $e) {
Log::info('DomainsSslClient: connected register skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
public function removeConnected(string $host): void
{
if (! $this->configured()) {
return;
}
try {
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
->delete(config('customdomain.ssl_api_url').'/connected-domains/'.urlencode($host));
} catch (\Throwable $e) {
Log::info('DomainsSslClient: connected remove skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Services\CustomDomain;
use App\Models\LinkCustomDomain;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class LinkCustomDomainService
{
public function __construct(
private readonly DnsResolver $dns,
private readonly DomainsSslClient $ssl,
) {}
public function enabled(): bool
{
return (bool) config('customdomain.enabled', true) && $this->ssl->configured();
}
public function attach(User $user, string $host, bool $includeWww = true, bool $makeDefault = false): LinkCustomDomain
{
return DB::transaction(function () use ($user, $host, $includeWww, $makeDefault) {
if ($makeDefault) {
LinkCustomDomain::where('user_id', $user->id)->update(['is_default' => false]);
}
return LinkCustomDomain::create([
'user_id' => $user->id,
'host' => $host,
'include_www' => $includeWww,
'is_default' => $makeDefault || ! LinkCustomDomain::where('user_id', $user->id)->exists(),
'status' => LinkCustomDomain::STATUS_PENDING,
'ssl_status' => LinkCustomDomain::STATUS_PENDING,
]);
});
}
/** @return array{0:bool,1:string} */
public function verifyAndProvision(LinkCustomDomain $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.'];
}
public function applyCallback(string $host, string $status, ?string $expiresAt, ?string $error): void
{
$domain = LinkCustomDomain::where('host', strtolower(trim($host)))->first();
if (! $domain) {
return;
}
if ($status === 'active') {
$domain->forceFill([
'ssl_status' => LinkCustomDomain::STATUS_ACTIVE,
'status' => LinkCustomDomain::STATUS_ACTIVE,
'ssl_issued_at' => Carbon::now(),
'ssl_expires_at' => $expiresAt ? Carbon::parse($expiresAt) : null,
'last_error' => null,
])->save();
} else {
$domain->forceFill([
'ssl_status' => LinkCustomDomain::STATUS_FAILED,
'status' => LinkCustomDomain::STATUS_FAILED,
'last_error' => $error ?: 'Certificate issuance failed.',
])->save();
}
}
public function setDefault(LinkCustomDomain $domain): void
{
DB::transaction(function () use ($domain) {
LinkCustomDomain::where('user_id', $domain->user_id)->update(['is_default' => false]);
$domain->forceFill(['is_default' => true])->save();
});
}
public function isAppHost(string $host): bool
{
$host = preg_replace('/^www\./', '', strtolower(trim($host)));
$known = array_filter([
(string) config('customdomain.app_host'),
(string) config('customdomain.public_host'),
'ladl.link',
'www.ladl.link',
]);
return in_array($host, $known, true);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Services\Link;
use App\Models\LinkClick;
use App\Models\ShortLink;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class LinkAnalyticsService
{
/** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,links_total:int} */
public function summaryForAccount(User $account): array
{
$links = ShortLink::query()->where('user_id', $account->id);
$clicks = LinkClick::query()->whereIn('short_link_id', (clone $links)->select('id'));
return [
'total_clicks' => (int) (clone $links)->sum('clicks_total'),
'unique_clicks' => (int) (clone $links)->sum('unique_clicks_total'),
'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(),
'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(),
'links_total' => (int) (clone $links)->count(),
];
}
/** @return Collection<int, object{date:string,total:int}> */
public function dailyClicksForAccount(User $account, int $days = 30): Collection
{
$rows = LinkClick::query()
->selectRaw('DATE(clicked_at) as date, COUNT(*) as total')
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->where('clicked_at', '>=', now()->subDays($days - 1)->startOfDay())
->groupBy('date')
->orderBy('date')
->get()
->keyBy('date');
return collect(range(0, $days - 1))->map(function (int $offset) use ($rows, $days) {
$date = now()->subDays($days - 1 - $offset)->toDateString();
return (object) [
'date' => $date,
'total' => (int) ($rows[$date]->total ?? 0),
];
});
}
/** @return Collection<int, array{label:string,total:int}> */
public function topLinksForAccount(User $account, int $limit = 8): Collection
{
return ShortLink::query()
->where('user_id', $account->id)
->orderByDesc('clicks_total')
->limit($limit)
->get()
->map(fn (ShortLink $link) => [
'label' => $link->label ?: $link->slug,
'slug' => $link->slug,
'total' => $link->clicks_total,
'url' => route('user.links.show', $link),
]);
}
/** @return Collection<int, object> */
public function recentClicksForAccount(User $account, int $limit = 15): Collection
{
return LinkClick::query()
->with('shortLink:id,slug,label')
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->latest('clicked_at')
->limit($limit)
->get();
}
/** @return Collection<int, array{label:string,total:int}> */
public function referrersForAccount(User $account, int $limit = 8): Collection
{
return LinkClick::query()
->select('referer', DB::raw('COUNT(*) as total'))
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->whereNotNull('referer')
->where('referer', '!=', '')
->groupBy('referer')
->orderByDesc('total')
->limit($limit)
->get()
->map(fn ($row) => [
'label' => $this->refererLabel((string) $row->referer),
'total' => (int) $row->total,
]);
}
private function refererLabel(string $referer): string
{
$host = parse_url($referer, PHP_URL_HOST);
return is_string($host) && $host !== '' ? $host : $referer;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Services\Link;
use App\Support\LadillLink;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* Proxies ladl.link ecosystem paths to the platform QR resolver (/q/* on ladill.com).
* The browser stays on ladl.link; resolution happens server-side.
*/
class LinkPlatformProxy
{
public function forward(Request $request, string $slug, ?string $path = null): Response
{
$platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
$target = $platform.'/q/'.$slug;
if ($path !== null && $path !== '') {
$target .= '/'.ltrim($path, '/');
}
if ($qs = $request->getQueryString()) {
$target .= '?'.$qs;
}
$client = Http::withOptions(['allow_redirects' => false])->timeout(15);
$method = strtoupper($request->method());
$pending = match ($method) {
'POST' => $client->withHeaders($this->forwardHeaders($request))->asForm()->post($target, $request->post()),
'PATCH' => $client->withHeaders($this->forwardHeaders($request))->patch($target, $request->all()),
'DELETE' => $client->withHeaders($this->forwardHeaders($request))->delete($target),
default => $client->withHeaders($this->forwardHeaders($request))->get($target),
};
$upstream = $pending->toPsrResponse();
$response = new Response(
(string) $upstream->getBody(),
$upstream->getStatusCode(),
$this->sanitizeHeaders($upstream->getHeaders())
);
if ($response->isRedirection()) {
$location = $response->headers->get('Location');
if (is_string($location)) {
$response->headers->set('Location', $this->rewriteLocation($location, $slug));
}
}
return $response;
}
/** @return array<string, string> */
private function forwardHeaders(Request $request): array
{
$headers = [];
foreach (['Accept', 'Accept-Language', 'User-Agent', 'Referer'] as $name) {
$value = $request->header($name);
if ($value !== null) {
$headers[$name] = $value;
}
}
return $headers;
}
/** @param array<string, array<int, string>> $headers */
private function sanitizeHeaders(array $headers): array
{
$flat = [];
foreach ($headers as $name => $values) {
if (strtolower($name) === 'transfer-encoding') {
continue;
}
$flat[$name] = $values[0] ?? '';
}
return $flat;
}
private function rewriteLocation(string $location, string $slug): string
{
$platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
$prefix = $platform.'/q/'.$slug;
if (str_starts_with($location, $prefix)) {
$suffix = substr($location, strlen($prefix));
$rewritten = LadillLink::url($slug).$suffix;
return $rewritten;
}
return $location;
}
}