mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-25 18:30:23 -06:00
Add a 3-second timeout to title resolution
This commit is contained in:
commit
d3bfd99210
@ -13,6 +13,7 @@
|
||||
* The short URLs `loosely` mode is no longer supported, as it was a typo. Use `loose` mode instead.
|
||||
* QR codes URLs now work by default, even for short URLs that cannot be visited due to max visits or date range limitations.
|
||||
If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option.
|
||||
* Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition.
|
||||
|
||||
### Changes in REST API
|
||||
|
||||
@ -21,7 +22,6 @@
|
||||
* `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion`
|
||||
* `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found`
|
||||
* `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation`
|
||||
* `INVALID_URL` -> `https://shlink.io/api/error/invalid-url`
|
||||
* `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug`
|
||||
* `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found`
|
||||
* `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict`
|
||||
|
@ -42,12 +42,12 @@
|
||||
"pugx/shortid-php": "^1.1",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/doctrine-specification": "^2.1.1",
|
||||
"shlinkio/shlink-common": "dev-main#178b332 as 6.0",
|
||||
"shlinkio/shlink-config": "dev-main#6b287b3 as 2.6",
|
||||
"shlinkio/shlink-event-dispatcher": "dev-main#46f5e21 as 4.0",
|
||||
"shlinkio/shlink-importer": "dev-main#f0a1f1d as 5.3",
|
||||
"shlinkio/shlink-installer": "dev-develop#2dee7db as 9.0",
|
||||
"shlinkio/shlink-ip-geolocation": "dev-main#c123a52 as 3.5",
|
||||
"shlinkio/shlink-common": "dev-main#762b3b8 as 6.0",
|
||||
"shlinkio/shlink-config": "dev-main#a43b380 as 3.0",
|
||||
"shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0",
|
||||
"shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3",
|
||||
"shlinkio/shlink-installer": "dev-develop#3e8d7d7 as 9.0",
|
||||
"shlinkio/shlink-ip-geolocation": "dev-main#a807668 as 3.5",
|
||||
"shlinkio/shlink-json": "^1.1",
|
||||
"spiral/roadrunner": "^2023.3",
|
||||
"spiral/roadrunner-cli": "^2.6",
|
||||
|
@ -20,11 +20,6 @@
|
||||
"description": "The maximum number of allowed visits for this short code",
|
||||
"type": ["number", "null"]
|
||||
},
|
||||
"validateUrl": {
|
||||
"deprecated": true,
|
||||
"description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
@ -388,10 +388,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "A URL that could not be verified, if the error type is https://shlink.io/api/error/invalid-url"
|
||||
},
|
||||
"customSlug": {
|
||||
"type": "string",
|
||||
"description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug"
|
||||
@ -408,15 +404,6 @@
|
||||
"Invalid arguments with API v3 and newer": {
|
||||
"$ref": "../examples/short-url-invalid-args-v3.json"
|
||||
},
|
||||
"Invalid long URL with API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Invalid URL",
|
||||
"type": "https://shlink.io/api/error/invalid-url",
|
||||
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||
"status": 400,
|
||||
"url": "https://invalid-url.com"
|
||||
}
|
||||
},
|
||||
"Non-unique slug with API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Invalid custom slug",
|
||||
@ -429,15 +416,6 @@
|
||||
"Invalid arguments previous to API v3": {
|
||||
"$ref": "../examples/short-url-invalid-args-v2.json"
|
||||
},
|
||||
"Invalid long URL previous to API v3": {
|
||||
"value": {
|
||||
"title": "Invalid URL",
|
||||
"type": "INVALID_URL",
|
||||
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||
"status": 400,
|
||||
"url": "https://invalid-url.com"
|
||||
}
|
||||
},
|
||||
"Non-unique slug previous to API v3": {
|
||||
"value": {
|
||||
"title": "Invalid custom slug",
|
||||
|
@ -88,49 +88,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The long URL was not provided or is invalid.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Invalid URL",
|
||||
"type": "https://shlink.io/api/error/invalid-url",
|
||||
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||
"status": 400,
|
||||
"url": "https://invalid-url.com"
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"title": "Invalid URL",
|
||||
"type": "INVALID_URL",
|
||||
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||
"status": 400,
|
||||
"url": "https://invalid-url.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": "https://shlink.io/api/error/invalid-url"
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": "INVALID_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
@ -95,12 +94,6 @@ class CreateShortUrlCommand extends Command
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The length for generated short code (it will be ignored if --custom-slug was provided).',
|
||||
)
|
||||
->addOption(
|
||||
'validate-url',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'[DEPRECATED] Makes the URL to be validated as publicly accessible.',
|
||||
)
|
||||
->addOption(
|
||||
'crawlable',
|
||||
'r',
|
||||
@ -148,7 +141,6 @@ class CreateShortUrlCommand extends Command
|
||||
$customSlug = $input->getOption('custom-slug');
|
||||
$maxVisits = $input->getOption('max-visits');
|
||||
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
|
||||
$doValidateUrl = $input->getOption('validate-url');
|
||||
|
||||
try {
|
||||
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
|
||||
@ -160,7 +152,6 @@ class CreateShortUrlCommand extends Command
|
||||
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
|
||||
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
|
||||
ShortUrlInputFilter::TAGS => $tags,
|
||||
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
|
||||
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
|
||||
@ -176,7 +167,7 @@ class CreateShortUrlCommand extends Command
|
||||
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
|
||||
]);
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (InvalidUrlException | NonUniqueSlugException $e) {
|
||||
} catch (NonUniqueSlugException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
|
@ -12,7 +12,6 @@ use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
@ -68,22 +67,6 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
self::assertStringNotContainsString('but the real-time updates cannot', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exceptionWhileParsingLongUrlOutputsError(): void
|
||||
{
|
||||
$url = 'http://domain.com/invalid';
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
|
||||
InvalidUrlException::fromUrl($url),
|
||||
);
|
||||
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
|
||||
|
||||
$this->commandTester->execute(['longUrl' => $url]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function providingNonUniqueSlugOutputsError(): void
|
||||
{
|
||||
@ -148,12 +131,12 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideFlags')]
|
||||
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
|
||||
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedCrawlable): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) {
|
||||
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
|
||||
$this->callback(function (ShortUrlCreation $meta) use ($expectedCrawlable) {
|
||||
Assert::assertEquals($expectedCrawlable, $meta->crawlable);
|
||||
return true;
|
||||
}),
|
||||
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
|
||||
@ -166,7 +149,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
public static function provideFlags(): iterable
|
||||
{
|
||||
yield 'no flags' => [[], null];
|
||||
yield 'validate-url' => [['--validate-url' => true], true];
|
||||
yield 'crawlable' => [['--crawlable' => true], true];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -75,7 +75,6 @@ return [
|
||||
Visit\Entity\Visit::class,
|
||||
],
|
||||
|
||||
Util\UrlValidator::class => ConfigAbstractFactory::class,
|
||||
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
|
||||
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
|
||||
|
||||
@ -153,7 +152,6 @@ return [
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class],
|
||||
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
Util\DoctrineBatchHelper::class => ['em'],
|
||||
Util\RedirectResponseHelper::class => [Options\RedirectOptions::class],
|
||||
|
||||
@ -180,7 +178,7 @@ return [
|
||||
Lock\LockFactory::class,
|
||||
],
|
||||
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
|
||||
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [
|
||||
|
@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class InvalidUrlException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Invalid URL';
|
||||
public const ERROR_CODE = 'invalid-url';
|
||||
|
||||
public static function fromUrl(string $url, ?Throwable $previous = null): self
|
||||
{
|
||||
$status = StatusCodeInterface::STATUS_BAD_REQUEST;
|
||||
$e = new self(sprintf('Provided URL %s is invalid. Try with a different one.', $url), $status, $previous);
|
||||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = $status;
|
||||
$e->additional = ['url' => $url];
|
||||
|
||||
return $e;
|
||||
}
|
||||
}
|
@ -120,7 +120,6 @@ class ShortUrl extends AbstractEntity
|
||||
?ShortUrlRelationResolverInterface $relationResolver = null,
|
||||
): self {
|
||||
$meta = [
|
||||
ShortUrlInputFilter::VALIDATE_URL => false,
|
||||
ShortUrlInputFilter::LONG_URL => $url->longUrl,
|
||||
ShortUrlInputFilter::DOMAIN => $url->domain,
|
||||
ShortUrlInputFilter::TAGS => $url->tags,
|
||||
|
@ -4,31 +4,92 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Throwable;
|
||||
|
||||
class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
|
||||
use function html_entity_decode;
|
||||
use function preg_match;
|
||||
use function str_contains;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
use const Shlinkio\Shlink\TITLE_TAG_VALUE;
|
||||
|
||||
readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
|
||||
{
|
||||
public function __construct(private readonly UrlValidatorInterface $urlValidator)
|
||||
{
|
||||
public const MAX_REDIRECTS = 15;
|
||||
public const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/121.0.0.0 Safari/537.36';
|
||||
|
||||
public function __construct(
|
||||
private ClientInterface $httpClient,
|
||||
private UrlShortenerOptions $options,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0
|
||||
* Move relevant logic from URL validator here.
|
||||
* @template T of TitleResolutionModelInterface
|
||||
* @param T $data
|
||||
* @return T
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface
|
||||
public function processTitle(TitleResolutionModelInterface $data): TitleResolutionModelInterface
|
||||
{
|
||||
if ($data->hasTitle()) {
|
||||
$this->urlValidator->validateUrl($data->getLongUrl(), $data->doValidateUrl());
|
||||
if (! $this->options->autoResolveTitles || $data->hasTitle()) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$title = $this->urlValidator->validateUrlWithTitle($data->getLongUrl(), $data->doValidateUrl());
|
||||
return $title === null ? $data : $data->withResolvedTitle($title);
|
||||
$response = $this->fetchUrl($data->getLongUrl());
|
||||
if ($response === null) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$contentType = strtolower($response->getHeaderLine('Content-Type'));
|
||||
if (! str_starts_with($contentType, 'text/html')) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$title = $this->tryToResolveTitle($response);
|
||||
return $title !== null ? $data->withResolvedTitle($title) : $data;
|
||||
}
|
||||
|
||||
private function fetchUrl(string $url): ?ResponseInterface
|
||||
{
|
||||
try {
|
||||
return $this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [
|
||||
// Add a sensible 3-second timeout that prevents hanging here forever
|
||||
RequestOptions::TIMEOUT => 3,
|
||||
RequestOptions::CONNECT_TIMEOUT => 3,
|
||||
// Prevent potential infinite redirection loops
|
||||
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
|
||||
RequestOptions::IDN_CONVERSION => true,
|
||||
// Making the request with a browser's user agent results in responses closer to a real user
|
||||
RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT],
|
||||
RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function tryToResolveTitle(ResponseInterface $response): ?string
|
||||
{
|
||||
$collectedBody = '';
|
||||
$body = $response->getBody();
|
||||
// With streaming enabled, we can walk the body until the </title> tag is found, and then stop
|
||||
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
|
||||
$collectedBody .= $body->read(1024);
|
||||
}
|
||||
preg_match(TITLE_TAG_VALUE, $collectedBody, $matches);
|
||||
return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null;
|
||||
}
|
||||
|
||||
private function normalizeTitle(string $title): string
|
||||
{
|
||||
return html_entity_decode(trim($title));
|
||||
}
|
||||
}
|
||||
|
@ -4,16 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
|
||||
interface ShortUrlTitleResolutionHelperInterface
|
||||
{
|
||||
/**
|
||||
* @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0
|
||||
* @template T of TitleResolutionModelInterface
|
||||
* @param T $data
|
||||
* @return T
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface;
|
||||
public function processTitle(TitleResolutionModelInterface $data): TitleResolutionModelInterface;
|
||||
}
|
||||
|
@ -10,8 +10,5 @@ interface TitleResolutionModelInterface
|
||||
|
||||
public function getLongUrl(): string;
|
||||
|
||||
/** @deprecated */
|
||||
public function doValidateUrl(): bool;
|
||||
|
||||
public function withResolvedTitle(string $title): static;
|
||||
}
|
||||
|
@ -35,8 +35,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
|
||||
public readonly bool $findIfExists = false,
|
||||
public readonly ?string $domain = null,
|
||||
public readonly int $shortCodeLength = 5,
|
||||
/** @deprecated */
|
||||
public readonly bool $validateUrl = false,
|
||||
public readonly ?ApiKey $apiKey = null,
|
||||
public readonly array $tags = [],
|
||||
public readonly ?string $title = null,
|
||||
@ -75,7 +73,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
|
||||
$inputFilter,
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH,
|
||||
) ?? DEFAULT_SHORT_CODES_LENGTH,
|
||||
validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
|
||||
apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY),
|
||||
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
|
||||
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
|
||||
@ -97,7 +94,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
|
||||
findIfExists: $this->findIfExists,
|
||||
domain: $this->domain,
|
||||
shortCodeLength: $this->shortCodeLength,
|
||||
validateUrl: $this->validateUrl,
|
||||
apiKey: $this->apiKey,
|
||||
tags: $this->tags,
|
||||
title: $title,
|
||||
@ -137,12 +133,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
|
||||
return $this->domain !== null;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
public function doValidateUrl(): bool
|
||||
{
|
||||
return $this->validateUrl;
|
||||
}
|
||||
|
||||
public function hasTitle(): bool
|
||||
{
|
||||
return $this->title !== null;
|
||||
|
@ -38,8 +38,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
|
||||
private readonly bool $titlePropWasProvided = false,
|
||||
public readonly ?string $title = null,
|
||||
public readonly bool $titleWasAutoResolved = false,
|
||||
/** @deprecated */
|
||||
public readonly bool $validateUrl = false,
|
||||
private readonly bool $crawlablePropWasProvided = false,
|
||||
public readonly bool $crawlable = false,
|
||||
private readonly bool $forwardQueryPropWasProvided = false,
|
||||
@ -76,7 +74,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
|
||||
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
|
||||
titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data),
|
||||
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
|
||||
validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
|
||||
crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data),
|
||||
crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE),
|
||||
forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data),
|
||||
@ -102,7 +99,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
|
||||
titlePropWasProvided: $this->titlePropWasProvided,
|
||||
title: $title,
|
||||
titleWasAutoResolved: true,
|
||||
validateUrl: $this->validateUrl,
|
||||
crawlablePropWasProvided: $this->crawlablePropWasProvided,
|
||||
crawlable: $this->crawlable,
|
||||
forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided,
|
||||
@ -155,12 +151,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
|
||||
return $this->titleWasAutoResolved;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
public function doValidateUrl(): bool
|
||||
{
|
||||
return $this->validateUrl;
|
||||
}
|
||||
|
||||
public function crawlableWasProvided(): bool
|
||||
{
|
||||
return $this->crawlablePropWasProvided;
|
||||
|
@ -36,8 +36,6 @@ class ShortUrlInputFilter extends InputFilter
|
||||
public const SHORT_CODE_LENGTH = 'shortCodeLength';
|
||||
public const LONG_URL = 'longUrl';
|
||||
public const DEVICE_LONG_URLS = 'deviceLongUrls';
|
||||
/** @deprecated */
|
||||
public const VALIDATE_URL = 'validateUrl';
|
||||
public const API_KEY = 'apiKey';
|
||||
public const TAGS = 'tags';
|
||||
public const TITLE = 'title';
|
||||
@ -97,9 +95,8 @@ class ShortUrlInputFilter extends InputFilter
|
||||
|
||||
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
|
||||
|
||||
// These cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null.
|
||||
// This cannot be defined as a boolean inputs, because they 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($this->createInput(self::VALIDATE_URL, false));
|
||||
$this->add($this->createInput(self::FORWARD_QUERY, false));
|
||||
|
||||
$domain = $this->createInput(self::DOMAIN, false);
|
||||
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||
|
||||
use Doctrine\ORM;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
|
||||
@ -26,7 +25,6 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function updateShortUrl(
|
||||
ShortUrlIdentifier $identifier,
|
||||
@ -34,7 +32,7 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
?ApiKey $apiKey = null,
|
||||
): ShortUrl {
|
||||
if ($shortUrlEdit->longUrlWasProvided()) {
|
||||
$shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit);
|
||||
$shortUrlEdit = $this->titleResolutionHelper->processTitle($shortUrlEdit);
|
||||
}
|
||||
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
|
||||
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
|
||||
@ -15,7 +14,6 @@ interface ShortUrlServiceInterface
|
||||
{
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function updateShortUrl(
|
||||
ShortUrlIdentifier $identifier,
|
||||
|
@ -8,7 +8,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface;
|
||||
@ -31,7 +30,6 @@ class UrlShortener implements UrlShortenerInterface
|
||||
|
||||
/**
|
||||
* @throws NonUniqueSlugException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function shorten(ShortUrlCreation $creation): UrlShorteningResult
|
||||
{
|
||||
@ -41,7 +39,7 @@ class UrlShortener implements UrlShortenerInterface
|
||||
return UrlShorteningResult::withoutErrorOnEventDispatching($existingShortUrl);
|
||||
}
|
||||
|
||||
$creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation);
|
||||
$creation = $this->titleResolutionHelper->processTitle($creation);
|
||||
|
||||
/** @var ShortUrl $newShortUrl */
|
||||
$newShortUrl = $this->em->wrapInTransaction(function () use ($creation): ShortUrl {
|
||||
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
|
||||
@ -13,7 +12,6 @@ interface UrlShortenerInterface
|
||||
{
|
||||
/**
|
||||
* @throws NonUniqueSlugException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function shorten(ShortUrlCreation $creation): UrlShorteningResult;
|
||||
}
|
||||
|
@ -1,116 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Util;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Throwable;
|
||||
|
||||
use function html_entity_decode;
|
||||
use function preg_match;
|
||||
use function str_contains;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
use const Shlinkio\Shlink\TITLE_TAG_VALUE;
|
||||
|
||||
/** @deprecated */
|
||||
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
||||
{
|
||||
private const MAX_REDIRECTS = 15;
|
||||
private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/112.0.0.0 Safari/537.36';
|
||||
|
||||
public function __construct(private ClientInterface $httpClient, private UrlShortenerOptions $options)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function validateUrl(string $url, bool $doValidate): void
|
||||
{
|
||||
if (! $doValidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateUrlAndGetResponse($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function validateUrlWithTitle(string $url, bool $doValidate): ?string
|
||||
{
|
||||
if (! $doValidate && ! $this->options->autoResolveTitles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->options->autoResolveTitles) {
|
||||
$this->validateUrlAndGetResponse($url, self::METHOD_HEAD);
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $doValidate ? $this->validateUrlAndGetResponse($url) : $this->getResponse($url);
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contentType = strtolower($response->getHeaderLine('Content-Type'));
|
||||
if (! str_starts_with($contentType, 'text/html')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$collectedBody = '';
|
||||
$body = $response->getBody();
|
||||
// With streaming enabled, we can walk the body until the </title> tag is found, and then stop
|
||||
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
|
||||
$collectedBody .= $body->read(1024);
|
||||
}
|
||||
preg_match(TITLE_TAG_VALUE, $collectedBody, $matches);
|
||||
return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param self::METHOD_GET|self::METHOD_HEAD $method
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
private function validateUrlAndGetResponse(string $url, string $method = self::METHOD_GET): ResponseInterface
|
||||
{
|
||||
try {
|
||||
return $this->httpClient->request($method, $url, [
|
||||
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
|
||||
RequestOptions::IDN_CONVERSION => true,
|
||||
// Making the request with a browser's user agent makes the validation closer to a real user
|
||||
RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT],
|
||||
RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed
|
||||
]);
|
||||
} catch (GuzzleException $e) {
|
||||
throw InvalidUrlException::fromUrl($url, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function getResponse(string $url): ?ResponseInterface
|
||||
{
|
||||
try {
|
||||
return $this->validateUrlAndGetResponse($url);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeTitle(string $title): string
|
||||
{
|
||||
return html_entity_decode(trim($title));
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Util;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
|
||||
/** @deprecated */
|
||||
interface UrlValidatorInterface
|
||||
{
|
||||
/**
|
||||
* @deprecated
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function validateUrl(string $url, bool $doValidate): void;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function validateUrlWithTitle(string $url, bool $doValidate): ?string;
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Exception;
|
||||
|
||||
use Exception;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Throwable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class InvalidUrlExceptionTest extends TestCase
|
||||
{
|
||||
#[Test, DataProvider('providePrevious')]
|
||||
public function properlyCreatesExceptionFromUrl(?Throwable $prev): void
|
||||
{
|
||||
$url = 'http://the_url.com';
|
||||
$expectedMessage = sprintf('Provided URL %s is invalid. Try with a different one.', $url);
|
||||
$e = InvalidUrlException::fromUrl($url, $prev);
|
||||
|
||||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Invalid URL', $e->getTitle());
|
||||
self::assertEquals('https://shlink.io/api/error/invalid-url', $e->getType());
|
||||
self::assertEquals(['url' => $url], $e->getAdditionalData());
|
||||
self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode());
|
||||
self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus());
|
||||
self::assertEquals($prev, $e->getPrevious());
|
||||
}
|
||||
|
||||
public static function providePrevious(): iterable
|
||||
{
|
||||
yield 'null previous' => [null];
|
||||
yield 'instance previous' => [new Exception('Previous error', 10)];
|
||||
}
|
||||
}
|
@ -4,46 +4,144 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Exception;
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\Stream;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
|
||||
class ShortUrlTitleResolutionHelperTest extends TestCase
|
||||
{
|
||||
private ShortUrlTitleResolutionHelper $helper;
|
||||
private MockObject & UrlValidatorInterface $urlValidator;
|
||||
private const LONG_URL = 'http://foobar.com/12345/hello?foo=bar';
|
||||
|
||||
private MockObject & ClientInterface $httpClient;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->urlValidator = $this->createMock(UrlValidatorInterface::class);
|
||||
$this->helper = new ShortUrlTitleResolutionHelper($this->urlValidator);
|
||||
$this->httpClient = $this->createMock(ClientInterface::class);
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideTitles')]
|
||||
public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void
|
||||
#[Test]
|
||||
public function dataIsReturnedAsIsWhenResolvingTitlesIsDisabled(): void
|
||||
{
|
||||
$longUrl = 'http://foobar.com/12345/hello?foo=bar';
|
||||
$this->urlValidator->expects($this->exactly($validateWithTitleCallsNum))->method('validateUrlWithTitle')->with(
|
||||
$longUrl,
|
||||
$this->isFalse(),
|
||||
);
|
||||
$this->urlValidator->expects($this->exactly($validateCallsNum))->method('validateUrl')->with(
|
||||
$longUrl,
|
||||
$this->isFalse(),
|
||||
);
|
||||
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
|
||||
$this->httpClient->expects($this->never())->method('request');
|
||||
|
||||
$this->helper->processTitleAndValidateUrl(
|
||||
ShortUrlCreation::fromRawData(['longUrl' => $longUrl, 'title' => $title]),
|
||||
$result = $this->helper()->processTitle($data);
|
||||
|
||||
self::assertSame($data, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataIsReturnedAsIsWhenItAlreadyHasTitle(): void
|
||||
{
|
||||
$data = ShortUrlCreation::fromRawData([
|
||||
'longUrl' => self::LONG_URL,
|
||||
'title' => 'foo',
|
||||
]);
|
||||
$this->httpClient->expects($this->never())->method('request');
|
||||
|
||||
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
|
||||
|
||||
self::assertSame($data, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataIsReturnedAsIsWhenFetchingFails(): void
|
||||
{
|
||||
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
|
||||
$this->expectRequestToBeCalled()->willThrowException(new Exception('Error'));
|
||||
|
||||
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
|
||||
|
||||
self::assertSame($data, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataIsReturnedAsIsWhenResponseIsNotHtml(): void
|
||||
{
|
||||
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
|
||||
$this->expectRequestToBeCalled()->willReturn(new JsonResponse(['foo' => 'bar']));
|
||||
|
||||
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
|
||||
|
||||
self::assertSame($data, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dataIsReturnedAsIsWhenTitleCannotBeResolvedFromResponse(): void
|
||||
{
|
||||
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
|
||||
$this->expectRequestToBeCalled()->willReturn($this->respWithoutTitle());
|
||||
|
||||
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
|
||||
|
||||
self::assertSame($data, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(): void
|
||||
{
|
||||
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
|
||||
$this->expectRequestToBeCalled()->willReturn($this->respWithTitle());
|
||||
|
||||
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
|
||||
|
||||
self::assertNotSame($data, $result);
|
||||
self::assertEquals('Resolved "title"', $result->title);
|
||||
}
|
||||
|
||||
private function expectRequestToBeCalled(): InvocationMocker
|
||||
{
|
||||
return $this->httpClient->expects($this->once())->method('request')->with(
|
||||
RequestMethodInterface::METHOD_GET,
|
||||
self::LONG_URL,
|
||||
[
|
||||
RequestOptions::TIMEOUT => 3,
|
||||
RequestOptions::CONNECT_TIMEOUT => 3,
|
||||
RequestOptions::ALLOW_REDIRECTS => ['max' => ShortUrlTitleResolutionHelper::MAX_REDIRECTS],
|
||||
RequestOptions::IDN_CONVERSION => true,
|
||||
RequestOptions::HEADERS => ['User-Agent' => ShortUrlTitleResolutionHelper::CHROME_USER_AGENT],
|
||||
RequestOptions::STREAM => true,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public static function provideTitles(): iterable
|
||||
private function respWithoutTitle(): Response
|
||||
{
|
||||
yield 'no title' => [null, 1, 0];
|
||||
yield 'title' => ['link title', 0, 1];
|
||||
$body = $this->createStreamWithContent('<body>No title</body>');
|
||||
return new Response($body, 200, ['Content-Type' => 'text/html']);
|
||||
}
|
||||
|
||||
private function respWithTitle(): Response
|
||||
{
|
||||
$body = $this->createStreamWithContent('<title data-foo="bar"> Resolved "title" </title>');
|
||||
return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']);
|
||||
}
|
||||
|
||||
private function createStreamWithContent(string $content): Stream
|
||||
{
|
||||
$body = new Stream('php://temp', 'wr');
|
||||
$body->write($content);
|
||||
$body->rewind();
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function helper(bool $autoResolveTitles = false): ShortUrlTitleResolutionHelper
|
||||
{
|
||||
return new ShortUrlTitleResolutionHelper(
|
||||
$this->httpClient,
|
||||
new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ class ShortUrlServiceTest extends TestCase
|
||||
)->willReturn($shortUrl);
|
||||
|
||||
$this->titleResolutionHelper->expects($expectedValidateCalls)
|
||||
->method('processTitleAndValidateUrl')
|
||||
->method('processTitle')
|
||||
->with($shortUrlEdit)
|
||||
->willReturn($shortUrlEdit);
|
||||
|
||||
@ -102,10 +102,6 @@ class ShortUrlServiceTest extends TestCase
|
||||
'maxVisits' => 10,
|
||||
'longUrl' => 'https://modifiedLongUrl',
|
||||
]), ApiKey::create()];
|
||||
yield 'long URL with validation' => [new InvokedCount(1), ShortUrlEdition::fromRawData([
|
||||
'longUrl' => 'https://modifiedLongUrl',
|
||||
'validateUrl' => true,
|
||||
]), null];
|
||||
yield 'device redirects' => [new InvokedCount(0), ShortUrlEdition::fromRawData([
|
||||
'deviceLongUrls' => [
|
||||
DeviceType::IOS->value => 'https://iosLongUrl',
|
||||
|
@ -57,7 +57,7 @@ class UrlShortenerTest extends TestCase
|
||||
{
|
||||
$longUrl = 'http://foobar.com/12345/hello?foo=bar';
|
||||
$meta = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]);
|
||||
$this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with(
|
||||
$this->titleResolutionHelper->expects($this->once())->method('processTitle')->with(
|
||||
$meta,
|
||||
)->willReturnArgument(0);
|
||||
$this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true);
|
||||
@ -90,7 +90,7 @@ class UrlShortenerTest extends TestCase
|
||||
);
|
||||
|
||||
$this->shortCodeHelper->expects($this->once())->method('ensureShortCodeUniqueness')->willReturn(false);
|
||||
$this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with(
|
||||
$this->titleResolutionHelper->expects($this->once())->method('processTitle')->with(
|
||||
$meta,
|
||||
)->willReturnArgument(0);
|
||||
|
||||
@ -105,7 +105,7 @@ class UrlShortenerTest extends TestCase
|
||||
$repo = $this->createMock(ShortUrlRepository::class);
|
||||
$repo->expects($this->once())->method('findOneMatching')->willReturn($expected);
|
||||
$this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo);
|
||||
$this->titleResolutionHelper->expects($this->never())->method('processTitleAndValidateUrl');
|
||||
$this->titleResolutionHelper->expects($this->never())->method('processTitle');
|
||||
$this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true);
|
||||
|
||||
$result = $this->urlShortener->shorten($meta);
|
||||
|
@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Util;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\Stream;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidator;
|
||||
|
||||
class UrlValidatorTest extends TestCase
|
||||
{
|
||||
private MockObject & ClientInterface $httpClient;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->httpClient = $this->createMock(ClientInterface::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exceptionIsThrownWhenUrlIsInvalid(): void
|
||||
{
|
||||
$this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException());
|
||||
$this->expectException(InvalidUrlException::class);
|
||||
|
||||
$this->urlValidator()->validateUrl('http://foobar.com/12345/hello?foo=bar', true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function expectedUrlIsCalledWhenTryingToVerify(): void
|
||||
{
|
||||
$expectedUrl = 'http://foobar.com';
|
||||
|
||||
$this->httpClient->expects($this->once())->method('request')->with(
|
||||
RequestMethodInterface::METHOD_GET,
|
||||
$expectedUrl,
|
||||
$this->callback(function (array $options) {
|
||||
Assert::assertArrayHasKey(RequestOptions::ALLOW_REDIRECTS, $options);
|
||||
Assert::assertEquals(['max' => 15], $options[RequestOptions::ALLOW_REDIRECTS]);
|
||||
Assert::assertArrayHasKey(RequestOptions::IDN_CONVERSION, $options);
|
||||
Assert::assertTrue($options[RequestOptions::IDN_CONVERSION]);
|
||||
Assert::assertArrayHasKey(RequestOptions::HEADERS, $options);
|
||||
Assert::assertArrayHasKey('User-Agent', $options[RequestOptions::HEADERS]);
|
||||
|
||||
return true;
|
||||
}),
|
||||
)->willReturn(new Response());
|
||||
|
||||
$this->urlValidator()->validateUrl($expectedUrl, true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void
|
||||
{
|
||||
$this->httpClient->expects($this->never())->method('request');
|
||||
$this->urlValidator()->validateUrl('', false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled(): void
|
||||
{
|
||||
$this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException());
|
||||
|
||||
$result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabled(): void
|
||||
{
|
||||
$this->httpClient->expects($this->never())->method('request');
|
||||
|
||||
$result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabledAndValidationIsEnabled(): void
|
||||
{
|
||||
$this->httpClient->expects($this->once())->method('request')->with(
|
||||
RequestMethodInterface::METHOD_HEAD,
|
||||
$this->anything(),
|
||||
$this->anything(),
|
||||
)->willReturn($this->respWithTitle());
|
||||
|
||||
$result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateUrlWithTitleResolvesTitleWhenAutoResolutionIsEnabled(): void
|
||||
{
|
||||
$this->httpClient->expects($this->once())->method('request')->with(
|
||||
RequestMethodInterface::METHOD_GET,
|
||||
$this->anything(),
|
||||
$this->anything(),
|
||||
)->willReturn($this->respWithTitle());
|
||||
|
||||
$result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
|
||||
|
||||
self::assertEquals('Resolved "title"', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndReturnedContentTypeIsInvalid(): void
|
||||
{
|
||||
$this->httpClient->expects($this->once())->method('request')->with(
|
||||
RequestMethodInterface::METHOD_GET,
|
||||
$this->anything(),
|
||||
$this->anything(),
|
||||
)->willReturn(new Response('php://memory', 200, ['Content-Type' => 'application/octet-stream']));
|
||||
|
||||
$result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndBodyDoesNotContainTitle(): void
|
||||
{
|
||||
$this->httpClient->expects($this->once())->method('request')->with(
|
||||
RequestMethodInterface::METHOD_GET,
|
||||
$this->anything(),
|
||||
$this->anything(),
|
||||
)->willReturn(
|
||||
new Response($this->createStreamWithContent('<body>No title</body>'), 200, ['Content-Type' => 'text/html']),
|
||||
);
|
||||
|
||||
$result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
private function respWithTitle(): Response
|
||||
{
|
||||
$body = $this->createStreamWithContent('<title data-foo="bar"> Resolved "title" </title>');
|
||||
return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']);
|
||||
}
|
||||
|
||||
private function createStreamWithContent(string $content): Stream
|
||||
{
|
||||
$body = new Stream('php://temp', 'wr');
|
||||
$body->write($content);
|
||||
$body->rewind();
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function clientException(): ClientException
|
||||
{
|
||||
return new ClientException(
|
||||
'',
|
||||
new Request(RequestMethodInterface::METHOD_GET, ''),
|
||||
new Response(),
|
||||
);
|
||||
}
|
||||
|
||||
public function urlValidator(bool $autoResolveTitles = false): UrlValidator
|
||||
{
|
||||
return new UrlValidator($this->httpClient, new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles));
|
||||
}
|
||||
}
|
@ -224,27 +224,6 @@ class CreateShortUrlTest extends ApiTestCase
|
||||
yield ['http://téstb.shlink.io']; // Redirects to http://tést.shlink.io
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideInvalidUrls')]
|
||||
public function failsToCreateShortUrlWithInvalidLongUrl(string $url, string $version, string $expectedType): void
|
||||
{
|
||||
$expectedDetail = sprintf('Provided URL %s is invalid. Try with a different one.', $url);
|
||||
|
||||
[$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url, 'validateUrl' => true], version: $version);
|
||||
|
||||
self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode);
|
||||
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
self::assertEquals($expectedDetail, $payload['detail']);
|
||||
self::assertEquals('Invalid URL', $payload['title']);
|
||||
self::assertEquals($url, $payload['url']);
|
||||
}
|
||||
|
||||
public static function provideInvalidUrls(): iterable
|
||||
{
|
||||
yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'https://shlink.io/api/error/invalid-url'];
|
||||
yield 'API version 3' => ['https://this-has-to-be-invalid.com', '3', 'https://shlink.io/api/error/invalid-url'];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideInvalidArgumentApiVersions')]
|
||||
public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version, string $expectedType): void
|
||||
{
|
||||
|
@ -75,28 +75,16 @@ class EditShortUrlTest extends ApiTestCase
|
||||
return $matchingShortUrl['meta'] ?? [];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideLongUrls')]
|
||||
public function longUrlCanBeEditedIfItIsValid(string $longUrl, int $expectedStatus, ?string $expectedError): void
|
||||
public function longUrlCanBeEdited(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$url = sprintf('/short-urls/%s', $shortCode);
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => [
|
||||
'longUrl' => $longUrl,
|
||||
'validateUrl' => true,
|
||||
'longUrl' => 'https://shlink.io',
|
||||
]]);
|
||||
|
||||
self::assertEquals($expectedStatus, $resp->getStatusCode());
|
||||
if ($expectedError !== null) {
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
self::assertEquals($expectedError, $payload['type']);
|
||||
}
|
||||
}
|
||||
|
||||
public static function provideLongUrls(): iterable
|
||||
{
|
||||
yield 'valid URL' => ['https://shlink.io', self::STATUS_OK, null];
|
||||
yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'https://shlink.io/api/error/invalid-url'];
|
||||
self::assertEquals(self::STATUS_OK, $resp->getStatusCode());
|
||||
}
|
||||
|
||||
#[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')]
|
||||
|
Loading…
Reference in New Issue
Block a user