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/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 46a1cc8f..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->mode)); + ], $this->options)); $io->writeln([ sprintf('Processed long URL: %s', $longUrl), diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 827988fa..98597bad 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -21,4 +21,9 @@ final class UrlShortenerOptions 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 e3d6544c..0328923a 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -63,6 +63,9 @@ class ShortUrl extends AbstractEntity { } + /** + * @internal + */ public static function createFake(): self { return self::withLongUrl('foo'); @@ -70,6 +73,7 @@ class ShortUrl extends AbstractEntity /** * @param non-empty-string $longUrl + * @internal */ public static function withLongUrl(string $longUrl): self { diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index d0d9b279..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; @@ -48,9 +49,10 @@ final class ShortUrlCreation implements TitleResolutionModelInterface /** * @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()) { throw ValidationException::fromInputFilter($inputFilter); } @@ -61,7 +63,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return new self( longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), - shortUrlMode: $mode, + shortUrlMode: $options->mode, deviceLongUrls: $deviceLongUrls, validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php new file mode 100644 index 00000000..8355a003 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php @@ -0,0 +1,34 @@ +options->isLooselyMode() ? strtolower($value) : $value; + if ($this->options->multiSegmentSlugsEnabled) { + return trim(str_replace(' ', '-', $value), '/'); + } + + return str_replace([' ', '/'], '-', $value); + } +} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 020cdfd2..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,23 +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) { - $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, @@ -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 // 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 f8e384e2..05800abd 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -36,7 +36,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode)) ->andWhere($qb->expr()->orX( $qb->expr()->isNull('s.domain'), - $qb->expr()->eq('d.authority', ':domain') + $qb->expr()->eq('d.authority', ':domain'), )) ->setParameter('domain', $identifier->domain); diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index be1747fd..b69b369a 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -8,6 +8,7 @@ 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; @@ -144,7 +145,7 @@ class ShortUrlTest extends TestCase $allFor = static fn (ShortUrlMode $mode): bool => every($range, static function () use ($mode): bool { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( [ShortUrlInputFilter::LONG_URL => 'foo'], - $mode, + new UrlShortenerOptions(mode: $mode), )); $shortCode = $shortUrl->getShortCode(); 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/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index c7b6b33f..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, $this->urlShortenerOptions->mode); + 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 b32e8a5d..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->mode); + ], $this->urlShortenerOptions); } }