Make Ladill Link short URLs fully dynamic at click time.
Deploy Ladill Link / deploy (push) Successful in 1m55s
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:
@@ -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);
|
||||
}
|
||||
$link = $this->links->resolve($request, $slug);
|
||||
|
||||
if ($link !== null && $link->isDirectRedirect()) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user