diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index b36c4b78..c887d5b7 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -8,6 +8,7 @@ use Fig\Http\Message\RequestMethodInterface; use RKA\Middleware\IpAddress; use Shlinkio\Shlink\Core\Action as CoreAction; use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\ShortUrl\Middleware\TrimTrailingSlashMiddleware; use Shlinkio\Shlink\Rest\Action; use Shlinkio\Shlink\Rest\ConfigProvider; use Shlinkio\Shlink\Rest\Middleware; @@ -19,6 +20,8 @@ return (static function (): array { $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; + + // TODO This should be based on config, not the env var $shortUrlRouteSuffix = EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false) ? '[/]' : ''; return [ @@ -97,6 +100,7 @@ return (static function (): array { 'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix), 'middleware' => [ IpAddress::class, + TrimTrailingSlashMiddleware::class, CoreAction\RedirectAction::class, ], 'allowed_methods' => [RequestMethodInterface::METHOD_GET], diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index bf9ecb93..ec3c1409 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -24,6 +24,7 @@ return (static function (): array { 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(false), 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), + 'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false), ], ]; diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index f49570e1..2d129625 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -18,6 +18,7 @@ return [ ], 'auto_resolve_titles' => true, // 'multi_segment_slugs_enabled' => true, +// 'trailing_slash_enabled' => true, ], ]; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index d05df586..a7ebd1b7 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -43,6 +43,7 @@ return [ ShortUrl\Helper\ShortUrlRedirectionBuilder::class => ConfigAbstractFactory::class, ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class, + ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => ConfigAbstractFactory::class, Tag\TagService::class => ConfigAbstractFactory::class, @@ -154,6 +155,7 @@ return [ Util\RedirectResponseHelper::class, Options\UrlShortenerOptions::class, ], + ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => [Options\UrlShortenerOptions::class], EventDispatcher\PublishingUpdatesGenerator::class => [ ShortUrl\Transformer\ShortUrlDataTransformer::class, diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index dd7fdc8d..9aacc085 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -15,6 +15,7 @@ final class UrlShortenerOptions public readonly bool $autoResolveTitles = false, public readonly bool $appendExtraPath = false, public readonly bool $multiSegmentSlugsEnabled = false, + public readonly bool $trailingSlashEnabled = false, ) { } } diff --git a/module/Core/src/ShortUrl/Middleware/TrimTrailingSlashMiddleware.php b/module/Core/src/ShortUrl/Middleware/TrimTrailingSlashMiddleware.php new file mode 100644 index 00000000..d3823b27 --- /dev/null +++ b/module/Core/src/ShortUrl/Middleware/TrimTrailingSlashMiddleware.php @@ -0,0 +1,39 @@ +handle($this->resolveRequest($request)); + } + + private function resolveRequest(ServerRequestInterface $request): ServerRequestInterface + { + // If multi-segment slugs are enabled together with trailing slashes, the "shortCode" attribute will include + // ending slashes that we need to trim for a proper short code matching + + /** @var string|null $shortCode */ + $shortCode = $request->getAttribute(self::SHORT_CODE_ATTR); + $shouldTrimSlash = $shortCode !== null && $this->options->trailingSlashEnabled; + + return $shouldTrimSlash ? $request->withAttribute(self::SHORT_CODE_ATTR, rtrim($shortCode, '/')) : $request; + } +} diff --git a/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php new file mode 100644 index 00000000..6a46f8e9 --- /dev/null +++ b/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php @@ -0,0 +1,85 @@ +requestHandler = $this->prophesize(RequestHandlerInterface::class); + } + + /** + * @test + * @dataProvider provideRequests + */ + public function returnsExpectedResponse( + bool $trailingSlashEnabled, + ServerRequestInterface $inputRequest, + callable $assertions, + ): void { + $arg = compose($assertions, const_function(true)); + + $this->requestHandler->handle(Argument::that($arg))->willReturn(new Response()); + $this->middleware($trailingSlashEnabled)->process($inputRequest, $this->requestHandler->reveal()); + } + + public function provideRequests(): iterable + { + yield 'trailing slash disabled' => [ + false, + $inputReq = ServerRequestFactory::fromGlobals(), + function (ServerRequestInterface $request) use ($inputReq): void { + Assert::assertSame($inputReq, $request); + }, + ]; + yield 'trailing slash enabled without shortCode attr' => [ + true, + $inputReq = ServerRequestFactory::fromGlobals(), + function (ServerRequestInterface $request) use ($inputReq): void { + Assert::assertSame($inputReq, $request); + }, + ]; + yield 'trailing slash enabled with null shortCode attr' => [ + true, + $inputReq = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', null), + function (ServerRequestInterface $request) use ($inputReq): void { + Assert::assertSame($inputReq, $request); + }, + ]; + yield 'trailing slash enabled with non-null shortCode attr' => [ + true, + $inputReq = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'foo//'), + function (ServerRequestInterface $request) use ($inputReq): void { + Assert::assertNotSame($inputReq, $request); + Assert::assertEquals('foo', $request->getAttribute('shortCode')); + }, + ]; + } + + private function middleware(bool $trailingSlashEnabled = false): TrimTrailingSlashMiddleware + { + return new TrimTrailingSlashMiddleware(new UrlShortenerOptions(trailingSlashEnabled: $trailingSlashEnabled)); + } +}