Fix ladl.link transfer QR redirect loop causing 500 errors.
Deploy Ladill Link / deploy (push) Successful in 1m1s

Proxy transfer paths directly to transfer.ladill.com and stop following ladl.link redirects server-side so recipient links resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 20:55:48 +00:00
co-authored by Cursor
parent 62bab93b26
commit c5e92012ff
3 changed files with 117 additions and 29 deletions
+86 -29
View File
@@ -8,45 +8,40 @@ use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
* Proxies ladl.link ecosystem paths to the platform QR resolver (/q/* on ladill.com). * Proxies ladl.link ecosystem paths to the platform QR resolver (/q/* on ladill.com)
* The browser stays on ladl.link; resolution happens server-side. * or directly to satellite apps (e.g. transfer.ladill.com). The browser stays on
* ladl.link; resolution happens server-side without redirect loops.
*/ */
class LinkPlatformProxy class LinkPlatformProxy
{ {
public const INTERNAL_HEADER = 'X-Ladill-Internal';
public function forward(Request $request, string $slug, ?string $path = null): Response public function forward(Request $request, string $slug, ?string $path = null): Response
{ {
$normalizedPath = $path !== null ? ltrim($path, '/') : '';
if ($normalizedPath === 'transfer' || str_starts_with($normalizedPath, 'transfer/')) {
return $this->fetch(
$request,
rtrim($this->transferBaseUrl(), '/').'/q/'.$slug.'/'.$normalizedPath,
);
}
$platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/'); $platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
$target = $platform.'/q/'.$slug; $target = $platform.'/q/'.$slug;
if ($path !== null && $path !== '') { if ($normalizedPath !== '') {
$target .= '/'.ltrim($path, '/'); $target .= '/'.$normalizedPath;
}
if ($qs = $request->getQueryString()) {
$target .= '?'.$qs;
} }
$method = strtoupper($request->method()); $response = $this->fetch($request, $target);
$followRedirects = $method === 'GET' || $method === 'HEAD';
$client = Http::withOptions([
'allow_redirects' => $followRedirects ? ['max' => 5] : false,
])->timeout(15);
$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()) { if ($response->isRedirection()) {
$location = $response->headers->get('Location'); $location = $response->headers->get('Location');
if (is_string($location)) { if (is_string($location)) {
if ($followUp = $this->followLadillLinkRedirect($request, $slug, $location)) {
return $followUp;
}
$response->headers->set('Location', $this->rewriteLocation($location, $slug)); $response->headers->set('Location', $this->rewriteLocation($location, $slug));
} }
} }
@@ -54,6 +49,62 @@ class LinkPlatformProxy
return $response; return $response;
} }
private function fetch(Request $request, string $target): Response
{
if ($qs = $request->getQueryString()) {
$target .= (str_contains($target, '?') ? '&' : '?').$qs;
}
$method = strtoupper($request->method());
$client = Http::withOptions(['allow_redirects' => false])
->timeout(15)
->withHeaders($this->forwardHeaders($request));
$pending = match ($method) {
'POST' => $client->asForm()->post($target, $request->post()),
'PATCH' => $client->patch($target, $request->all()),
'DELETE' => $client->delete($target),
default => $client->get($target),
};
$upstream = $pending->toPsrResponse();
return new Response(
(string) $upstream->getBody(),
$upstream->getStatusCode(),
$this->sanitizeHeaders($upstream->getHeaders()),
);
}
private function followLadillLinkRedirect(Request $request, string $slug, string $location): ?Response
{
$parsed = parse_url($location);
if (! is_array($parsed)) {
return null;
}
$host = strtolower((string) ($parsed['host'] ?? ''));
$linkHost = strtolower((string) parse_url(LadillLink::baseUrl(), PHP_URL_HOST));
if ($host !== $linkHost) {
return null;
}
$path = ltrim((string) ($parsed['path'] ?? ''), '/');
$slugPrefix = $slug.'/';
if (! str_starts_with($path, $slugPrefix)) {
return null;
}
return $this->forward($request, $slug, substr($path, strlen($slugPrefix)));
}
private function transferBaseUrl(): string
{
$domain = (string) config('app.transfer_domain', 'transfer.ladill.com');
return 'https://'.$domain;
}
/** @return array<string, string> */ /** @return array<string, string> */
private function forwardHeaders(Request $request): array private function forwardHeaders(Request $request): array
{ {
@@ -62,7 +113,7 @@ class LinkPlatformProxy
'Accept-Language' => $request->header('Accept-Language'), 'Accept-Language' => $request->header('Accept-Language'),
'User-Agent' => $request->header('User-Agent'), 'User-Agent' => $request->header('User-Agent'),
'Referer' => $request->header('Referer'), 'Referer' => $request->header('Referer'),
'X-Ladill-Internal' => '1', self::INTERNAL_HEADER => '1',
]); ]);
} }
@@ -87,9 +138,15 @@ class LinkPlatformProxy
if (str_starts_with($location, $prefix)) { if (str_starts_with($location, $prefix)) {
$suffix = substr($location, strlen($prefix)); $suffix = substr($location, strlen($prefix));
$rewritten = LadillLink::url($slug).$suffix;
return $rewritten; return LadillLink::url($slug).$suffix;
}
$transfer = rtrim($this->transferBaseUrl(), '/').'/q/'.$slug;
if (str_starts_with($location, $transfer)) {
$suffix = substr($location, strlen($transfer));
return LadillLink::url($slug).$suffix;
} }
return $location; return $location;
+1
View File
@@ -129,6 +129,7 @@ return [
'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), 'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), 'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'link_domain' => env('LINK_DOMAIN', parse_url((string) env('APP_URL', 'https://link.ladill.com'), PHP_URL_HOST) ?: 'link.ladill.com'), 'link_domain' => env('LINK_DOMAIN', parse_url((string) env('APP_URL', 'https://link.ladill.com'), PHP_URL_HOST) ?: 'link.ladill.com'),
'transfer_domain' => env('TRANSFER_DOMAIN', 'transfer.ladill.com'),
'servers_domain' => env('SERVERS_DOMAIN', 'servers.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), 'servers_domain' => env('SERVERS_DOMAIN', 'servers.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), 'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
+30
View File
@@ -65,4 +65,34 @@ class LinkRedirectTest extends TestCase
&& $request->hasHeader('X-Ladill-Internal', '1'); && $request->hasHeader('X-Ladill-Internal', '1');
}); });
} }
public function test_transfer_qr_proxies_to_transfer_app_without_redirect_loop(): void
{
$user = $this->user();
ShortLink::create([
'user_id' => $user->id,
'slug' => 'mo4o0v1t',
'source_app' => 'transfer',
'source_kind' => 'qr',
'is_managed_here' => false,
'destination_url' => 'https://ladl.link/mo4o0v1t',
'is_active' => true,
]);
Http::fake([
'https://ladill.com/q/mo4o0v1t' => Http::response('', 302, [
'Location' => 'https://ladl.link/mo4o0v1t/transfer',
]),
'https://transfer.ladill.com/q/mo4o0v1t/transfer' => Http::response('<html>Transfer files</html>', 200),
]);
$this->get('https://ladl.link/mo4o0v1t')
->assertOk()
->assertSee('Transfer files');
Http::assertSent(function ($request) {
return $request->url() === 'https://transfer.ladill.com/q/mo4o0v1t/transfer'
&& $request->hasHeader('X-Ladill-Internal', '1');
});
}
} }