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

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

View File

@ -21,4 +21,9 @@ final class UrlShortenerOptions
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
{
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
{

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

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

View File

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

View File

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

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

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

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