Allow the extra path to be ignored when redirecting

This commit is contained in:
Alejandro Celaya 2024-12-01 09:51:00 +01:00
parent e74ee793a0
commit c65349d265
7 changed files with 68 additions and 18 deletions

View File

@ -6,7 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
# [Unreleased] # [Unreleased]
### Added ### Added
* *Nothing* * [#2265](https://github.com/shlinkio/shlink/issues/2265) Add a new `REDIRECT_EXTRA_PATH_MODE` option that accepts three values:
* `default`: Short URLs only match if the path matches their short code or custom slug.
* `append`: Short URLs are matched as soon as the path starts with the short code or custom slug, and the extra path is appended to the long URL before redirecting.
* `ignore`: Short URLs are matched as soon as the path starts with the short code or custom slug, and the extra path is ignored.
This option effectively replaces the old `REDIRECT_APPEND_EXTRA_PATH` option, which is now deprecated and will be removed in Shlink 5.0.0
### Changed ### Changed
* * [#2281](https://github.com/shlinkio/shlink/issues/2281) Update docker image to PHP 8.4 * * [#2281](https://github.com/shlinkio/shlink/issues/2281) Update docker image to PHP 8.4

View File

@ -154,8 +154,8 @@
"@test:cli", "@test:cli",
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov" "phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
], ],
"swagger:validate": "php-openapi validate docs/swagger/swagger.json", "swagger:validate": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\" vendor/bin/php-openapi validate docs/swagger/swagger.json",
"swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json", "swagger:inline": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\" vendor/bin/php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json",
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php" "clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
}, },
"scripts-descriptions": { "scripts-descriptions": {

View File

@ -84,7 +84,7 @@ enum EnvVars: string
case IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED'; case IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED';
case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN'; case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES'; case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; case REDIRECT_EXTRA_PATH_MODE = 'REDIRECT_EXTRA_PATH_MODE';
case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED'; case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED';
case ROBOTS_ALLOW_ALL_SHORT_URLS = 'ROBOTS_ALLOW_ALL_SHORT_URLS'; case ROBOTS_ALLOW_ALL_SHORT_URLS = 'ROBOTS_ALLOW_ALL_SHORT_URLS';
case ROBOTS_USER_AGENTS = 'ROBOTS_USER_AGENTS'; case ROBOTS_USER_AGENTS = 'ROBOTS_USER_AGENTS';
@ -92,6 +92,8 @@ enum EnvVars: string
case MEMORY_LIMIT = 'MEMORY_LIMIT'; case MEMORY_LIMIT = 'MEMORY_LIMIT';
case INITIAL_API_KEY = 'INITIAL_API_KEY'; case INITIAL_API_KEY = 'INITIAL_API_KEY';
case SKIP_INITIAL_GEOLITE_DOWNLOAD = 'SKIP_INITIAL_GEOLITE_DOWNLOAD'; case SKIP_INITIAL_GEOLITE_DOWNLOAD = 'SKIP_INITIAL_GEOLITE_DOWNLOAD';
/** @deprecated Use REDIRECT_EXTRA_PATH */
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
public function loadFromEnv(): mixed public function loadFromEnv(): mixed
{ {
@ -125,11 +127,13 @@ enum EnvVars: string
self::DEFAULT_SHORT_CODES_LENGTH => DEFAULT_SHORT_CODES_LENGTH, self::DEFAULT_SHORT_CODES_LENGTH => DEFAULT_SHORT_CODES_LENGTH,
self::SHORT_URL_MODE => ShortUrlMode::STRICT->value, self::SHORT_URL_MODE => ShortUrlMode::STRICT->value,
self::IS_HTTPS_ENABLED, self::AUTO_RESOLVE_TITLES => true, self::IS_HTTPS_ENABLED, self::AUTO_RESOLVE_TITLES => true,
self::REDIRECT_APPEND_EXTRA_PATH,
self::MULTI_SEGMENT_SLUGS_ENABLED, self::MULTI_SEGMENT_SLUGS_ENABLED,
self::SHORT_URL_TRAILING_SLASH => false, self::SHORT_URL_TRAILING_SLASH => false,
self::DEFAULT_DOMAIN, self::BASE_PATH => '', self::DEFAULT_DOMAIN, self::BASE_PATH => '',
self::CACHE_NAMESPACE => 'Shlink', self::CACHE_NAMESPACE => 'Shlink',
// Deprecated. In Shlink 5.0.0, add default value for REDIRECT_EXTRA_PATH_MODE
self::REDIRECT_APPEND_EXTRA_PATH => false,
// self::REDIRECT_EXTRA_PATH_MODE => ExtraPathMode::DEFAULT->value,
self::REDIS_PUB_SUB_ENABLED, self::REDIS_PUB_SUB_ENABLED,
self::MATOMO_ENABLED, self::MATOMO_ENABLED,

View File

@ -0,0 +1,13 @@
<?php
namespace Shlinkio\Shlink\Core\Config\Options;
enum ExtraPathMode: string
{
/** URLs with extra path will not match a short URL */
case DEFAULT = 'default';
/** The extra path will be appended to the long URL */
case APPEND = 'append';
/** The extra path will be ignored */
case IGNORE = 'ignore';
}

View File

@ -22,10 +22,10 @@ final readonly class UrlShortenerOptions
public string $schema = 'http', public string $schema = 'http',
public int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH, public int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH,
public bool $autoResolveTitles = false, public bool $autoResolveTitles = false,
public bool $appendExtraPath = false,
public bool $multiSegmentSlugsEnabled = false, public bool $multiSegmentSlugsEnabled = false,
public bool $trailingSlashEnabled = false, public bool $trailingSlashEnabled = false,
public ShortUrlMode $mode = ShortUrlMode::STRICT, public ShortUrlMode $mode = ShortUrlMode::STRICT,
public ExtraPathMode $extraPathMode = ExtraPathMode::DEFAULT,
) { ) {
} }
@ -35,17 +35,26 @@ final readonly class UrlShortenerOptions
(int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(), (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(),
MIN_SHORT_CODES_LENGTH, MIN_SHORT_CODES_LENGTH,
); );
$mode = EnvVars::SHORT_URL_MODE->loadFromEnv();
// Deprecated. Initialize extra path from REDIRECT_APPEND_EXTRA_PATH.
$appendExtraPath = EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv();
$extraPathMode = $appendExtraPath ? ExtraPathMode::APPEND : ExtraPathMode::DEFAULT;
// If REDIRECT_EXTRA_PATH_MODE was explicitly provided, it has precedence
$extraPathModeFromEnv = EnvVars::REDIRECT_EXTRA_PATH_MODE->loadFromEnv();
if ($extraPathModeFromEnv !== null) {
$extraPathMode = ExtraPathMode::tryFrom($extraPathModeFromEnv) ?? ExtraPathMode::DEFAULT;
}
return new self( return new self(
defaultDomain: EnvVars::DEFAULT_DOMAIN->loadFromEnv(), defaultDomain: EnvVars::DEFAULT_DOMAIN->loadFromEnv(),
schema: ((bool) EnvVars::IS_HTTPS_ENABLED->loadFromEnv()) ? 'https' : 'http', schema: ((bool) EnvVars::IS_HTTPS_ENABLED->loadFromEnv()) ? 'https' : 'http',
defaultShortCodesLength: $shortCodesLength, defaultShortCodesLength: $shortCodesLength,
autoResolveTitles: (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(), autoResolveTitles: (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(),
appendExtraPath: (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(),
multiSegmentSlugsEnabled: (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(), multiSegmentSlugsEnabled: (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(),
trailingSlashEnabled: (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(), trailingSlashEnabled: (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(),
mode: ShortUrlMode::tryFrom($mode) ?? ShortUrlMode::STRICT, mode: ShortUrlMode::tryFrom(EnvVars::SHORT_URL_MODE->loadFromEnv()) ?? ShortUrlMode::STRICT,
extraPathMode: $extraPathMode,
); );
} }

View File

@ -9,6 +9,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Config\Options\ExtraPathMode;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
@ -51,7 +52,7 @@ readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface
private function shouldApplyLogic(NotFoundType|null $notFoundType): bool private function shouldApplyLogic(NotFoundType|null $notFoundType): bool
{ {
if ($notFoundType === null || ! $this->urlShortenerOptions->appendExtraPath) { if ($notFoundType === null || $this->urlShortenerOptions->extraPathMode === ExtraPathMode::DEFAULT) {
return false; return false;
} }
@ -75,7 +76,11 @@ readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface
try { try {
$shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier);
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); $longUrl = $this->redirectionBuilder->buildShortUrlRedirect(
$shortUrl,
$request,
$this->urlShortenerOptions->extraPathMode === ExtraPathMode::APPEND ? $extraPath : null,
);
$this->requestTracker->trackIfApplicable( $this->requestTracker->trackIfApplicable(
$shortUrl, $shortUrl,
$request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, $longUrl), $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, $longUrl),

View File

@ -11,11 +11,13 @@ use Mezzio\Router\Route;
use Mezzio\Router\RouteResult; use Mezzio\Router\RouteResult;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Config\Options\ExtraPathMode;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
@ -57,8 +59,8 @@ class ExtraPathRedirectMiddlewareTest extends TestCase
ServerRequestInterface $request, ServerRequestInterface $request,
): void { ): void {
$options = new UrlShortenerOptions( $options = new UrlShortenerOptions(
appendExtraPath: $appendExtraPath,
multiSegmentSlugsEnabled: $multiSegmentEnabled, multiSegmentSlugsEnabled: $multiSegmentEnabled,
extraPathMode: $appendExtraPath ? ExtraPathMode::APPEND : ExtraPathMode::DEFAULT,
); );
$this->resolver->expects($this->never())->method('resolveEnabledShortUrl'); $this->resolver->expects($this->never())->method('resolveEnabledShortUrl');
$this->requestTracker->expects($this->never())->method('trackIfApplicable'); $this->requestTracker->expects($this->never())->method('trackIfApplicable');
@ -102,12 +104,17 @@ class ExtraPathRedirectMiddlewareTest extends TestCase
]; ];
} }
#[Test, DataProvider('provideResolves')] #[Test]
#[TestWith(['multiSegmentEnabled' => false, 'expectedResolveCalls' => 1])]
#[TestWith(['multiSegmentEnabled' => true, 'expectedResolveCalls' => 3])]
public function handlerIsCalledWhenNoShortUrlIsFoundAfterExpectedAmountOfIterations( public function handlerIsCalledWhenNoShortUrlIsFoundAfterExpectedAmountOfIterations(
bool $multiSegmentEnabled, bool $multiSegmentEnabled,
int $expectedResolveCalls, int $expectedResolveCalls,
): void { ): void {
$options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); $options = new UrlShortenerOptions(
multiSegmentSlugsEnabled: $multiSegmentEnabled,
extraPathMode: ExtraPathMode::APPEND,
);
$type = $this->createMock(NotFoundType::class); $type = $this->createMock(NotFoundType::class);
$type->method('isRegularNotFound')->willReturn(true); $type->method('isRegularNotFound')->willReturn(true);
@ -127,11 +134,15 @@ class ExtraPathRedirectMiddlewareTest extends TestCase
#[Test, DataProvider('provideResolves')] #[Test, DataProvider('provideResolves')]
public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFoundAfterExpectedAmountOfIterations( public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFoundAfterExpectedAmountOfIterations(
ExtraPathMode $extraPathMode,
bool $multiSegmentEnabled, bool $multiSegmentEnabled,
int $expectedResolveCalls, int $expectedResolveCalls,
string|null $expectedExtraPath, string|null $expectedExtraPath,
): void { ): void {
$options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); $options = new UrlShortenerOptions(
multiSegmentSlugsEnabled: $multiSegmentEnabled,
extraPathMode: $extraPathMode,
);
$type = $this->createMock(NotFoundType::class); $type = $this->createMock(NotFoundType::class);
$type->method('isRegularNotFound')->willReturn(true); $type->method('isRegularNotFound')->willReturn(true);
@ -171,8 +182,10 @@ class ExtraPathRedirectMiddlewareTest extends TestCase
public static function provideResolves(): iterable public static function provideResolves(): iterable
{ {
yield [false, 1, '/bar/baz']; yield [ExtraPathMode::APPEND, false, 1, '/bar/baz'];
yield [true, 3, null]; yield [ExtraPathMode::APPEND, true, 3, null];
yield [ExtraPathMode::IGNORE, false, 1, null];
yield [ExtraPathMode::IGNORE, true, 3, null];
} }
private function middleware(UrlShortenerOptions|null $options = null): ExtraPathRedirectMiddleware private function middleware(UrlShortenerOptions|null $options = null): ExtraPathRedirectMiddleware
@ -182,7 +195,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase
$this->requestTracker, $this->requestTracker,
$this->redirectionBuilder, $this->redirectionBuilder,
$this->redirectResponseHelper, $this->redirectResponseHelper,
$options ?? new UrlShortenerOptions(appendExtraPath: true), $options ?? new UrlShortenerOptions(extraPathMode: ExtraPathMode::APPEND),
); );
} }
} }