mirror of
https://github.com/shlinkio/shlink.git
synced 2024-12-21 22:53:52 -06:00
Add support for short URL mode in installer, and handle loosely mode in custom slugs
This commit is contained in:
parent
2f83e90c8b
commit
fdaf5fb2f3
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
|
@ -21,4 +21,9 @@ final class UrlShortenerOptions
|
||||
public readonly ShortUrlMode $mode = ShortUrlMode::STRICT,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isLooselyMode(): bool
|
||||
{
|
||||
return $this->mode === ShortUrlMode::LOOSELY;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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)),
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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 ['グーグル', 'グーグル'];
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user