Add two modes for short URLs

This commit is contained in:
Alejandro Celaya 2023-01-25 20:33:07 +01:00
parent 87007677ed
commit 05acd4ae88
15 changed files with 68 additions and 17 deletions

View file

@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars; 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\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\MIN_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), (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH),
MIN_SHORT_CODES_LENGTH, MIN_SHORT_CODES_LENGTH,
); );
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
$mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT;
return [ return [
@ -25,6 +28,7 @@ return (static function (): array {
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false),
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false),
'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false), 'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false),
'mode' => $mode,
], ],
]; ];

View file

@ -176,7 +176,7 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled, EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled,
])); ], $this->options->mode));
$io->writeln([ $io->writeln([
sprintf('Processed long URL: <info>%s</info>', $longUrl), sprintf('Processed long URL: <info>%s</info>', $longUrl),

View file

@ -137,7 +137,7 @@ return [
ShortUrl\ShortUrlResolver::class, ShortUrl\ShortUrlResolver::class,
], ],
ShortUrl\ShortUrlResolver::class => ['em'], ShortUrl\ShortUrlResolver::class => ['em'],
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em'], ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class],
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],

View file

@ -15,6 +15,7 @@ use Laminas\Filter\Word\CamelCaseToUnderscore;
use Laminas\InputFilter\InputFilter; use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory; use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use function date_default_timezone_get; use function date_default_timezone_get;
use function Functional\map; use function Functional\map;
@ -27,14 +28,16 @@ use function str_repeat;
use function strtolower; use function strtolower;
use function ucfirst; use function ucfirst;
function generateRandomShortCode(int $length): string function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
{ {
static $shortIdFactory; static $shortIdFactory;
if ($shortIdFactory === null) { if ($shortIdFactory === null) {
$shortIdFactory = new ShortIdFactory(); $shortIdFactory = new ShortIdFactory();
} }
$alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; $alphabet = $mode === ShortUrlMode::STRICT
? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
: '0123456789abcdefghijklmnopqrstuvwxyz';
return $shortIdFactory->generate($length, $alphabet)->serialize(); return $shortIdFactory->generate($length, $alphabet)->serialize();
} }

View file

@ -43,6 +43,7 @@ enum EnvVars: string
case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME';
case BASE_PATH = 'BASE_PATH'; case BASE_PATH = 'BASE_PATH';
case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH'; case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH';
case SHORT_URL_MODE = 'SHORT_URL_MODE';
case PORT = 'PORT'; case PORT = 'PORT';
case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; case TASK_WORKER_NUM = 'TASK_WORKER_NUM';
case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; case WEB_WORKER_NUM = 'WEB_WORKER_NUM';

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options; namespace Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
final class UrlShortenerOptions final class UrlShortenerOptions
@ -16,6 +18,7 @@ final class UrlShortenerOptions
public readonly bool $appendExtraPath = false, public readonly bool $appendExtraPath = false,
public readonly bool $multiSegmentSlugsEnabled = false, public readonly bool $multiSegmentSlugsEnabled = false,
public readonly bool $trailingSlashEnabled = false, public readonly bool $trailingSlashEnabled = false,
public readonly ShortUrlMode $mode = ShortUrlMode::STRICT,
) { ) {
} }
} }

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\DeviceLongUrlPair;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; 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\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
@ -95,7 +96,10 @@ class ShortUrl extends AbstractEntity
$instance->maxVisits = $creation->maxVisits; $instance->maxVisits = $creation->maxVisits;
$instance->customSlugWasProvided = $creation->hasCustomSlug(); $instance->customSlugWasProvided = $creation->hasCustomSlug();
$instance->shortCodeLength = $creation->shortCodeLength; $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->domain = $relationResolver->resolveDomain($creation->domain);
$instance->authorApiKey = $creation->apiKey; $instance->authorApiKey = $creation->apiKey;
$instance->title = $creation->title; $instance->title = $creation->title;
@ -292,7 +296,7 @@ class ShortUrl extends AbstractEntity
/** /**
* @throws ShortCodeCannotBeRegeneratedException * @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 // In ShortUrls where a custom slug was provided, throw error, unless it is an imported one
if ($this->customSlugWasProvided && $this->importSource === null) { if ($this->customSlugWasProvided && $this->importSource === null) {
@ -304,7 +308,7 @@ class ShortUrl extends AbstractEntity
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted(); throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
} }
$this->shortCode = generateRandomShortCode($this->shortCodeLength); $this->shortCode = generateRandomShortCode($this->shortCodeLength, $mode);
} }
public function isEnabled(): bool public function isEnabled(): bool

View file

@ -5,14 +5,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper; namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository;
class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface 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 public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool
@ -29,7 +32,7 @@ class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface
return false; return false;
} }
$shortUrlToBeCreated->regenerateShortCode(); $shortUrlToBeCreated->regenerateShortCode($this->options->mode);
return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug); return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug);
} }
} }

View file

@ -25,6 +25,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
*/ */
private function __construct( private function __construct(
public readonly string $longUrl, public readonly string $longUrl,
public readonly ShortUrlMode $shortUrlMode,
public readonly array $deviceLongUrls = [], public readonly array $deviceLongUrls = [],
public readonly ?Chronos $validSince = null, public readonly ?Chronos $validSince = null,
public readonly ?Chronos $validUntil = null, public readonly ?Chronos $validUntil = null,
@ -47,7 +48,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
public static function fromRawData(array $data): self public static function fromRawData(array $data, ShortUrlMode $mode = ShortUrlMode::STRICT): self
{ {
$inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data); $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data);
if (! $inputFilter->isValid()) { if (! $inputFilter->isValid()) {
@ -60,6 +61,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
return new self( return new self(
longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
shortUrlMode: $mode,
deviceLongUrls: $deviceLongUrls, deviceLongUrls: $deviceLongUrls,
validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
@ -84,6 +86,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
{ {
return new self( return new self(
longUrl: $this->longUrl, longUrl: $this->longUrl,
shortUrlMode: $this->shortUrlMode,
deviceLongUrls: $this->deviceLongUrls, deviceLongUrls: $this->deviceLongUrls,
validSince: $this->validSince, validSince: $this->validSince,
validUntil: $this->validUntil, 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

@ -42,7 +42,6 @@ class ShortUrlInputFilter extends InputFilter
private function __construct(array $data, bool $requireLongUrl) private function __construct(array $data, bool $requireLongUrl)
{ {
// FIXME The multi-segment slug option should be injected
$this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); $this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false);
$this->setData($data); $this->setData($data);
} }

View file

@ -11,13 +11,16 @@ use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; 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\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Sources\ImportSource; use Shlinkio\Shlink\Importer\Sources\ImportSource;
use function Functional\every;
use function Functional\map; use function Functional\map;
use function range; use function range;
use function strlen; use function strlen;
use function strtolower;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
@ -34,7 +37,7 @@ class ShortUrlTest extends TestCase
$this->expectException(ShortCodeCannotBeRegeneratedException::class); $this->expectException(ShortCodeCannotBeRegeneratedException::class);
$this->expectExceptionMessage($expectedMessage); $this->expectExceptionMessage($expectedMessage);
$shortUrl->regenerateShortCode(); $shortUrl->regenerateShortCode(ShortUrlMode::STRICT);
} }
public function provideInvalidShortUrls(): iterable public function provideInvalidShortUrls(): iterable
@ -58,7 +61,7 @@ class ShortUrlTest extends TestCase
): void { ): void {
$firstShortCode = $shortUrl->getShortCode(); $firstShortCode = $shortUrl->getShortCode();
$shortUrl->regenerateShortCode(); $shortUrl->regenerateShortCode(ShortUrlMode::STRICT);
$secondShortCode = $shortUrl->getShortCode(); $secondShortCode = $shortUrl->getShortCode();
self::assertNotEquals($firstShortCode, $secondShortCode); self::assertNotEquals($firstShortCode, $secondShortCode);
@ -133,4 +136,22 @@ class ShortUrlTest extends TestCase
DeviceType::DESKTOP->value => 'desktop', DeviceType::DESKTOP->value => 'desktop',
], $shortUrl->deviceLongUrls()); ], $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'],
$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\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; 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\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelper; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelper;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
@ -22,7 +23,7 @@ class ShortCodeUniquenessHelperTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->em = $this->createMock(EntityManagerInterface::class); $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 = $this->createMock(ShortUrl::class);
$this->shortUrl->method('getShortCode')->willReturn('abc123'); $this->shortUrl->method('getShortCode')->willReturn('abc123');

View file

@ -25,6 +25,6 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
$payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request);
$payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled; $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled;
return ShortUrlCreation::fromRawData($payload); return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions->mode);
} }
} }

View file

@ -25,6 +25,6 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
ShortUrlInputFilter::API_KEY => $apiKey, ShortUrlInputFilter::API_KEY => $apiKey,
// This will usually be null, unless this API key enforces one specific domain // This will usually be null, unless this API key enforces one specific domain
ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN), ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN),
]); ], $this->urlShortenerOptions->mode);
} }
} }