diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index a6a70c31..64837a64 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -4,12 +4,20 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; +use League\Uri\Uri; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; +use function Functional\compose; +use function str_replace; + class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface { + private const DOMAIN_PLACEHOLDER = '{DOMAIN}'; + private const ORIGINAL_PATH_PLACEHOLDER = '{ORIGINAL_PATH}'; + public function __construct(private RedirectResponseHelperInterface $redirectResponseHelper) { } @@ -17,18 +25,46 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface public function resolveRedirectResponse( NotFoundType $notFoundType, NotFoundRedirectConfigInterface $config, + UriInterface $currentUri, ): ?ResponseInterface { return match (true) { $notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => - // @phpstan-ignore-next-line Create custom PHPStan rule - $this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()), + $this->redirectResponseHelper->buildRedirectResponse( + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->resolvePlaceholders($currentUri, $config->baseUrlRedirect()), + ), $notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => - // @phpstan-ignore-next-line Create custom PHPStan rule - $this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()), + $this->redirectResponseHelper->buildRedirectResponse( + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->resolvePlaceholders($currentUri, $config->regular404Redirect()), + ), $notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() => - // @phpstan-ignore-next-line Create custom PHPStan rule - $this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()), + $this->redirectResponseHelper->buildRedirectResponse( + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->resolvePlaceholders($currentUri, $config->invalidShortUrlRedirect()), + ), default => null, }; } + + private function resolvePlaceholders(UriInterface $currentUri, string $redirectUrl): string + { + $domain = $currentUri->getAuthority(); + $path = $currentUri->getPath(); + $redirectUri = Uri::createFromString($redirectUrl); + + $replacePlaceholders = static fn (callable $modifier) => compose( + static fn (?string $value) => + $value === null ? null : str_replace(self::DOMAIN_PLACEHOLDER, $modifier($domain), $value), + static fn (?string $value) => + $value === null ? null : str_replace(self::ORIGINAL_PATH_PLACEHOLDER, $modifier($path), $value), + ); + $replacePlaceholdersInPath = $replacePlaceholders('\Functional\id'); + $replacePlaceholdersInQuery = $replacePlaceholders('\urlencode'); + + return $redirectUri + ->withPath($replacePlaceholdersInPath($redirectUri->getPath())) + ->withQuery($replacePlaceholdersInQuery($redirectUri->getQuery())) + ->__toString(); + } } diff --git a/module/Core/src/Config/NotFoundRedirectResolverInterface.php b/module/Core/src/Config/NotFoundRedirectResolverInterface.php index ab010d2e..6cbdf702 100644 --- a/module/Core/src/Config/NotFoundRedirectResolverInterface.php +++ b/module/Core/src/Config/NotFoundRedirectResolverInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; interface NotFoundRedirectResolverInterface @@ -12,5 +13,6 @@ interface NotFoundRedirectResolverInterface public function resolveRedirectResponse( NotFoundType $notFoundType, NotFoundRedirectConfigInterface $config, + UriInterface $currentUri, ): ?ResponseInterface; } diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index 84918876..4138a72e 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ErrorHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface; @@ -26,19 +27,25 @@ class NotFoundRedirectHandler implements MiddlewareInterface { /** @var NotFoundType $notFoundType */ $notFoundType = $request->getAttribute(NotFoundType::class); - $authority = $request->getUri()->getAuthority(); - $domainSpecificRedirect = $this->resolveDomainSpecificRedirect($authority, $notFoundType); + $currentUri = $request->getUri(); + $domainSpecificRedirect = $this->resolveDomainSpecificRedirect($currentUri, $notFoundType); return $domainSpecificRedirect // If we did not find domain-specific redirects for current domain, we try to fall back to default redirects - ?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions) + ?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions, $currentUri) // Ultimately, we just call next handler if no domain-specific redirects or default redirects were found ?? $handler->handle($request); } - private function resolveDomainSpecificRedirect(string $authority, NotFoundType $notFoundType): ?ResponseInterface - { - $domain = $this->domainService->findByAuthority($authority); - return $domain === null ? null : $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain); + private function resolveDomainSpecificRedirect( + UriInterface $currentUri, + NotFoundType $notFoundType, + ): ?ResponseInterface { + $domain = $this->domainService->findByAuthority($currentUri->getAuthority()); + if ($domain === null) { + return null; + } + + return $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain, $currentUri); } } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index fe482a41..3a4c6476 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -53,7 +53,7 @@ class NotFoundRedirectResolverTest extends TestCase $expectedResp = new Response(); $buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp); - $resp = $this->resolver->resolveRedirectResponse($notFoundType, $this->config); + $resp = $this->resolver->resolveRedirectResponse($notFoundType, $this->config, new Uri()); self::assertSame($expectedResp, $resp); $buildResp->shouldHaveBeenCalledOnce(); @@ -84,7 +84,7 @@ class NotFoundRedirectResolverTest extends TestCase { $notFoundType = $this->notFoundType($this->requestForRoute('foo')); - $result = $this->resolver->resolveRedirectResponse($notFoundType, $this->config); + $result = $this->resolver->resolveRedirectResponse($notFoundType, $this->config, new Uri()); self::assertNull($result); $this->helper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index e508a87b..70063764 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -11,6 +11,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; @@ -75,6 +76,7 @@ class NotFoundRedirectHandlerTest extends TestCase $resolver->resolveRedirectResponse( Argument::type(NotFoundType::class), Argument::type(NotFoundRedirectOptions::class), + Argument::type(UriInterface::class), )->willReturn(null)->shouldBeCalledOnce(); }]; yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { @@ -84,10 +86,13 @@ class NotFoundRedirectHandlerTest extends TestCase $resolver->resolveRedirectResponse( Argument::type(NotFoundType::class), Argument::type(NotFoundRedirectOptions::class), + Argument::type(UriInterface::class), + )->willReturn(null)->shouldBeCalledOnce(); + $resolver->resolveRedirectResponse( + Argument::type(NotFoundType::class), + Argument::type(Domain::class), + Argument::type(UriInterface::class), )->willReturn(null)->shouldBeCalledOnce(); - $resolver->resolveRedirectResponse(Argument::type(NotFoundType::class), Argument::type(Domain::class)) - ->willReturn(null) - ->shouldBeCalledOnce(); }]; } @@ -100,6 +105,7 @@ class NotFoundRedirectHandlerTest extends TestCase $resolveRedirect = $this->resolver->resolveRedirectResponse( Argument::type(NotFoundType::class), $this->redirectOptions, + Argument::type(UriInterface::class), )->willReturn($expectedResp); $result = $this->middleware->process($this->req, $this->next->reveal()); @@ -120,6 +126,7 @@ class NotFoundRedirectHandlerTest extends TestCase $resolveRedirect = $this->resolver->resolveRedirectResponse( Argument::type(NotFoundType::class), $domain, + Argument::type(UriInterface::class), )->willReturn($expectedResp); $result = $this->middleware->process($this->req, $this->next->reveal());