diff --git a/app/Services/Link/LinkPlatformProxy.php b/app/Services/Link/LinkPlatformProxy.php index b2a3b0e..551b568 100644 --- a/app/Services/Link/LinkPlatformProxy.php +++ b/app/Services/Link/LinkPlatformProxy.php @@ -8,45 +8,40 @@ 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. + * Proxies ladl.link ecosystem paths to the platform QR resolver (/q/* on ladill.com) + * 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 { + public const INTERNAL_HEADER = 'X-Ladill-Internal'; + 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'), '/'); $target = $platform.'/q/'.$slug; - if ($path !== null && $path !== '') { - $target .= '/'.ltrim($path, '/'); - } - if ($qs = $request->getQueryString()) { - $target .= '?'.$qs; + if ($normalizedPath !== '') { + $target .= '/'.$normalizedPath; } - $method = strtoupper($request->method()); - $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()) - ); + $response = $this->fetch($request, $target); if ($response->isRedirection()) { $location = $response->headers->get('Location'); if (is_string($location)) { + if ($followUp = $this->followLadillLinkRedirect($request, $slug, $location)) { + return $followUp; + } + $response->headers->set('Location', $this->rewriteLocation($location, $slug)); } } @@ -54,6 +49,62 @@ class LinkPlatformProxy 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 */ private function forwardHeaders(Request $request): array { @@ -62,7 +113,7 @@ class LinkPlatformProxy 'Accept-Language' => $request->header('Accept-Language'), 'User-Agent' => $request->header('User-Agent'), 'Referer' => $request->header('Referer'), - 'X-Ladill-Internal' => '1', + self::INTERNAL_HEADER => '1', ]); } @@ -87,9 +138,15 @@ class LinkPlatformProxy if (str_starts_with($location, $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; diff --git a/config/app.php b/config/app.php index be6a259..eb4d8d5 100644 --- a/config/app.php +++ b/config/app.php @@ -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')), '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'), + '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')), 'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), diff --git a/tests/Feature/LinkRedirectTest.php b/tests/Feature/LinkRedirectTest.php index 862fa69..f9bcec0 100644 --- a/tests/Feature/LinkRedirectTest.php +++ b/tests/Feature/LinkRedirectTest.php @@ -65,4 +65,34 @@ class LinkRedirectTest extends TestCase && $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('Transfer files', 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'); + }); + } }