From d45f4b5c58ad48d1b850acf03d0821103a711832 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 16 Jul 2026 20:05:43 +0000 Subject: [PATCH] Make Ladill Link short URLs fully dynamic at click time. 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. --- app/Http/Controllers/Link/LinkController.php | 4 ++ .../Controllers/Link/OverviewController.php | 2 + .../Controllers/Link/SettingsController.php | 4 +- .../Public/LinkRedirectController.php | 13 ++-- app/Models/ShortLink.php | 72 ++++++++++++++++++- app/Services/Afia/AfiaService.php | 2 +- .../CustomDomain/LinkCustomDomainService.php | 6 +- app/Services/Link/LinkPlatformProxy.php | 44 ++++++++++-- app/Support/LadillLink.php | 3 +- resources/views/links/create.blade.php | 10 ++- resources/views/links/dashboard.blade.php | 2 +- resources/views/links/index.blade.php | 6 +- resources/views/links/show.blade.php | 2 +- tests/Feature/LinkRedirectTest.php | 53 ++++++++++++++ 14 files changed, 195 insertions(+), 28 deletions(-) diff --git a/app/Http/Controllers/Link/LinkController.php b/app/Http/Controllers/Link/LinkController.php index 40790cd..2a63e98 100644 --- a/app/Http/Controllers/Link/LinkController.php +++ b/app/Http/Controllers/Link/LinkController.php @@ -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(), ]); } diff --git a/app/Http/Controllers/Link/OverviewController.php b/app/Http/Controllers/Link/OverviewController.php index 61c7454..fd80a34 100644 --- a/app/Http/Controllers/Link/OverviewController.php +++ b/app/Http/Controllers/Link/OverviewController.php @@ -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(), ]); } } diff --git a/app/Http/Controllers/Link/SettingsController.php b/app/Http/Controllers/Link/SettingsController.php index 8799317..7e1d53e 100644 --- a/app/Http/Controllers/Link/SettingsController.php +++ b/app/Http/Controllers/Link/SettingsController.php @@ -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() diff --git a/app/Http/Controllers/Public/LinkRedirectController.php b/app/Http/Controllers/Public/LinkRedirectController.php index 52fc6a9..96820d6 100644 --- a/app/Http/Controllers/Public/LinkRedirectController.php +++ b/app/Http/Controllers/Public/LinkRedirectController.php @@ -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); } } diff --git a/app/Models/ShortLink.php b/app/Models/ShortLink.php index a254d45..0795469 100644 --- a/app/Models/ShortLink.php +++ b/app/Models/ShortLink.php @@ -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 diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 37f68c0..ea6ae12 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -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. diff --git a/app/Services/CustomDomain/LinkCustomDomainService.php b/app/Services/CustomDomain/LinkCustomDomainService.php index eda23b6..9f475ec 100644 --- a/app/Services/CustomDomain/LinkCustomDomainService.php +++ b/app/Services/CustomDomain/LinkCustomDomainService.php @@ -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); diff --git a/app/Services/Link/LinkPlatformProxy.php b/app/Services/Link/LinkPlatformProxy.php index e5c9ad7..40fb5dc 100644 --- a/app/Services/Link/LinkPlatformProxy.php +++ b/app/Services/Link/LinkPlatformProxy.php @@ -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; + } } diff --git a/app/Support/LadillLink.php b/app/Support/LadillLink.php index 3a51bc0..af01356 100644 --- a/app/Support/LadillLink.php +++ b/app/Support/LadillLink.php @@ -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 { diff --git a/resources/views/links/create.blade.php b/resources/views/links/create.blade.php index 99bdc97..dd0660d 100644 --- a/resources/views/links/create.blade.php +++ b/resources/views/links/create.blade.php @@ -1,10 +1,10 @@ Create Link -
+

Create short link

- Your link will be published on ladl.link. + Your link will be published on {{ $publicHost }}. GHS {{ number_format($pricePerLink, 2) }} will be debited from your wallet.

@@ -35,11 +35,15 @@
- ladl.link/ + {{ $publicHost }}/
+

+ Preview: +

@error('custom_slug')

{{ $message }}

@enderror
diff --git a/resources/views/links/dashboard.blade.php b/resources/views/links/dashboard.blade.php index 7186031..499c00b 100644 --- a/resources/views/links/dashboard.blade.php +++ b/resources/views/links/dashboard.blade.php @@ -4,7 +4,7 @@

Overview

- +
@include('partials.mobile-header-btn', [ 'href' => route('user.links.create'), diff --git a/resources/views/links/index.blade.php b/resources/views/links/index.blade.php index a7b2ad8..adcb3ac 100644 --- a/resources/views/links/index.blade.php +++ b/resources/views/links/index.blade.php @@ -22,11 +22,11 @@
- Short links · Click analytics · ladl.link + Short links · Click analytics · {{ $publicHost }}

My Links

- 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.