Make Ladill Link short URLs fully dynamic at click time.
Deploy Ladill Link / deploy (push) Successful in 1m55s

Forward query strings and path suffixes to the live destination, resolve public hosts from config/custom domains instead of hardcoding ladl.link, and keep proxy Location rewrites on the visitor host.
This commit is contained in:
isaacclad
2026-07-16 20:05:43 +00:00
parent a9ddd600db
commit d45f4b5c58
14 changed files with 195 additions and 28 deletions
@@ -41,6 +41,8 @@ class LinkController extends Controller
'canCreate' => $this->billing->canCreate($account),
'pricePerLink' => \App\Models\LinkWallet::pricePerLink(),
'topupUrl' => ladill_account_url('wallet'),
'publicHost' => parse_url($account->publicLinkBaseUrl(), PHP_URL_HOST) ?: ShortLink::publicHost(),
'publicBaseUrl' => $account->publicLinkBaseUrl(),
]);
}
@@ -51,6 +53,8 @@ class LinkController extends Controller
return view('links.create', [
'canCreate' => $this->billing->canCreate($account),
'pricePerLink' => \App\Models\LinkWallet::pricePerLink(),
'publicHost' => parse_url($account->publicLinkBaseUrl(), PHP_URL_HOST) ?: ShortLink::publicHost(),
'publicBaseUrl' => $account->publicLinkBaseUrl(),
]);
}
@@ -29,6 +29,8 @@ class OverviewController extends Controller
'wallet' => $wallet,
'recentLinks' => $recentLinks,
'pricePerLink' => \App\Models\LinkWallet::pricePerLink(),
'publicHost' => parse_url($account->publicLinkBaseUrl(), PHP_URL_HOST) ?: ShortLink::publicHost(),
'publicBaseUrl' => $account->publicLinkBaseUrl(),
]);
}
}
@@ -33,7 +33,9 @@ class SettingsController extends Controller
if ($validated['domain'] === 'platform') {
LinkCustomDomain::where('user_id', $account->id)->update(['is_default' => false]);
return back()->with('success', 'Default short-link domain set to ladl.link.');
$domain = (string) config('link.public_domain', 'ladl.link');
return back()->with('success', 'Default short-link domain set to '.$domain.'.');
}
$domain = LinkCustomDomain::query()
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Services\Link\LinkManagerService;
use App\Services\Link\LinkPlatformProxy;
use App\Support\LadillLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -19,13 +18,15 @@ class LinkRedirectController extends Controller
public function resolve(Request $request, string $slug, ?string $path = null): RedirectResponse|Response
{
if ($path === null || $path === '') {
$link = $this->links->resolve($request, $slug);
if ($link !== null && $link->isDirectRedirect()) {
return redirect()->away($link->destination_url, 302);
}
$target = $link->resolveRedirectUrl($request, $path);
return redirect()->away($target, 302);
}
// Catalog / QR ecosystem slugs: proxy so destination stays live in the source app.
return $this->platform->forward($request, $slug, $path);
}
}
+70 -2
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Request;
class ShortLink extends Model
{
@@ -44,6 +45,10 @@ class ShortLink extends Model
return $this->hasMany(LinkClick::class);
}
/**
* Shareable short URL for this slug always derived at runtime from the
* owner's default domain (custom or platform), never stored.
*/
public function publicUrl(?User $owner = null): string
{
$owner ??= $this->user;
@@ -52,11 +57,74 @@ class ShortLink extends Model
return rtrim($base, '/').'/'.$this->slug;
}
public static function publicHost(): string
{
return strtolower((string) config('link.public_domain', 'ladl.link'));
}
public static function publicBaseUrl(): string
{
$domain = (string) config('link.public_domain', 'ladl.link');
return 'https://'.self::publicHost();
}
return 'https://'.$domain;
/**
* Build the live redirect target: current destination_url plus any path
* suffix and query string from the inbound request (query keys on the
* short link override destination keys when both are set).
*/
public function resolveRedirectUrl(Request $request, ?string $path = null): string
{
$base = trim((string) $this->destination_url);
if ($base === '') {
return $this->publicUrl();
}
$suffix = $path !== null ? trim($path, '/') : '';
if ($suffix !== '') {
$parsed = parse_url($base);
$basePath = rtrim((string) ($parsed['path'] ?? ''), '/');
$rebuilt = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? '');
if (! empty($parsed['port'])) {
$rebuilt .= ':'.$parsed['port'];
}
$rebuilt .= $basePath.'/'.$suffix;
if (! empty($parsed['query'])) {
$rebuilt .= '?'.$parsed['query'];
}
if (! empty($parsed['fragment'])) {
$rebuilt .= '#'.$parsed['fragment'];
}
$base = $rebuilt;
}
$requestQuery = $request->query();
if ($requestQuery === []) {
return $base;
}
$parts = parse_url($base);
$existing = [];
if (! empty($parts['query'])) {
parse_str((string) $parts['query'], $existing);
}
// Inbound short-link params win so campaigns can override destination defaults.
$merged = array_merge($existing, $requestQuery);
$query = http_build_query($merged);
$target = ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? '');
if (! empty($parts['port'])) {
$target .= ':'.$parts['port'];
}
$target .= $parts['path'] ?? '';
if ($query !== '') {
$target .= '?'.$query;
}
if (! empty($parts['fragment'])) {
$target .= '#'.$parts['fragment'];
}
return $target;
}
public function isExpired(): bool
+1 -1
View File
@@ -102,7 +102,7 @@ class AfiaService
What Ladill Link does and where things live:
- Overview (Dashboard): link count, recent clicks, and wallet balance.
- My Links: create, edit, enable/disable, and delete short links. Each new link debits a small fee from the Ladill wallet (see Settings or the create screen for the current price).
- Public URLs: default domain is ladl.link/{slug}. Links can also use a verified custom domain from Custom Domains.
- Public URLs: default domain is configured (usually ladl.link/{slug}) and always resolved live custom domains from Settings replace it when set as default.
- Analytics: click totals and trends across links.
- Custom Domains: connect a domain you own, verify DNS, and set a default domain for new links.
- Settings: default domain preference and account shortcuts.
@@ -103,11 +103,11 @@ class LinkCustomDomainService
public function isAppHost(string $host): bool
{
$host = preg_replace('/^www\./', '', strtolower(trim($host)));
$public = (string) config('customdomain.public_host', config('link.public_domain', 'ladl.link'));
$known = array_filter([
(string) config('customdomain.app_host'),
(string) config('customdomain.public_host'),
'ladl.link',
'www.ladl.link',
$public,
'www.'.$public,
]);
return in_array($host, $known, true);
+38 -6
View File
@@ -55,7 +55,7 @@ class LinkPlatformProxy
return $followUp;
}
$response->headers->set('Location', $this->rewriteLocation($location, $slug));
$response->headers->set('Location', $this->rewriteLocation($request, $location, $slug));
}
}
@@ -97,8 +97,7 @@ class LinkPlatformProxy
}
$host = strtolower((string) ($parsed['host'] ?? ''));
$linkHost = strtolower((string) parse_url(LadillLink::baseUrl(), PHP_URL_HOST));
if ($host !== $linkHost) {
if (! $this->isLinkPublicHost($host, $request)) {
return null;
}
@@ -111,6 +110,18 @@ class LinkPlatformProxy
return $this->forward($request, $slug, substr($path, strlen($slugPrefix)));
}
private function isLinkPublicHost(string $host, Request $request): bool
{
$host = preg_replace('/^www\./', '', strtolower($host)) ?: $host;
$candidates = array_filter([
strtolower((string) parse_url(LadillLink::baseUrl(), PHP_URL_HOST)),
strtolower((string) config('link.public_domain', 'ladl.link')),
strtolower((string) $request->getHost()),
]);
return in_array($host, $candidates, true);
}
private function isEventsRegistrationPath(string $path): bool
{
return $path === 'register'
@@ -179,14 +190,16 @@ class LinkPlatformProxy
return $flat;
}
private function rewriteLocation(string $location, string $slug): string
private function rewriteLocation(Request $request, string $location, string $slug): string
{
$publicBase = $this->publicBaseForRequest($request);
foreach ([$this->platformBaseUrl(), $this->eventsBaseUrl()] as $base) {
$prefix = $base.'/q/'.$slug;
if (str_starts_with($location, $prefix)) {
$suffix = substr($location, strlen($prefix));
return LadillLink::url($slug).$suffix;
return rtrim($publicBase, '/').'/'.$slug.$suffix;
}
}
@@ -194,9 +207,28 @@ class LinkPlatformProxy
if (str_starts_with($location, $transfer)) {
$suffix = substr($location, strlen($transfer));
return LadillLink::url($slug).$suffix;
return rtrim($publicBase, '/').'/'.$slug.$suffix;
}
return $location;
}
/** Keep the visitor on the host they used (custom domain or platform public domain). */
private function publicBaseForRequest(Request $request): string
{
$host = strtolower((string) $request->getHost());
$appHost = strtolower((string) (
config('customdomain.app_host')
?: (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '')
));
// Dashboard host (link.ladill.com) is not a public short-link host.
if ($host === '' || $host === $appHost) {
return LadillLink::baseUrl();
}
$scheme = $request->getScheme() ?: 'https';
return $scheme.'://'.$host;
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
namespace App\Support;
/**
* Canonical short-link URLs for the Ladill platform (ladl.link).
* Canonical short-link URLs for the Ladill platform.
* Domain is always read from config (LINK_PUBLIC_DOMAIN) never hard-coded.
*/
final class LadillLink
{
+7 -3
View File
@@ -1,10 +1,10 @@
<x-user-layout>
<x-slot name="title">Create Link</x-slot>
<div class="mx-auto max-w-xl space-y-6">
<div class="mx-auto max-w-xl space-y-6" x-data="{ slug: @js(old('custom_slug', '')) }">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Create short link</h1>
<p class="mt-1 text-sm text-slate-500">
Your link will be published on <span class="font-medium text-emerald-700">ladl.link</span>.
Your link will be published on <span class="font-medium text-emerald-700">{{ $publicHost }}</span>.
GHS {{ number_format($pricePerLink, 2) }} will be debited from your wallet.
</p>
</div>
@@ -35,11 +35,15 @@
<div>
<label for="custom_slug" class="block text-sm font-medium text-slate-700">Custom slug (optional)</label>
<div class="mt-1 flex rounded-lg shadow-sm">
<span class="inline-flex items-center rounded-l-lg border border-r-0 border-slate-300 bg-slate-50 px-3 text-sm text-slate-500">ladl.link/</span>
<span class="inline-flex items-center rounded-l-lg border border-r-0 border-slate-300 bg-slate-50 px-3 text-sm text-slate-500">{{ $publicHost }}/</span>
<input type="text" name="custom_slug" id="custom_slug" value="{{ old('custom_slug') }}"
pattern="[a-z0-9][a-z0-9-]{1,18}[a-z0-9]"
x-model="slug"
class="block w-full rounded-r-lg border-slate-300 focus:border-emerald-500 focus:ring-emerald-500">
</div>
<p class="mt-1.5 text-xs text-slate-400" x-show="slug" x-cloak>
Preview: <span class="font-medium text-emerald-700" x-text="'{{ $publicBaseUrl }}/' + slug"></span>
</p>
@error('custom_slug')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg font-semibold text-slate-900 lg:text-2xl">Overview</h1>
<p class="mt-1 hidden text-sm text-slate-500 sm:block">Short links on <span class="font-medium text-emerald-700">ladl.link</span> billed from your Ladill wallet</p>
<p class="mt-1 hidden text-sm text-slate-500 sm:block">Short links on <span class="font-medium text-emerald-700">{{ $publicHost }}</span> billed from your Ladill wallet</p>
</div>
@include('partials.mobile-header-btn', [
'href' => route('user.links.create'),
+3 -3
View File
@@ -22,11 +22,11 @@
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-xl">
<div class="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">
Short links · Click analytics · ladl.link
Short links · Click analytics · {{ $publicHost }}
</div>
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">My Links</h1>
<p class="mt-2 text-sm leading-6 text-slate-600">
Create branded short URLs on ladl.link, track clicks, and manage destinations from one place.
Create branded short URLs on {{ $publicHost }}, track clicks, and manage destinations from one place.
</p>
<div class="mt-6">
<button type="button"
@@ -62,7 +62,7 @@
</div>
@if($links->isEmpty())
<div class="px-6 py-12 text-center">
<p class="text-sm text-slate-500">No links yet. Create your first short link on ladl.link.</p>
<p class="text-sm text-slate-500">No links yet. Create your first short link on {{ $publicHost }}.</p>
</div>
@else
<table class="min-w-full divide-y divide-slate-100">
+1 -1
View File
@@ -28,7 +28,7 @@
@if (! $link->is_managed_here)
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
This ladl.link slug was created in {{ $link->sourceAppLabel() }}. Edit it there changes sync back here automatically.
This {{ \App\Models\ShortLink::publicHost() }} slug was created in {{ $link->sourceAppLabel() }}. Edit it there changes sync back here automatically.
</div>
@endif
+53
View File
@@ -39,6 +39,59 @@ class LinkRedirectTest extends TestCase
->assertRedirect('https://example.com/target');
}
public function test_direct_redirect_forwards_query_string_dynamically(): void
{
$user = $this->user();
ShortLink::create([
'user_id' => $user->id,
'slug' => 'promo',
'source_app' => 'link',
'source_kind' => 'redirect',
'is_managed_here' => true,
'destination_url' => 'https://example.com/landing?ref=home',
'is_active' => true,
]);
$this->get('https://ladl.link/promo?utm_source=ig&ref=campaign')
->assertRedirect('https://example.com/landing?ref=campaign&utm_source=ig');
}
public function test_direct_redirect_appends_path_suffix_dynamically(): void
{
$user = $this->user();
ShortLink::create([
'user_id' => $user->id,
'slug' => 'docs',
'source_app' => 'link',
'source_kind' => 'redirect',
'is_managed_here' => true,
'destination_url' => 'https://example.com/help',
'is_active' => true,
]);
$this->get('https://ladl.link/docs/getting-started?src=nav')
->assertRedirect('https://example.com/help/getting-started?src=nav');
}
public function test_public_url_uses_config_domain_dynamically(): void
{
config(['link.public_domain' => 'go.example.test']);
$user = $this->user();
$link = ShortLink::create([
'user_id' => $user->id,
'slug' => 'branded',
'source_app' => 'link',
'source_kind' => 'redirect',
'is_managed_here' => true,
'destination_url' => 'https://example.com',
'is_active' => true,
]);
$this->assertSame('https://go.example.test/branded', $link->publicUrl());
$this->assertSame('go.example.test', ShortLink::publicHost());
}
public function test_catalog_qr_links_proxy_to_platform_instead_of_bad_destination(): void
{
$user = $this->user();