Merge pull request #1680 from acelaya-forks/feature/loosly-mode

Feature/loosly mode
This commit is contained in:
Alejandro Celaya 2023-01-28 10:36:19 +01:00 committed by GitHub
commit b3a2ceedea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 207 additions and 73 deletions

View file

@ -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*

View file

@ -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",

View file

@ -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,

View file

@ -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,
],
];

View file

@ -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: <info>%s</info>', $longUrl),

View file

@ -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],

View file

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

View file

@ -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';

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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,

View file

@ -0,0 +1,9 @@
<?php
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
enum ShortUrlMode: string
{
case STRICT = 'strict';
case LOOSELY = 'loosely';
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
use Laminas\Filter\FilterInterface;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use function is_string;
use function str_replace;
use function strtolower;
use function trim;
class CustomSlugFilter implements FilterInterface
{
public function __construct(private readonly UrlShortenerOptions $options)
{
}
public function filter(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
$value = $this->options->isLooselyMode() ? strtolower($value) : $value;
return (match ($this->options->multiSegmentSlugsEnabled) {
true => trim(str_replace(' ', '-', $value), '/'),
false => str_replace([' ', '/'], '-', $value),
});
}
}

View file

@ -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,

View file

@ -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 = <<<DQL
SELECT s
FROM Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl AS s
LEFT JOIN s.domain AS d
WHERE s.shortCode = :shortCode
AND (s.domain IS NULL OR d.authority = :domain)
ORDER BY s.domain {$ordering}
DQL;
$qb = $this->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

View file

@ -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;

View file

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

View file

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

View file

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

View file

@ -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');

View file

@ -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 ['グーグル', 'グーグル'];

View file

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

View file

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

View file

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