Merge pull request #2020 from acelaya-forks/feature/path-prefix

Feature/path prefix
This commit is contained in:
Alejandro Celaya 2024-02-21 19:41:19 +01:00 committed by GitHub
commit 145d4eaaed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 203 additions and 133 deletions

View file

@ -12,6 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag. This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag.
* [#1904](https://github.com/shlinkio/shlink/issues/1904) Allow to customize QR codes foreground color, background color and logo. * [#1904](https://github.com/shlinkio/shlink/issues/1904) Allow to customize QR codes foreground color, background color and logo.
* [#1884](https://github.com/shlinkio/shlink/issues/1884) Allow a path prefix to be provided during short URL creation.
This can be useful to let Shlink generate partially random URLs, but with a known prefix.
Path prefixes are validated and filtered taking multi-segment slugs into consideration, which means slashes are replaced with dashes as long as multi-segment slugs are disabled.
### Changed ### Changed
* [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware.

View file

@ -42,7 +42,7 @@
"pugx/shortid-php": "^1.1", "pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7", "ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1", "shlinkio/doctrine-specification": "^2.1.1",
"shlinkio/shlink-common": "dev-main#b9a6bd5 as 6.0", "shlinkio/shlink-common": "dev-main#3e5bf59 as 6.0",
"shlinkio/shlink-config": "dev-main#a43b380 as 3.0", "shlinkio/shlink-config": "dev-main#a43b380 as 3.0",
"shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0",
"shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3",

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Application; use Mezzio\Application;
use Mezzio\Container; use Mezzio\Container;
use Psr\Http\Client\ClientInterface; use Psr\Http\Client\ClientInterface;
@ -12,12 +13,14 @@ use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface; use Psr\Http\Message\UploadedFileFactoryInterface;
use Spiral\RoadRunner\Http\PSR7Worker; use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\WorkerInterface; use Spiral\RoadRunner\WorkerInterface;
use Symfony\Component\Filesystem\Filesystem;
return [ return [
'dependencies' => [ 'dependencies' => [
'factories' => [ 'factories' => [
PSR7Worker::class => ConfigAbstractFactory::class, PSR7Worker::class => ConfigAbstractFactory::class,
Filesystem::class => InvokableFactory::class,
], ],
'delegators' => [ 'delegators' => [

View file

@ -303,6 +303,10 @@
"description": "A unique custom slug to be used instead of the generated short code", "description": "A unique custom slug to be used instead of the generated short code",
"type": "string" "type": "string"
}, },
"pathPrefix": {
"description": "A prefix that will be prepended to provided custom slug or auto-generated short code",
"type": "string"
},
"findIfExists": { "findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one", "description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean" "type": "boolean"
@ -382,6 +386,7 @@
"validSince", "validSince",
"validUntil", "validUntil",
"customSlug", "customSlug",
"pathPrefix",
"maxVisits", "maxVisits",
"findIfExists", "findIfExists",
"domain" "domain"

View file

@ -70,6 +70,12 @@ class CreateShortUrlCommand extends Command
InputOption::VALUE_REQUIRED, InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code', 'If provided, this slug will be used instead of generating a short code',
) )
->addOption(
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption( ->addOption(
'max-visits', 'max-visits',
'm', 'm',
@ -138,7 +144,6 @@ class CreateShortUrlCommand extends Command
$explodeWithComma = static fn (string $tag) => explode(',', $tag); $explodeWithComma = static fn (string $tag) => explode(',', $tag);
$tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); $tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('custom-slug');
$maxVisits = $input->getOption('max-visits'); $maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
@ -147,8 +152,9 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::LONG_URL => $longUrl, ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'), ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'), ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
ShortUrlInputFilter::CUSTOM_SLUG => $customSlug,
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'),
ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'), ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,

View file

@ -5,12 +5,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Validation; namespace Shlinkio\Shlink\Core\Domain\Validation;
use Laminas\InputFilter\InputFilter; use Laminas\InputFilter\InputFilter;
use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
use Shlinkio\Shlink\Common\Validation\InputFactory;
class DomainRedirectsInputFilter extends InputFilter class DomainRedirectsInputFilter extends InputFilter
{ {
use Validation\InputFactoryTrait;
public const DOMAIN = 'domain'; public const DOMAIN = 'domain';
public const BASE_URL_REDIRECT = 'baseUrlRedirect'; public const BASE_URL_REDIRECT = 'baseUrlRedirect';
public const REGULAR_404_REDIRECT = 'regular404Redirect'; public const REGULAR_404_REDIRECT = 'regular404Redirect';
@ -32,12 +31,12 @@ class DomainRedirectsInputFilter extends InputFilter
private function initializeInputs(): void private function initializeInputs(): void
{ {
$domain = $this->createInput(self::DOMAIN); $domain = InputFactory::basic(self::DOMAIN, required: true);
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $domain->getValidatorChain()->attach(new HostAndPortValidator());
$this->add($domain); $this->add($domain);
$this->add($this->createInput(self::BASE_URL_REDIRECT, false)); $this->add(InputFactory::basic(self::BASE_URL_REDIRECT));
$this->add($this->createInput(self::REGULAR_404_REDIRECT, false)); $this->add(InputFactory::basic(self::REGULAR_404_REDIRECT));
$this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false)); $this->add(InputFactory::basic(self::INVALID_SHORT_URL_REDIRECT));
} }
} }

View file

@ -33,6 +33,7 @@ use function Shlinkio\Shlink\Core\enumValues;
use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
class ShortUrl extends AbstractEntity class ShortUrl extends AbstractEntity
{ {
@ -100,9 +101,10 @@ class ShortUrl extends AbstractEntity
$instance->maxVisits = $creation->maxVisits; $instance->maxVisits = $creation->maxVisits;
$instance->customSlugWasProvided = $creation->hasCustomSlug(); $instance->customSlugWasProvided = $creation->hasCustomSlug();
$instance->shortCodeLength = $creation->shortCodeLength; $instance->shortCodeLength = $creation->shortCodeLength;
$instance->shortCode = $creation->customSlug ?? generateRandomShortCode( $instance->shortCode = sprintf(
$instance->shortCodeLength, '%s%s',
$creation->shortUrlMode, $creation->pathPrefix ?? '',
$creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength, $creation->shortUrlMode),
); );
$instance->domain = $relationResolver->resolveDomain($creation->domain); $instance->domain = $relationResolver->resolveDomain($creation->domain);
$instance->authorApiKey = $creation->apiKey; $instance->authorApiKey = $creation->apiKey;

View file

@ -18,39 +18,39 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
final class ShortUrlCreation implements TitleResolutionModelInterface final readonly class ShortUrlCreation implements TitleResolutionModelInterface
{ {
/** /**
* @param string[] $tags * @param string[] $tags
* @param DeviceLongUrlPair[] $deviceLongUrls * @param DeviceLongUrlPair[] $deviceLongUrls
*/ */
private function __construct( private function __construct(
public readonly string $longUrl, public string $longUrl,
public readonly ShortUrlMode $shortUrlMode, public ShortUrlMode $shortUrlMode,
public readonly array $deviceLongUrls = [], public array $deviceLongUrls = [],
public readonly ?Chronos $validSince = null, public ?Chronos $validSince = null,
public readonly ?Chronos $validUntil = null, public ?Chronos $validUntil = null,
public readonly ?string $customSlug = null, public ?string $customSlug = null,
public readonly ?int $maxVisits = null, public ?string $pathPrefix = null,
public readonly bool $findIfExists = false, public ?int $maxVisits = null,
public readonly ?string $domain = null, public bool $findIfExists = false,
public readonly int $shortCodeLength = 5, public ?string $domain = null,
public readonly ?ApiKey $apiKey = null, public int $shortCodeLength = 5,
public readonly array $tags = [], public ?ApiKey $apiKey = null,
public readonly ?string $title = null, public array $tags = [],
public readonly bool $titleWasAutoResolved = false, public ?string $title = null,
public readonly bool $crawlable = false, public bool $titleWasAutoResolved = false,
public readonly bool $forwardQuery = true, public bool $crawlable = false,
public bool $forwardQuery = true,
) { ) {
} }
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
public static function fromRawData(array $data, ?UrlShortenerOptions $options = null): self public static function fromRawData(array $data, UrlShortenerOptions $options = new UrlShortenerOptions()): self
{ {
$options = $options ?? new UrlShortenerOptions(); $inputFilter = ShortUrlInputFilter::forCreation($data, $options);
$inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data, $options);
if (! $inputFilter->isValid()) { if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter); throw ValidationException::fromInputFilter($inputFilter);
} }
@ -66,6 +66,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
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)),
customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG), customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG),
pathPrefix: $inputFilter->getValue(ShortUrlInputFilter::PATH_PREFIX),
maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS), maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS),
findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false, findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false,
domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN), domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN),
@ -90,6 +91,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
validSince: $this->validSince, validSince: $this->validSince,
validUntil: $this->validUntil, validUntil: $this->validUntil,
customSlug: $this->customSlug, customSlug: $this->customSlug,
pathPrefix: $this->pathPrefix,
maxVisits: $this->maxVisits, maxVisits: $this->maxVisits,
findIfExists: $this->findIfExists, findIfExists: $this->findIfExists,
domain: $this->domain, domain: $this->domain,

View file

@ -15,7 +15,7 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlEdition implements TitleResolutionModelInterface final readonly class ShortUrlEdition implements TitleResolutionModelInterface
{ {
/** /**
* @param string[] $tags * @param string[] $tags
@ -23,25 +23,25 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
* @param DeviceType[] $devicesToRemove * @param DeviceType[] $devicesToRemove
*/ */
private function __construct( private function __construct(
private readonly bool $longUrlPropWasProvided = false, private bool $longUrlPropWasProvided = false,
public readonly ?string $longUrl = null, public ?string $longUrl = null,
public readonly array $deviceLongUrls = [], public array $deviceLongUrls = [],
public readonly array $devicesToRemove = [], public array $devicesToRemove = [],
private readonly bool $validSincePropWasProvided = false, private bool $validSincePropWasProvided = false,
public readonly ?Chronos $validSince = null, public ?Chronos $validSince = null,
private readonly bool $validUntilPropWasProvided = false, private bool $validUntilPropWasProvided = false,
public readonly ?Chronos $validUntil = null, public ?Chronos $validUntil = null,
private readonly bool $maxVisitsPropWasProvided = false, private bool $maxVisitsPropWasProvided = false,
public readonly ?int $maxVisits = null, public ?int $maxVisits = null,
private readonly bool $tagsPropWasProvided = false, private bool $tagsPropWasProvided = false,
public readonly array $tags = [], public array $tags = [],
private readonly bool $titlePropWasProvided = false, private bool $titlePropWasProvided = false,
public readonly ?string $title = null, public ?string $title = null,
public readonly bool $titleWasAutoResolved = false, public bool $titleWasAutoResolved = false,
private readonly bool $crawlablePropWasProvided = false, private bool $crawlablePropWasProvided = false,
public readonly bool $crawlable = false, public bool $crawlable = false,
private readonly bool $forwardQueryPropWasProvided = false, private bool $forwardQueryPropWasProvided = false,
public readonly bool $forwardQuery = true, public bool $forwardQuery = true,
) { ) {
} }
@ -50,7 +50,7 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
*/ */
public static function fromRawData(array $data): self public static function fromRawData(array $data): self
{ {
$inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data); $inputFilter = ShortUrlInputFilter::forEdition($data);
if (! $inputFilter->isValid()) { if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter); throw ValidationException::fromInputFilter($inputFilter);
} }

View file

@ -12,9 +12,9 @@ use function str_replace;
use function strtolower; use function strtolower;
use function trim; use function trim;
class CustomSlugFilter implements FilterInterface readonly class CustomSlugFilter implements FilterInterface
{ {
public function __construct(private readonly UrlShortenerOptions $options) public function __construct(private UrlShortenerOptions $options)
{ {
} }
@ -25,9 +25,8 @@ class CustomSlugFilter implements FilterInterface
} }
$value = $this->options->isLooseMode() ? strtolower($value) : $value; $value = $this->options->isLooseMode() ? strtolower($value) : $value;
return (match ($this->options->multiSegmentSlugsEnabled) { return $this->options->multiSegmentSlugsEnabled
true => trim(str_replace(' ', '-', $value), '/'), ? trim(str_replace(' ', '-', $value), '/')
false => str_replace([' ', '/'], '-', $value), : str_replace([' ', '/'], '-', $value);
});
} }
} }

View file

@ -8,7 +8,8 @@ use DateTimeInterface;
use Laminas\Filter; 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\HostAndPortValidator;
use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -19,68 +20,50 @@ use function substr;
use const Shlinkio\Shlink\LOOSE_URI_MATCHER; use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
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; // Fields for creation only
public const SHORT_CODE_LENGTH = 'shortCodeLength';
public const VALID_SINCE = 'validSince';
public const VALID_UNTIL = 'validUntil';
public const CUSTOM_SLUG = 'customSlug'; public const CUSTOM_SLUG = 'customSlug';
public const MAX_VISITS = 'maxVisits'; public const PATH_PREFIX = 'pathPrefix';
public const FIND_IF_EXISTS = 'findIfExists'; public const FIND_IF_EXISTS = 'findIfExists';
public const DOMAIN = 'domain'; public const DOMAIN = 'domain';
public const SHORT_CODE_LENGTH = 'shortCodeLength';
// Fields for creation and edition
public const LONG_URL = 'longUrl'; public const LONG_URL = 'longUrl';
public const DEVICE_LONG_URLS = 'deviceLongUrls'; public const DEVICE_LONG_URLS = 'deviceLongUrls';
public const API_KEY = 'apiKey'; public const VALID_SINCE = 'validSince';
public const TAGS = 'tags'; public const VALID_UNTIL = 'validUntil';
public const MAX_VISITS = 'maxVisits';
public const TITLE = 'title'; public const TITLE = 'title';
public const TAGS = 'tags';
public const CRAWLABLE = 'crawlable'; public const CRAWLABLE = 'crawlable';
public const FORWARD_QUERY = 'forwardQuery'; public const FORWARD_QUERY = 'forwardQuery';
public const API_KEY = 'apiKey';
private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options) public static function forCreation(array $data, UrlShortenerOptions $options): self
{ {
$this->initialize($requireLongUrl, $options); $instance = new self();
$this->setData($data); $instance->initializeForCreation($options);
$instance->setData($data);
return $instance;
} }
public static function withRequiredLongUrl(array $data, UrlShortenerOptions $options): self public static function forEdition(array $data): self
{ {
return new self($data, true, $options); $instance = new self();
$instance->initializeForEdition();
$instance->setData($data);
return $instance;
} }
public static function withNonRequiredLongUrl(array $data): self private function initializeForCreation(UrlShortenerOptions $options): void
{ {
return new self($data, false, new UrlShortenerOptions());
}
private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void
{
$longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl);
$longUrlInput->getValidatorChain()->merge($this->longUrlValidators());
$this->add($longUrlInput);
$deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false);
$deviceLongUrlsInput->getValidatorChain()->attach(
new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)),
);
$this->add($deviceLongUrlsInput);
$validSince = $this->createInput(self::VALID_SINCE, false);
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validSince);
$validUntil = $this->createInput(self::VALID_UNTIL, false);
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validUntil);
// 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 with setContinueIfEmpty // is with setContinueIfEmpty(true)
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug = InputFactory::basic(self::CUSTOM_SLUG)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); $customSlug->getFilterChain()->attach(new CustomSlugFilter($options));
$customSlug->getValidatorChain() $customSlug->getValidatorChain()
->attach(new Validator\NotEmpty([ ->attach(new Validator\NotEmpty([
@ -90,32 +73,62 @@ class ShortUrlInputFilter extends InputFilter
->attach(CustomSlugValidator::forUrlShortenerOptions($options)); ->attach(CustomSlugValidator::forUrlShortenerOptions($options));
$this->add($customSlug); $this->add($customSlug);
$this->add($this->createNumericInput(self::MAX_VISITS, false)); // The path prefix is subject to the same filtering and validation logic as the custom slug, which takes into
$this->add($this->createNumericInput(self::SHORT_CODE_LENGTH, false, MIN_SHORT_CODES_LENGTH)); // consideration if multi-segment slugs are enabled or not.
// The only difference is that empty values are allowed here.
$pathPrefix = InputFactory::basic(self::PATH_PREFIX);
$pathPrefix->getFilterChain()->attach(new CustomSlugFilter($options));
$pathPrefix->getValidatorChain()->attach(CustomSlugValidator::forUrlShortenerOptions($options));
$this->add($pathPrefix);
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false)); $this->add(InputFactory::numeric(self::SHORT_CODE_LENGTH, min: MIN_SHORT_CODES_LENGTH));
$this->add(InputFactory::boolean(self::FIND_IF_EXISTS));
// This cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null. $domain = InputFactory::basic(self::DOMAIN);
// Defining them as boolean will make null fall back to false, which is not the desired behavior. $domain->getValidatorChain()->attach(new HostAndPortValidator());
$this->add($this->createInput(self::FORWARD_QUERY, false));
$domain = $this->createInput(self::DOMAIN, false);
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$this->add($domain); $this->add($domain);
$apiKeyInput = $this->createInput(self::API_KEY, false); $this->initializeForEdition(requireLongUrl: true);
$apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); }
$this->add($apiKeyInput);
$this->add($this->createTagsInput(self::TAGS, false)); private function initializeForEdition(bool $requireLongUrl = false): void
{
$longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl);
$longUrlInput->getValidatorChain()->merge($this->longUrlValidators());
$this->add($longUrlInput);
$title = $this->createInput(self::TITLE, false); $deviceLongUrlsInput = InputFactory::basic(self::DEVICE_LONG_URLS);
$deviceLongUrlsInput->getValidatorChain()->attach(
new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)),
);
$this->add($deviceLongUrlsInput);
$validSince = InputFactory::basic(self::VALID_SINCE);
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validSince);
$validUntil = InputFactory::basic(self::VALID_UNTIL);
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validUntil);
$this->add(InputFactory::numeric(self::MAX_VISITS));
$title = InputFactory::basic(self::TITLE);
$title->getFilterChain()->attach(new Filter\Callback( $title->getFilterChain()->attach(new Filter\Callback(
static fn (?string $value) => $value === null ? $value : substr($value, 0, 512), static fn (?string $value) => $value === null ? $value : substr($value, 0, 512),
)); ));
$this->add($title); $this->add($title);
$this->add($this->createBooleanInput(self::CRAWLABLE, false)); $this->add(InputFactory::tags(self::TAGS));
$this->add(InputFactory::boolean(self::CRAWLABLE));
// This cannot be defined as a boolean inputs, because it can actually have 3 values: true, false and null.
// Defining them as boolean will make null fall back to false, which is not the desired behavior.
$this->add(InputFactory::basic(self::FORWARD_QUERY));
$apiKeyInput = InputFactory::basic(self::API_KEY);
$apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
$this->add($apiKeyInput);
} }
private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain

View file

@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
use Laminas\InputFilter\InputFilter; use Laminas\InputFilter\InputFilter;
use Laminas\Validator\InArray; use Laminas\Validator\InArray;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
@ -15,8 +15,6 @@ use function Shlinkio\Shlink\Core\enumValues;
class ShortUrlsParamsInputFilter extends InputFilter class ShortUrlsParamsInputFilter extends InputFilter
{ {
use Validation\InputFactoryTrait;
public const PAGE = 'page'; public const PAGE = 'page';
public const SEARCH_TERM = 'searchTerm'; public const SEARCH_TERM = 'searchTerm';
public const TAGS = 'tags'; public const TAGS = 'tags';
@ -36,26 +34,26 @@ class ShortUrlsParamsInputFilter extends InputFilter
private function initialize(): void private function initialize(): void
{ {
$this->add($this->createDateInput(self::START_DATE, false)); $this->add(InputFactory::date(self::START_DATE));
$this->add($this->createDateInput(self::END_DATE, false)); $this->add(InputFactory::date(self::END_DATE));
$this->add($this->createInput(self::SEARCH_TERM, false)); $this->add(InputFactory::basic(self::SEARCH_TERM));
$this->add($this->createNumericInput(self::PAGE, false)); $this->add(InputFactory::numeric(self::PAGE));
$this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, Paginator::ALL_ITEMS)); $this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS));
$this->add($this->createTagsInput(self::TAGS, false)); $this->add(InputFactory::tags(self::TAGS));
$tagsMode = $this->createInput(self::TAGS_MODE, false); $tagsMode = InputFactory::basic(self::TAGS_MODE);
$tagsMode->getValidatorChain()->attach(new InArray([ $tagsMode->getValidatorChain()->attach(new InArray([
'haystack' => enumValues(TagsMode::class), 'haystack' => enumValues(TagsMode::class),
'strict' => InArray::COMPARE_STRICT, 'strict' => InArray::COMPARE_STRICT,
])); ]));
$this->add($tagsMode); $this->add($tagsMode);
$this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class))); $this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class)));
$this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false)); $this->add(InputFactory::boolean(self::EXCLUDE_MAX_VISITS_REACHED));
$this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false)); $this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL));
} }
} }

View file

@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Entity;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
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;
@ -91,6 +92,27 @@ class ShortUrlTest extends TestCase
yield from array_map(fn (int $value) => [$value, $value], range(4, 10)); yield from array_map(fn (int $value) => [$value, $value], range(4, 10));
} }
#[Test]
#[TestWith([null, '', 5])]
#[TestWith(['foo bar/', 'foo-bar-', 13])]
public function shortCodesHaveExpectedPrefix(
?string $pathPrefix,
string $expectedPrefix,
int $expectedShortCodeLength,
): void {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://longUrl',
ShortUrlInputFilter::SHORT_CODE_LENGTH => 5,
ShortUrlInputFilter::PATH_PREFIX => $pathPrefix,
]));
$shortCode = $shortUrl->getShortCode();
if (strlen($expectedPrefix) > 0) {
self::assertStringStartsWith($expectedPrefix, $shortCode);
}
self::assertEquals($expectedShortCodeLength, strlen($shortCode));
}
#[Test] #[Test]
public function deviceLongUrlsAreUpdated(): void public function deviceLongUrlsAreUpdated(): void
{ {

View file

@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
use GuzzleHttp\RequestOptions; use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function array_map; use function array_map;
@ -339,6 +340,21 @@ class CreateShortUrlTest extends ApiTestCase
self::assertNull($payload['title']); self::assertNull($payload['title']);
} }
#[Test]
#[TestWith([null])]
#[TestWith(['my-custom-slug'])]
public function prefixCanBeSet(?string $customSlug): void
{
[$statusCode, $payload] = $this->createShortUrl([
'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557',
'pathPrefix' => 'foo/b ar-baz',
'customSlug' => $customSlug,
]);
self::assertEquals(self::STATUS_OK, $statusCode);
self::assertStringStartsWith('foo-b--ar-baz', $payload['shortCode']);
}
/** /**
* @return array{int, array} * @return array{int, array}
*/ */