mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-26 23:18:37 +03:00
Add two modes for short URLs
This commit is contained in:
parent
87007677ed
commit
05acd4ae88
15 changed files with 68 additions and 17 deletions
|
@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -176,7 +176,7 @@ class CreateShortUrlCommand extends Command
|
|||
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
|
||||
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
|
||||
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled,
|
||||
]));
|
||||
], $this->options->mode));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
|
|
|
@ -137,7 +137,7 @@ return [
|
|||
ShortUrl\ShortUrlResolver::class,
|
||||
],
|
||||
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'],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,7 @@ final class UrlShortenerOptions
|
|||
public readonly bool $appendExtraPath = false,
|
||||
public readonly bool $multiSegmentSlugsEnabled = false,
|
||||
public readonly bool $trailingSlashEnabled = false,
|
||||
public readonly ShortUrlMode $mode = ShortUrlMode::STRICT,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
@ -95,7 +96,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 +296,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 +308,7 @@ class ShortUrl extends AbstractEntity
|
|||
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
|
||||
}
|
||||
|
||||
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
|
||||
$this->shortCode = generateRandomShortCode($this->shortCodeLength, $mode);
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,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,7 +48,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
|
|||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function fromRawData(array $data): self
|
||||
public static function fromRawData(array $data, ShortUrlMode $mode = ShortUrlMode::STRICT): self
|
||||
{
|
||||
$inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data);
|
||||
if (! $inputFilter->isValid()) {
|
||||
|
@ -60,6 +61,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
|
|||
|
||||
return new self(
|
||||
longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
|
||||
shortUrlMode: $mode,
|
||||
deviceLongUrls: $deviceLongUrls,
|
||||
validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
|
||||
validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
|
||||
|
@ -84,6 +86,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
|
|||
{
|
||||
return new self(
|
||||
longUrl: $this->longUrl,
|
||||
shortUrlMode: $this->shortUrlMode,
|
||||
deviceLongUrls: $this->deviceLongUrls,
|
||||
validSince: $this->validSince,
|
||||
validUntil: $this->validUntil,
|
||||
|
|
9
module/Core/src/ShortUrl/Model/ShortUrlMode.php
Normal file
9
module/Core/src/ShortUrl/Model/ShortUrlMode.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
||||
|
||||
enum ShortUrlMode: string
|
||||
{
|
||||
case STRICT = 'strict';
|
||||
case LOOSELY = 'loosely';
|
||||
}
|
|
@ -42,7 +42,6 @@ class ShortUrlInputFilter extends InputFilter
|
|||
|
||||
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->setData($data);
|
||||
}
|
||||
|
|
|
@ -11,13 +11,16 @@ use Shlinkio\Shlink\Core\Model\DeviceType;
|
|||
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 +37,7 @@ class ShortUrlTest extends TestCase
|
|||
$this->expectException(ShortCodeCannotBeRegeneratedException::class);
|
||||
$this->expectExceptionMessage($expectedMessage);
|
||||
|
||||
$shortUrl->regenerateShortCode();
|
||||
$shortUrl->regenerateShortCode(ShortUrlMode::STRICT);
|
||||
}
|
||||
|
||||
public function provideInvalidShortUrls(): iterable
|
||||
|
@ -58,7 +61,7 @@ class ShortUrlTest extends TestCase
|
|||
): void {
|
||||
$firstShortCode = $shortUrl->getShortCode();
|
||||
|
||||
$shortUrl->regenerateShortCode();
|
||||
$shortUrl->regenerateShortCode(ShortUrlMode::STRICT);
|
||||
$secondShortCode = $shortUrl->getShortCode();
|
||||
|
||||
self::assertNotEquals($firstShortCode, $secondShortCode);
|
||||
|
@ -133,4 +136,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'],
|
||||
$mode,
|
||||
));
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
|
||||
return $shortCode === strtolower($shortCode);
|
||||
});
|
||||
|
||||
self::assertTrue($allFor(ShortUrlMode::LOOSELY));
|
||||
self::assertFalse($allFor(ShortUrlMode::STRICT));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -25,6 +25,6 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
|
|||
$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->mode);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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->mode);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue