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);
}
}