Add support for short URL mode in installer, and handle loosely mode in custom slugs

This commit is contained in:
Alejandro Celaya 2023-01-28 10:06:11 +01:00
parent 2f83e90c8b
commit fdaf5fb2f3
13 changed files with 76 additions and 30 deletions

View File

@ -50,7 +50,7 @@
"shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4", "shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4",
"shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-event-dispatcher": "^2.6",
"shlinkio/shlink-importer": "^5.0", "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", "shlinkio/shlink-ip-geolocation": "^3.2",
"spiral/roadrunner": "^2.11", "spiral/roadrunner": "^2.11",
"spiral/roadrunner-jobs": "^2.5", "spiral/roadrunner-jobs": "^2.5",

View File

@ -45,6 +45,7 @@ return [
Option\UrlShortener\AppendExtraPathConfigOption::class, Option\UrlShortener\AppendExtraPathConfigOption::class,
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class, Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
Option\UrlShortener\EnableTrailingSlashConfigOption::class, Option\UrlShortener\EnableTrailingSlashConfigOption::class,
Option\UrlShortener\ShortUrlModeConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class, Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class, Option\Tracking\DisableTrackParamConfigOption::class,

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
@ -175,8 +174,7 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::TAGS => $tags,
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, ], $this->options));
], $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

@ -21,4 +21,9 @@ final class UrlShortenerOptions
public readonly ShortUrlMode $mode = ShortUrlMode::STRICT, public readonly ShortUrlMode $mode = ShortUrlMode::STRICT,
) { ) {
} }
public function isLooselyMode(): bool
{
return $this->mode === ShortUrlMode::LOOSELY;
}
} }

View File

@ -63,6 +63,9 @@ class ShortUrl extends AbstractEntity
{ {
} }
/**
* @internal
*/
public static function createFake(): self public static function createFake(): self
{ {
return self::withLongUrl('foo'); return self::withLongUrl('foo');
@ -70,6 +73,7 @@ class ShortUrl extends AbstractEntity
/** /**
* @param non-empty-string $longUrl * @param non-empty-string $longUrl
* @internal
*/ */
public static function withLongUrl(string $longUrl): self public static function withLongUrl(string $longUrl): self
{ {

View File

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -48,9 +49,10 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
public static function fromRawData(array $data, ShortUrlMode $mode = ShortUrlMode::STRICT): 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()) { if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter); throw ValidationException::fromInputFilter($inputFilter);
} }
@ -61,7 +63,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, shortUrlMode: $options->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)),

View File

@ -0,0 +1,34 @@
<?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;
if ($this->options->multiSegmentSlugsEnabled) {
return trim(str_replace(' ', '-', $value), '/');
}
return str_replace([' ', '/'], '-', $value);
}
}

View File

@ -9,16 +9,17 @@ use Laminas\Filter;
use Laminas\InputFilter\InputFilter; use Laminas\InputFilter\InputFilter;
use Laminas\Validator; use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function is_string;
use function str_replace;
use function substr; use function substr;
use function trim;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; 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 class ShortUrlInputFilter extends InputFilter
{ {
use Validation\InputFactoryTrait; use Validation\InputFactoryTrait;
@ -40,23 +41,23 @@ class ShortUrlInputFilter extends InputFilter
public const CRAWLABLE = 'crawlable'; public const CRAWLABLE = 'crawlable';
public const FORWARD_QUERY = 'forwardQuery'; public const FORWARD_QUERY = 'forwardQuery';
private function __construct(array $data, bool $requireLongUrl) private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options)
{ {
$this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); $this->initialize($requireLongUrl, $options);
$this->setData($data); $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 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 = [ $longUrlNotEmptyCommonOptions = [
Validator\NotEmpty::OBJECT, Validator\NotEmpty::OBJECT,
@ -93,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 // 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 // is by using the deprecated setContinueIfEmpty
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) { $customSlug->getFilterChain()->attach(new CustomSlugFilter($options));
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->getValidatorChain()->attach(new Validator\NotEmpty([ $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
Validator\NotEmpty::STRING, Validator\NotEmpty::STRING,
Validator\NotEmpty::SPACE, Validator\NotEmpty::SPACE,

View File

@ -36,7 +36,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode)) ->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode))
->andWhere($qb->expr()->orX( ->andWhere($qb->expr()->orX(
$qb->expr()->isNull('s.domain'), $qb->expr()->isNull('s.domain'),
$qb->expr()->eq('d.authority', ':domain') $qb->expr()->eq('d.authority', ':domain'),
)) ))
->setParameter('domain', $identifier->domain); ->setParameter('domain', $identifier->domain);

View File

@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Model\DeviceType;
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\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
@ -144,7 +145,7 @@ class ShortUrlTest extends TestCase
$allFor = static fn (ShortUrlMode $mode): bool => every($range, static function () use ($mode): bool { $allFor = static fn (ShortUrlMode $mode): bool => every($range, static function () use ($mode): bool {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(
[ShortUrlInputFilter::LONG_URL => 'foo'], [ShortUrlInputFilter::LONG_URL => 'foo'],
$mode, new UrlShortenerOptions(mode: $mode),
)); ));
$shortCode = $shortUrl->getShortCode(); $shortCode = $shortUrl->getShortCode();

View File

@ -6,10 +6,11 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Model;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\DeviceType; 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\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use stdClass; use stdClass;
@ -114,13 +115,13 @@ class ShortUrlCreationTest extends TestCase
string $customSlug, string $customSlug,
string $expectedSlug, string $expectedSlug,
bool $multiSegmentEnabled = false, bool $multiSegmentEnabled = false,
ShortUrlMode $shortUrlMode = ShortUrlMode::STRICT,
): void { ): void {
$creation = ShortUrlCreation::fromRawData([ $creation = ShortUrlCreation::fromRawData([
'validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'validSince' => Chronos::parse('2015-01-01')->toAtomString(),
'customSlug' => $customSlug, 'customSlug' => $customSlug,
'longUrl' => 'longUrl', 'longUrl' => 'longUrl',
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled, ], new UrlShortenerOptions(multiSegmentSlugsEnabled: $multiSegmentEnabled, mode: $shortUrlMode));
]);
self::assertTrue($creation->hasValidSince()); self::assertTrue($creation->hasValidSince());
self::assertEquals(Chronos::parse('2015-01-01'), $creation->validSince); self::assertEquals(Chronos::parse('2015-01-01'), $creation->validSince);
@ -139,16 +140,20 @@ class ShortUrlCreationTest extends TestCase
{ {
yield ['🔥', '🔥']; yield ['🔥', '🔥'];
yield ['🦣 🍅', '🦣-🍅']; yield ['🦣 🍅', '🦣-🍅'];
yield ['🦣 🍅', '🦣-🍅', false, ShortUrlMode::LOOSELY];
yield ['foobar', 'foobar']; yield ['foobar', 'foobar'];
yield ['foo bar', 'foo-bar']; 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']; 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]; 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 ['/foo/bar/baz', '-foo-bar-baz']; yield ['/foo/bar/baz', '-foo-bar-baz'];
yield ['wp-admin.php', 'wp-admin.php']; yield ['wp-admin.php', 'wp-admin.php'];
yield ['UPPER_lower', 'UPPER_lower']; yield ['UPPER_lower', 'UPPER_lower'];
yield ['UPPER_lower', 'upper_lower', false, ShortUrlMode::LOOSELY];
yield ['more~url_special.chars', 'more~url_special.chars']; yield ['more~url_special.chars', 'more~url_special.chars'];
yield ['구글', '구글']; yield ['구글', '구글'];
yield ['グーグル', 'グーグル']; yield ['グーグル', 'グーグル'];

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl; namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
@ -23,8 +22,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
{ {
$payload = (array) $request->getParsedBody(); $payload = (array) $request->getParsedBody();
$payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request);
$payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled;
return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions->mode); return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions);
} }
} }

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); ], $this->urlShortenerOptions);
} }
} }