mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-25 02:10:18 -06:00
Merge pull request #2020 from acelaya-forks/feature/path-prefix
Feature/path prefix
This commit is contained in:
commit
145d4eaaed
@ -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.
|
||||||
|
@ -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",
|
||||||
|
@ -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' => [
|
||||||
|
@ -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"
|
||||||
|
@ -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,
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
{
|
{
|
||||||
|
@ -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}
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user