Files
ladill-link/app/Services/Link/LinkPlatformProxy.php
T
isaacclad d45f4b5c58
Deploy Ladill Link / deploy (push) Successful in 1m55s
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.
2026-07-16 20:05:43 +00:00

235 lines
7.7 KiB
PHP

<?php
namespace App\Services\Link;
use App\Support\LadillLink;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* 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,
);
}
if ($this->isEventsRegistrationPath($normalizedPath)) {
return $this->forwardToQrHub($request, $slug, $normalizedPath, $this->eventsBaseUrl());
}
$platformResponse = $this->forwardToQrHub($request, $slug, $normalizedPath, $this->platformBaseUrl());
if ($platformResponse->getStatusCode() !== 404) {
return $platformResponse;
}
return $this->forwardToQrHub($request, $slug, $normalizedPath, $this->eventsBaseUrl());
}
private function forwardToQrHub(Request $request, string $slug, string $normalizedPath, string $hubBase): Response
{
$target = rtrim($hubBase, '/').'/q/'.$slug;
if ($normalizedPath !== '') {
$target .= '/'.$normalizedPath;
}
$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($request, $location, $slug));
}
}
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' => $this->forwardPost($client, $request, $target),
'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'] ?? ''));
if (! $this->isLinkPublicHost($host, $request)) {
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 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'
|| str_starts_with($path, 'register/')
|| str_starts_with($path, 'registered/')
|| str_starts_with($path, 'speaker/');
}
private function platformBaseUrl(): string
{
return rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
}
private function eventsBaseUrl(): string
{
$domain = (string) config('app.events_domain', 'events.ladill.com');
return 'https://'.$domain;
}
private function transferBaseUrl(): string
{
$domain = (string) config('app.transfer_domain', 'transfer.ladill.com');
return 'https://'.$domain;
}
/** @return array<string, string> */
private function forwardHeaders(Request $request): array
{
return array_filter([
'Accept' => $request->header('Accept'),
'Accept-Language' => $request->header('Accept-Language'),
'Content-Type' => $request->header('Content-Type'),
'User-Agent' => $request->header('User-Agent'),
'Referer' => $request->header('Referer'),
self::INTERNAL_HEADER => '1',
]);
}
private function forwardPost(\Illuminate\Http\Client\PendingRequest $client, Request $request, string $target): \Illuminate\Http\Client\Response
{
if ($this->requestSendsJson($request)) {
return $client->asJson()->post($target, $request->all());
}
return $client->asForm()->post($target, $request->post());
}
private function requestSendsJson(Request $request): bool
{
return str_contains(strtolower((string) $request->header('Content-Type', '')), 'application/json');
}
/** @param array<string, array<int, string>> $headers */
private function sanitizeHeaders(array $headers): array
{
$flat = [];
foreach ($headers as $name => $values) {
if (strtolower($name) === 'transfer-encoding') {
continue;
}
$flat[$name] = $values[0] ?? '';
}
return $flat;
}
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 rtrim($publicBase, '/').'/'.$slug.$suffix;
}
}
$transfer = rtrim($this->transferBaseUrl(), '/').'/q/'.$slug;
if (str_starts_with($location, $transfer)) {
$suffix = substr($location, strlen($transfer));
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;
}
}