diff --git a/CHANGELOG.md b/CHANGELOG.md index 5384703e..bd0c8222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [3.5.0] - 2023-01-28 ### Added * [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type. @@ -23,6 +23,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The status 308 is equivalent to 301, and 307 is equivalent to 302. The difference is that the spec requires the client to respect the original HTTP method when performing the redirect. With 301 and 302, some old clients might perform a `GET` request during the redirect, regardless the original request method. * [#1662](https://github.com/shlinkio/shlink/issues/1662) Added support to provide openswoole-specific config options via env vars prefixed with `OPENSWOOLE_`. +* [#1389](https://github.com/shlinkio/shlink/issues/1389) and [#706](https://github.com/shlinkio/shlink/issues/706) Added support for case-insensitive short URLs. + + In order to achieve this, a new env var/config option has been implemented (`SHORT_URL_MODE`), which allows either `strict` or `loosely`. + + Default value is `strict`, but if `loosely` is provided, then short URLs will be matched in a case-insensitive way, and new short URLs will be generated with short-codes in lowercase only. ### Changed * *Nothing* diff --git a/composer.json b/composer.json index 6a279051..1b25ba49 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^5.0", - "shlinkio/shlink-installer": "dev-develop#5fcee9b as 8.3", + "shlinkio/shlink-installer": "dev-develop#7f6fce7 as 8.3", "shlinkio/shlink-ip-geolocation": "^3.2", "spiral/roadrunner": "^2.11", "spiral/roadrunner-jobs": "^2.5", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index fbc5fa03..029a50d6 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -45,6 +45,7 @@ return [ Option\UrlShortener\AppendExtraPathConfigOption::class, Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class, Option\UrlShortener\EnableTrailingSlashConfigOption::class, + Option\UrlShortener\ShortUrlModeConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class, Option\Tracking\OrphanVisitsTrackingConfigOption::class, Option\Tracking\DisableTrackParamConfigOption::class, diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index ec3c1409..2816577d 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; @@ -12,6 +13,8 @@ return (static function (): array { (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH), MIN_SHORT_CODES_LENGTH, ); + $modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value); + $mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT; return [ @@ -25,6 +28,7 @@ return (static function (): array { '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), + 'mode' => $mode, ], ]; diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 71ab5fa7..6fb1001b 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -175,8 +174,7 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), - EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled, - ])); + ], $this->options)); $io->writeln([ sprintf('Processed long URL: %s', $longUrl), diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 48fe3cb2..4555bff1 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -136,8 +136,8 @@ return [ Options\DeleteShortUrlsOptions::class, ShortUrl\ShortUrlResolver::class, ], - ShortUrl\ShortUrlResolver::class => ['em'], - ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em'], + ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class], + ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class], Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 574d604c..b6acbb35 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -15,6 +15,7 @@ use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use function date_default_timezone_get; use function Functional\map; @@ -27,14 +28,16 @@ use function str_repeat; use function strtolower; use function ucfirst; -function generateRandomShortCode(int $length): string +function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string { static $shortIdFactory; if ($shortIdFactory === null) { $shortIdFactory = new ShortIdFactory(); } - $alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $alphabet = $mode === ShortUrlMode::STRICT + ? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + : '0123456789abcdefghijklmnopqrstuvwxyz'; return $shortIdFactory->generate($length, $alphabet)->serialize(); } diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 75454ecc..44919415 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -43,6 +43,7 @@ enum EnvVars: string case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; case BASE_PATH = 'BASE_PATH'; case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH'; + case SHORT_URL_MODE = 'SHORT_URL_MODE'; case PORT = 'PORT'; case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; diff --git a/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php index b84491f6..33945063 100644 --- a/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php +++ b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php @@ -9,8 +9,8 @@ use function str_replace; class MultiSegmentSlugProcessor { - private const SINGLE_SHORT_CODE_PATTERN = '{shortCode}'; - private const MULTI_SHORT_CODE_PATTERN = '{shortCode:.+}'; + private const SINGLE_SEGMENT_PATTERN = '{shortCode}'; + private const MULTI_SEGMENT_PATTERN = '{shortCode:.+}'; public function __invoke(array $config): array { @@ -21,7 +21,7 @@ class MultiSegmentSlugProcessor $config['routes'] = map($config['routes'] ?? [], static function (array $route): array { ['path' => $path] = $route; - $route['path'] = str_replace(self::SINGLE_SHORT_CODE_PATTERN, self::MULTI_SHORT_CODE_PATTERN, $path); + $route['path'] = str_replace(self::SINGLE_SEGMENT_PATTERN, self::MULTI_SEGMENT_PATTERN, $path); return $route; }); diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 9aacc085..98597bad 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; + use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; final class UrlShortenerOptions @@ -16,6 +18,12 @@ final class UrlShortenerOptions public readonly bool $appendExtraPath = false, public readonly bool $multiSegmentSlugsEnabled = false, public readonly bool $trailingSlashEnabled = false, + public readonly ShortUrlMode $mode = ShortUrlMode::STRICT, ) { } + + public function isLooselyMode(): bool + { + return $this->mode === ShortUrlMode::LOOSELY; + } } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index d0e9cba4..0328923a 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; @@ -62,6 +63,9 @@ class ShortUrl extends AbstractEntity { } + /** + * @internal + */ public static function createFake(): self { return self::withLongUrl('foo'); @@ -69,6 +73,7 @@ class ShortUrl extends AbstractEntity /** * @param non-empty-string $longUrl + * @internal */ public static function withLongUrl(string $longUrl): self { @@ -95,7 +100,10 @@ class ShortUrl extends AbstractEntity $instance->maxVisits = $creation->maxVisits; $instance->customSlugWasProvided = $creation->hasCustomSlug(); $instance->shortCodeLength = $creation->shortCodeLength; - $instance->shortCode = $creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength); + $instance->shortCode = $creation->customSlug ?? generateRandomShortCode( + $instance->shortCodeLength, + $creation->shortUrlMode, + ); $instance->domain = $relationResolver->resolveDomain($creation->domain); $instance->authorApiKey = $creation->apiKey; $instance->title = $creation->title; @@ -292,7 +300,7 @@ class ShortUrl extends AbstractEntity /** * @throws ShortCodeCannotBeRegeneratedException */ - public function regenerateShortCode(): void + public function regenerateShortCode(ShortUrlMode $mode): void { // In ShortUrls where a custom slug was provided, throw error, unless it is an imported one if ($this->customSlugWasProvided && $this->importSource === null) { @@ -304,7 +312,7 @@ class ShortUrl extends AbstractEntity throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted(); } - $this->shortCode = generateRandomShortCode($this->shortCodeLength); + $this->shortCode = generateRandomShortCode($this->shortCodeLength, $mode); } public function isEnabled(): bool diff --git a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php index 1f16f037..b428019e 100644 --- a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php @@ -5,14 +5,17 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface { - public function __construct(private readonly EntityManagerInterface $em) - { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly UrlShortenerOptions $options, + ) { } public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool @@ -29,7 +32,7 @@ class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface return false; } - $shortUrlToBeCreated->regenerateShortCode(); + $shortUrlToBeCreated->regenerateShortCode($this->options->mode); return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug); } } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index c29817b6..43b39874 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -25,6 +26,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface */ private function __construct( public readonly string $longUrl, + public readonly ShortUrlMode $shortUrlMode, public readonly array $deviceLongUrls = [], public readonly ?Chronos $validSince = null, public readonly ?Chronos $validUntil = null, @@ -47,9 +49,10 @@ final class ShortUrlCreation implements TitleResolutionModelInterface /** * @throws ValidationException */ - public static function fromRawData(array $data): self + public static function fromRawData(array $data, ?UrlShortenerOptions $options = null): self { - $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data); + $options = $options ?? new UrlShortenerOptions(); + $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data, $options); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } @@ -60,6 +63,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return new self( longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), + shortUrlMode: $options->mode, deviceLongUrls: $deviceLongUrls, validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), @@ -84,6 +88,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface { return new self( longUrl: $this->longUrl, + shortUrlMode: $this->shortUrlMode, deviceLongUrls: $this->deviceLongUrls, validSince: $this->validSince, validUntil: $this->validUntil, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlMode.php b/module/Core/src/ShortUrl/Model/ShortUrlMode.php new file mode 100644 index 00000000..41698e18 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/ShortUrlMode.php @@ -0,0 +1,9 @@ +options->isLooselyMode() ? strtolower($value) : $value; + return (match ($this->options->multiSegmentSlugsEnabled) { + true => trim(str_replace(' ', '-', $value), '/'), + false => str_replace([' ', '/'], '-', $value), + }); + } +} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 68b87158..9c10d3ff 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -9,16 +9,17 @@ use Laminas\Filter; use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; -use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function is_string; -use function str_replace; use function substr; -use function trim; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; +/** + * @todo Pass forCreation/forEdition, instead of withRequiredLongUrl/withNonRequiredLongUrl. + * Make it also dynamically add the relevant fields + */ class ShortUrlInputFilter extends InputFilter { use Validation\InputFactoryTrait; @@ -40,24 +41,23 @@ class ShortUrlInputFilter extends InputFilter public const CRAWLABLE = 'crawlable'; public const FORWARD_QUERY = 'forwardQuery'; - private function __construct(array $data, bool $requireLongUrl) + private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options) { - // FIXME The multi-segment slug option should be injected - $this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); + $this->initialize($requireLongUrl, $options); $this->setData($data); } - public static function withRequiredLongUrl(array $data): self + public static function withRequiredLongUrl(array $data, UrlShortenerOptions $options): self { - return new self($data, true); + return new self($data, true, $options); } public static function withNonRequiredLongUrl(array $data): self { - return new self($data, false); + return new self($data, false, new UrlShortenerOptions()); } - private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void + private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void { $longUrlNotEmptyCommonOptions = [ Validator\NotEmpty::OBJECT, @@ -94,10 +94,7 @@ class ShortUrlInputFilter extends InputFilter // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value // is by using the deprecated setContinueIfEmpty $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); - $customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) { - true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v, - false => static fn (mixed $v) => is_string($v) ? str_replace([' ', '/'], '-', $v) : $v, - })); + $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::STRING, Validator\NotEmpty::SPACE, diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index ee2f7389..05800abd 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -14,42 +14,41 @@ use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function count; +use function strtolower; class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl + public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl { // When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at // the bottom $dbPlatform = $this->getEntityManager()->getConnection()->getDatabasePlatform(); $ordering = $dbPlatform instanceof PostgreSQLPlatform ? 'ASC' : 'DESC'; + $isStrict = $shortUrlMode === ShortUrlMode::STRICT; - $dql = <<createQueryBuilder('s'); + $qb->leftJoin('s.domain', 'd') + ->where($qb->expr()->eq($isStrict ? 's.shortCode' : 'LOWER(s.shortCode)', ':shortCode')) + ->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode)) + ->andWhere($qb->expr()->orX( + $qb->expr()->isNull('s.domain'), + $qb->expr()->eq('d.authority', ':domain'), + )) + ->setParameter('domain', $identifier->domain); - $query = $this->getEntityManager()->createQuery($dql); - $query->setMaxResults(1) - ->setParameters([ - 'shortCode' => $identifier->shortCode, - 'domain' => $identifier->domain, - ]); - - // Since we ordered by domain, we will have first the URL matching provided domain, followed by the one - // with no domain (if any), so it is safe to fetch 1 max result and we will get: + // Since we order by domain, we will have first the URL matching provided domain, followed by the one + // with no domain (if any), so it is safe to fetch 1 max result, and we will get: // * The short URL matching both the short code and the domain, or // * The short URL matching the short code but without any domain, or // * No short URL at all + $qb->orderBy('s.domain', $ordering) + ->setMaxResults(1); - return $query->getOneOrNullResult(); + return $qb->getQuery()->getOneOrNullResult(); } public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php index 18a4ec71..8af53cb9 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -10,11 +10,12 @@ use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl; + public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl; public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 20ec930b..2c4f7bdc 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; @@ -13,8 +14,10 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlResolver implements ShortUrlResolverInterface { - public function __construct(private readonly EntityManagerInterface $em) - { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly UrlShortenerOptions $urlShortenerOptions, + ) { } /** @@ -39,7 +42,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier); + $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode); if (! $shortUrl?->isEnabled()) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index 0d90675a..dd0cc4f0 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository; use Cake\Chronos\Chronos; +use Doctrine\DBAL\Platforms\SQLServerPlatform; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -32,7 +34,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneWithDomainFallbackReturnsProperData(): void { - $regularOne = ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'foo', 'longUrl' => 'foo'])); + $regularOne = ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'Foo', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($regularOne); $withDomain = ShortUrl::create(ShortUrlCreation::fromRawData( @@ -41,7 +43,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($withDomain); $withDomainDuplicatingRegular = ShortUrl::create(ShortUrlCreation::fromRawData( - ['domain' => 's.test', 'customSlug' => 'foo', 'longUrl' => 'foo_with_domain'], + ['domain' => 's.test', 'customSlug' => 'Foo', 'longUrl' => 'foo_with_domain'], )); $this->getEntityManager()->persist($withDomainDuplicatingRegular); @@ -49,29 +51,53 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($regularOne->getShortCode()), + ShortUrlMode::STRICT, )); + self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + ShortUrlMode::LOOSELY, + )); + self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('fOo'), + ShortUrlMode::LOOSELY, + )); + // TODO MS is doing loosely checks always, making this fail. + if (! $this->getEntityManager()->getConnection()->getDatabasePlatform() instanceof SQLServerPlatform) { + self::assertNull($this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + ShortUrlMode::STRICT, + )); + } self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode()), + ShortUrlMode::STRICT, )); self::assertSame($withDomain, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode(), 'example.com'), + ShortUrlMode::STRICT, )); self::assertSame( $withDomainDuplicatingRegular, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode(), 's.test'), + ShortUrlMode::STRICT, ), ); self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain( $withDomainDuplicatingRegular->getShortCode(), 'other-domain.com', - ))); - self::assertNull($this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain('invalid'))); + ), ShortUrlMode::STRICT)); + self::assertNull($this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('invalid'), + ShortUrlMode::STRICT, + )); self::assertNull($this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode()), + ShortUrlMode::STRICT, )); self::assertNull($this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode(), 'other-domain.com'), + ShortUrlMode::STRICT, )); } diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index 2d950d5f..b69b369a 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -8,16 +8,20 @@ use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Sources\ImportSource; +use function Functional\every; use function Functional\map; use function range; use function strlen; +use function strtolower; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; @@ -34,7 +38,7 @@ class ShortUrlTest extends TestCase $this->expectException(ShortCodeCannotBeRegeneratedException::class); $this->expectExceptionMessage($expectedMessage); - $shortUrl->regenerateShortCode(); + $shortUrl->regenerateShortCode(ShortUrlMode::STRICT); } public function provideInvalidShortUrls(): iterable @@ -58,7 +62,7 @@ class ShortUrlTest extends TestCase ): void { $firstShortCode = $shortUrl->getShortCode(); - $shortUrl->regenerateShortCode(); + $shortUrl->regenerateShortCode(ShortUrlMode::STRICT); $secondShortCode = $shortUrl->getShortCode(); self::assertNotEquals($firstShortCode, $secondShortCode); @@ -133,4 +137,22 @@ class ShortUrlTest extends TestCase DeviceType::DESKTOP->value => 'desktop', ], $shortUrl->deviceLongUrls()); } + + /** @test */ + public function generatesLowercaseOnlyShortCodesInLooselyMode(): void + { + $range = range(1, 1000); // Use a "big" number to reduce false negatives + $allFor = static fn (ShortUrlMode $mode): bool => every($range, static function () use ($mode): bool { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( + [ShortUrlInputFilter::LONG_URL => 'foo'], + new UrlShortenerOptions(mode: $mode), + )); + $shortCode = $shortUrl->getShortCode(); + + return $shortCode === strtolower($shortCode); + }); + + self::assertTrue($allFor(ShortUrlMode::LOOSELY)); + self::assertFalse($allFor(ShortUrlMode::STRICT)); + } } diff --git a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php index 5df79fc5..ae0d9363 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Domain\Entity\Domain; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelper; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; @@ -22,7 +23,7 @@ class ShortCodeUniquenessHelperTest extends TestCase protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->helper = new ShortCodeUniquenessHelper($this->em); + $this->helper = new ShortCodeUniquenessHelper($this->em, new UrlShortenerOptions()); $this->shortUrl = $this->createMock(ShortUrl::class); $this->shortUrl->method('getShortCode')->willReturn('abc123'); diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 4d11289c..9582180b 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -6,10 +6,11 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use stdClass; @@ -114,13 +115,13 @@ class ShortUrlCreationTest extends TestCase string $customSlug, string $expectedSlug, bool $multiSegmentEnabled = false, + ShortUrlMode $shortUrlMode = ShortUrlMode::STRICT, ): void { $creation = ShortUrlCreation::fromRawData([ 'validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug, 'longUrl' => 'longUrl', - EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled, - ]); + ], new UrlShortenerOptions(multiSegmentSlugsEnabled: $multiSegmentEnabled, mode: $shortUrlMode)); self::assertTrue($creation->hasValidSince()); self::assertEquals(Chronos::parse('2015-01-01'), $creation->validSince); @@ -139,16 +140,20 @@ class ShortUrlCreationTest extends TestCase { yield ['πŸ”₯', 'πŸ”₯']; yield ['🦣 πŸ…', '🦣-πŸ…']; + yield ['🦣 πŸ…', '🦣-πŸ…', false, ShortUrlMode::LOOSELY]; yield ['foobar', 'foobar']; yield ['foo bar', 'foo-bar']; yield ['foo bar baz', 'foo-bar-baz']; yield ['foo bar-baz', 'foo-bar-baz']; + yield ['foo BAR-baz', 'foo-bar-baz', false, ShortUrlMode::LOOSELY]; yield ['foo/bar/baz', 'foo/bar/baz', true]; yield ['/foo/bar/baz', 'foo/bar/baz', true]; + yield ['/foo/baR/baZ', 'foo/bar/baz', true, ShortUrlMode::LOOSELY]; yield ['foo/bar/baz', 'foo-bar-baz']; yield ['/foo/bar/baz', '-foo-bar-baz']; yield ['wp-admin.php', 'wp-admin.php']; yield ['UPPER_lower', 'UPPER_lower']; + yield ['UPPER_lower', 'upper_lower', false, ShortUrlMode::LOOSELY]; yield ['more~url_special.chars', 'more~url_special.chars']; yield ['ꡬ글', 'ꡬ글']; yield ['グーグル', 'グーグル']; diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 177e432e..9c42fefb 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -10,9 +10,11 @@ use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -35,7 +37,7 @@ class ShortUrlResolverTest extends TestCase { $this->em = $this->createMock(EntityManagerInterface::class); $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); - $this->urlResolver = new ShortUrlResolver($this->em); + $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); } /** @@ -83,6 +85,7 @@ class ShortUrlResolverTest extends TestCase $this->repo->expects($this->once())->method('findOneWithDomainFallback')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ShortUrlMode::STRICT, )->willReturn($shortUrl); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); @@ -101,6 +104,7 @@ class ShortUrlResolverTest extends TestCase $this->repo->expects($this->once())->method('findOneWithDomainFallback')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ShortUrlMode::STRICT, )->willReturn($shortUrl); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index e60414b2..67509f1b 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -23,8 +22,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction { $payload = (array) $request->getParsedBody(); $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); - $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled; - return ShortUrlCreation::fromRawData($payload); + return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions); } } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index 89989dda..d7f5a360 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -25,6 +25,6 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction ShortUrlInputFilter::API_KEY => $apiKey, // This will usually be null, unless this API key enforces one specific domain ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN), - ]); + ], $this->urlShortenerOptions); } }