From f5dcc52b3b327556c91d2411b339e93ae23c76f4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 26 Apr 2024 09:27:21 +0200 Subject: [PATCH 1/8] Migrate to new docker-publish-image reusable workflow --- .github/workflows/publish-docker-image.yml | 2 +- CHANGELOG.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml index a57ebe41..32bbcda7 100644 --- a/.github/workflows/publish-docker-image.yml +++ b/.github/workflows/publish-docker-image.yml @@ -15,7 +15,7 @@ jobs: - runtime: 'rr' tag-suffix: 'roadrunner' platforms: 'linux/arm64/v8,linux/amd64' - uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main + uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main secrets: inherit with: image-name: shlinkio/shlink diff --git a/CHANGELOG.md b/CHANGELOG.md index 08738f51..49d8f5de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* Use new reusable workflow to publish docker image + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [4.1.0] - 2024-04-14 ### Added * [#1330](https://github.com/shlinkio/shlink/issues/1330) All visit-related endpoints now expose the `visitedUrl` prop for any visit. From 83584a31751d7c46c0d215efbd25cd7216733965 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 21 Apr 2024 16:46:24 +0200 Subject: [PATCH 2/8] Link crchived changelogs from main one --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d8f5de..1cd56364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -841,3 +841,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * *Nothing* + + +## Older versions +* [2.x.x](docs/changelog-archive/CHANGELOG-2.x.md) +* [1.x.x](docs/changelog-archive/CHANGELOG-1.x.md) From c6109fd396ac057b744bef17fb623153ab5bb72a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 29 Apr 2024 15:22:32 +0200 Subject: [PATCH 3/8] Merge pull request #2115 from acelaya-forks/feature/fix-oas-docs Fix typo in OAS docs --- CHANGELOG.md | 2 +- .../v3_short-urls_{shortCode}_redirect-rules.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd56364..09ff8801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed -* *Nothing* +* [#2111](https://github.com/shlinkio/shlink/issues/2111) Fix typo in OAS docs examples where redirect rules with `query-param` condition type were defined as `query`. ## [4.1.0] - 2024-04-14 diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json index b87e26cb..ecb80693 100644 --- a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -77,12 +77,12 @@ "priority": 3, "conditions": [ { - "type": "query", + "type": "query-param", "matchKey": "foo", "matchValue": "bar" }, { - "type": "query", + "type": "query-param", "matchKey": "hello", "matchValue": "world" } @@ -209,12 +209,12 @@ "longUrl": "https://example.com/query-foo-bar-hello-world", "conditions": [ { - "type": "query", + "type": "query-param", "matchKey": "foo", "matchValue": "bar" }, { - "type": "query", + "type": "query-param", "matchKey": "hello", "matchValue": "world" } @@ -280,12 +280,12 @@ "priority": 3, "conditions": [ { - "type": "query", + "type": "query-param", "matchKey": "foo", "matchValue": "bar" }, { - "type": "query", + "type": "query-param", "matchKey": "hello", "matchValue": "world" } From de70ebe769db14f24f4e2988e73445292695a8a9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 May 2024 13:22:26 +0200 Subject: [PATCH 4/8] Merge pull request #2125 from acelaya-forks/feature/phpunit-11 Update to PHPUnit 11 --- CHANGELOG.md | 1 + composer.json | 8 ++--- .../MatomoSendVisitsCommandTest.php | 4 +-- module/CLI/test/Util/CliTestUtils.php | 1 + .../NotFoundRedirectHandlerTest.php | 17 ++++++++--- .../RabbitMq/NotifyVisitToRabbitMqTest.php | 29 +++++++++++-------- 6 files changed, 38 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ff8801..e06c3572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * Use new reusable workflow to publish docker image +* [#2015](https://github.com/shlinkio/shlink/issues/2015) Update to PHPUnit 11. ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 517caf0c..48ea06aa 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "guzzlehttp/guzzle": "^7.5", "jaybizzle/crawler-detect": "^1.2.116", "laminas/laminas-config": "^3.8", - "laminas/laminas-config-aggregator": "^1.13", + "laminas/laminas-config-aggregator": "^1.15", "laminas/laminas-diactoros": "^3.3", "laminas/laminas-inputfilter": "^2.27", "laminas/laminas-servicemanager": "^3.21", @@ -67,9 +67,9 @@ "phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-symfony": "^1.3", - "phpunit/php-code-coverage": "^10.1", - "phpunit/phpcov": "^9.0", - "phpunit/phpunit": "^10.4", + "phpunit/php-code-coverage": "^11.0", + "phpunit/phpcov": "^10.0", + "phpunit/phpunit": "^11.1", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", "shlinkio/shlink-test-utils": "^4.1", diff --git a/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php index e3a52733..78d2f828 100644 --- a/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php +++ b/module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php @@ -34,8 +34,8 @@ class MatomoSendVisitsCommandTest extends TestCase } #[Test] - #[TestWith([true])] - #[TestWith([false])] + #[TestWith([true], 'interactive')] + #[TestWith([false], 'not interactive')] public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void { $this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult()); diff --git a/module/CLI/test/Util/CliTestUtils.php b/module/CLI/test/Util/CliTestUtils.php index 9c92f882..c18186ba 100644 --- a/module/CLI/test/Util/CliTestUtils.php +++ b/module/CLI/test/Util/CliTestUtils.php @@ -25,6 +25,7 @@ class CliTestUtils $command = $generator->testDouble( Command::class, mockObject: true, + markAsMockObject: true, callOriginalConstructor: false, callOriginalClone: false, cloneArguments: false, diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index d8a0390d..53642f3a 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Rule\InvokedCount as InvokedCountMatcher; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; @@ -60,14 +61,19 @@ class NotFoundRedirectHandlerTest extends TestCase public static function provideNonRedirectScenarios(): iterable { + $exactly = static fn (int $expectedCount) => new InvokedCountMatcher($expectedCount); + $once = static fn () => $exactly(1); + yield 'no domain' => [function ( MockObject&DomainServiceInterface $domainService, MockObject&NotFoundRedirectResolverInterface $resolver, + ) use ( + $once, ): void { - $domainService->expects(self::once())->method('findByAuthority')->withAnyParameters()->willReturn( + $domainService->expects($once())->method('findByAuthority')->withAnyParameters()->willReturn( null, ); - $resolver->expects(self::once())->method('resolveRedirectResponse')->with( + $resolver->expects($once())->method('resolveRedirectResponse')->with( self::isInstanceOf(NotFoundType::class), self::isInstanceOf(NotFoundRedirectOptions::class), self::isInstanceOf(UriInterface::class), @@ -76,12 +82,15 @@ class NotFoundRedirectHandlerTest extends TestCase yield 'non-redirecting domain' => [function ( MockObject&DomainServiceInterface $domainService, MockObject&NotFoundRedirectResolverInterface $resolver, + ) use ( + $once, + $exactly, ): void { - $domainService->expects(self::once())->method('findByAuthority')->withAnyParameters()->willReturn( + $domainService->expects($once())->method('findByAuthority')->withAnyParameters()->willReturn( Domain::withAuthority(''), ); $callCount = 0; - $resolver->expects(self::exactly(2))->method('resolveRedirectResponse')->willReturnCallback( + $resolver->expects($exactly(2))->method('resolveRedirectResponse')->willReturnCallback( function (mixed $arg1, mixed $arg2, mixed $arg3) use (&$callCount) { Assert::assertInstanceOf(NotFoundType::class, $arg1); Assert::assertInstanceOf($callCount === 0 ? Domain::class : NotFoundRedirectOptions::class, $arg2); diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 7386169f..2251d301 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -10,6 +10,7 @@ use Exception; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Rule\InvokedCount as InvokedCountMatcher; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use RuntimeException; @@ -146,30 +147,34 @@ class NotifyVisitToRabbitMqTest extends TestCase public static function providePayloads(): iterable { + $exactly = static fn (int $expectedCount) => new InvokedCountMatcher($expectedCount); + $once = static fn () => $exactly(1); + $never = static fn () => $exactly(0); + yield 'non-orphan visit' => [ Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), - function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { + function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator) use ($once, $never): void { $update = Update::forTopicAndPayload('', []); - $updatesGenerator->expects(self::never())->method('newOrphanVisitUpdate'); - $updatesGenerator->expects(self::once())->method('newVisitUpdate')->withAnyParameters()->willReturn( + $updatesGenerator->expects($never())->method('newOrphanVisitUpdate'); + $updatesGenerator->expects($once())->method('newVisitUpdate')->withAnyParameters()->willReturn( $update, ); - $updatesGenerator->expects(self::once())->method('newShortUrlVisitUpdate')->willReturn($update); + $updatesGenerator->expects($once())->method('newShortUrlVisitUpdate')->willReturn($update); }, - function (MockObject & PublishingHelperInterface $helper): void { - $helper->expects(self::exactly(2))->method('publishUpdate')->with(self::isInstanceOf(Update::class)); + function (MockObject & PublishingHelperInterface $helper) use ($exactly): void { + $helper->expects($exactly(2))->method('publishUpdate')->with(self::isInstanceOf(Update::class)); }, ]; yield 'orphan visit' => [ Visit::forBasePath(Visitor::emptyInstance()), - function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { + function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator) use ($once, $never): void { $update = Update::forTopicAndPayload('', []); - $updatesGenerator->expects(self::once())->method('newOrphanVisitUpdate')->willReturn($update); - $updatesGenerator->expects(self::never())->method('newVisitUpdate'); - $updatesGenerator->expects(self::never())->method('newShortUrlVisitUpdate'); + $updatesGenerator->expects($once())->method('newOrphanVisitUpdate')->willReturn($update); + $updatesGenerator->expects($never())->method('newVisitUpdate'); + $updatesGenerator->expects($never())->method('newShortUrlVisitUpdate'); }, - function (MockObject & PublishingHelperInterface $helper): void { - $helper->expects(self::once())->method('publishUpdate')->with(self::isInstanceOf(Update::class)); + function (MockObject & PublishingHelperInterface $helper) use ($once): void { + $helper->expects($once())->method('publishUpdate')->with(self::isInstanceOf(Update::class)); }, ]; } From 02717eb2fb5de70049ad999f668ecd86e9bada5f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 May 2024 17:58:53 +0200 Subject: [PATCH 5/8] Merge pull request #2130 from marijnvandevoorde/nanoid Replaces short-id by nano-id --- composer.json | 2 +- module/Core/functions/functions.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 48ea06aa..571545fb 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.5", + "hidehalo/nanoid-php": "^1.1", "jaybizzle/crawler-detect": "^1.2.116", "laminas/laminas-config": "^3.8", "laminas/laminas-config-aggregator": "^1.15", @@ -40,7 +41,6 @@ "mlocati/ip-lib": "^1.18", "mobiledetect/mobiledetectlib": "^4.8", "pagerfanta/core": "^3.8", - "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", "shlinkio/shlink-common": "^6.1", diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 2e238e99..8a11209d 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -9,11 +9,11 @@ use Cake\Chronos\Chronos; use DateTimeInterface; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use GuzzleHttp\Psr7\Query; +use Hidehalo\Nanoid\Client; use Jaybizzle\CrawlerDetect\CrawlerDetect; use Laminas\Filter\Word\CamelCaseToSeparator; use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\InputFilter\InputFilter; -use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; @@ -39,13 +39,13 @@ function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode: { static $shortIdFactory; if ($shortIdFactory === null) { - $shortIdFactory = new ShortIdFactory(); + $shortIdFactory = new Client(); } $alphabet = $mode === ShortUrlMode::STRICT ? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' : '0123456789abcdefghijklmnopqrstuvwxyz'; - return $shortIdFactory->generate($length, $alphabet)->serialize(); + return $shortIdFactory->formattedId($alphabet, $length); } function parseDateFromQuery(array $query, string $dateName): ?Chronos From c855f011d19e542fd2d1d2de645e748264008fde Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 May 2024 19:05:39 +0200 Subject: [PATCH 6/8] Merge pull request #2132 from acelaya-forks/feature/update-phpstan Update to latest phpstan --- CHANGELOG.md | 1 + composer.json | 8 ++++---- docker-compose.yml | 2 +- module/Core/functions/functions.php | 10 +++++----- module/Core/test/Matomo/MatomoTrackerBuilderTest.php | 4 ++-- phpstan.neon | 4 ++-- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e06c3572..f700041b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * Use new reusable workflow to publish docker image * [#2015](https://github.com/shlinkio/shlink/issues/2015) Update to PHPUnit 11. +* [#2130](https://github.com/shlinkio/shlink/pull/2130) Replace deprecated `pugx/shortid-php` package with `hidehalo/nanoid-php`. ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 571545fb..0a0a5460 100644 --- a/composer.json +++ b/composer.json @@ -63,10 +63,10 @@ "require-dev": { "devizzent/cebe-php-openapi": "^1.0.1", "devster/ubench": "^2.1", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-doctrine": "^1.3", - "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-symfony": "^1.3", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-doctrine": "^1.4", + "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan-symfony": "^1.4", "phpunit/php-code-coverage": "^11.0", "phpunit/phpcov": "^10.0", "phpunit/phpunit": "^11.1", diff --git a/docker-compose.yml b/docker-compose.yml index 5bccfd48..414d7adb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -105,7 +105,7 @@ services: shlink_db_ms: container_name: shlink_db_ms - image: mcr.microsoft.com/mssql/server:2019-latest + image: mcr.microsoft.com/mssql/server:2022-latest ports: - "1433:1433" environment: diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 8a11209d..d9fc2b5e 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -9,7 +9,7 @@ use Cake\Chronos\Chronos; use DateTimeInterface; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use GuzzleHttp\Psr7\Query; -use Hidehalo\Nanoid\Client; +use Hidehalo\Nanoid\Client as NanoidClient; use Jaybizzle\CrawlerDetect\CrawlerDetect; use Laminas\Filter\Word\CamelCaseToSeparator; use Laminas\Filter\Word\CamelCaseToUnderscore; @@ -37,15 +37,15 @@ use function ucfirst; function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string { - static $shortIdFactory; - if ($shortIdFactory === null) { - $shortIdFactory = new Client(); + static $nanoIdClient; + if ($nanoIdClient === null) { + $nanoIdClient = new NanoidClient(); } $alphabet = $mode === ShortUrlMode::STRICT ? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' : '0123456789abcdefghijklmnopqrstuvwxyz'; - return $shortIdFactory->formattedId($alphabet, $length); + return $nanoIdClient->formattedId($alphabet, $length); } function parseDateFromQuery(array $query, string $dateName): ?Chronos diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php index 5a4e6ab0..1b55405e 100644 --- a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -37,8 +37,8 @@ class MatomoTrackerBuilderTest extends TestCase { $tracker = $this->builder()->buildMatomoTracker(); - self::assertEquals('api_token', $tracker->token_auth); // @phpstan-ignore-line - self::assertEquals(5, $tracker->idSite); // @phpstan-ignore-line + self::assertEquals('api_token', $tracker->token_auth); + self::assertEquals(5, $tracker->idSite); self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestTimeout()); self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout()); } diff --git a/phpstan.neon b/phpstan.neon index eee4853b..9ebaec6e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,8 +4,6 @@ includes: - vendor/phpstan/phpstan-phpunit/extension.neon - vendor/phpstan/phpstan-phpunit/rules.neon parameters: - checkMissingIterableValueType: false - checkGenericClassInNonGenericObjectType: false symfony: console_application_loader: 'config/cli-app.php' doctrine: @@ -14,3 +12,5 @@ parameters: ignoreErrors: - '#should return int<0, max> but returns int#' - '#expects -1|int<1, max>, int given#' + - identifier: missingType.generics + - identifier: missingType.iterableValue From fb4fecf41192793a7d473fd788be92ea368d533a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 May 2024 18:14:56 +0200 Subject: [PATCH 7/8] Merge pull request #2135 from acelaya-forks/feature/non-utf8-titles Convert encoding of resolved titles based on page encoding --- CHANGELOG.md | 1 + composer.json | 1 + config/constants.php | 1 - .../Helper/ShortUrlTitleResolutionHelper.php | 29 +++++++++++++------ .../ShortUrlTitleResolutionHelperTest.php | 11 ++++--- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f700041b..2caa4152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * [#2111](https://github.com/shlinkio/shlink/issues/2111) Fix typo in OAS docs examples where redirect rules with `query-param` condition type were defined as `query`. +* [#2129](https://github.com/shlinkio/shlink/issues/2129) Fix error when resolving title for sites not using UTF-8 charset (detected with Japanese charsets). ## [4.1.0] - 2024-04-14 diff --git a/composer.json b/composer.json index 0a0a5460..fe2644ce 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "ext-curl": "*", "ext-gd": "*", "ext-json": "*", + "ext-mbstring": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^3.0.2", diff --git a/config/constants.php b/config/constants.php index 51ee0476..20c64f19 100644 --- a/config/constants.php +++ b/config/constants.php @@ -12,7 +12,6 @@ const MIN_SHORT_CODES_LENGTH = 4; const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; -const TITLE_TAG_VALUE = '/]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema. const DEFAULT_QR_CODE_SIZE = 300; const DEFAULT_QR_CODE_MARGIN = 0; diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index e91b1ff1..3f9b6225 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -12,20 +12,24 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Throwable; use function html_entity_decode; +use function mb_convert_encoding; 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 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'; + // Matches the value inside a html title tag + private const TITLE_TAG_VALUE = '/]*>(.*?)<\/title>/i'; + // Matches the charset inside a Content-Type header + private const CHARSET_VALUE = '/charset=([^;]+)/i'; + public function __construct( private ClientInterface $httpClient, private UrlShortenerOptions $options, @@ -53,7 +57,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH return $data; } - $title = $this->tryToResolveTitle($response); + $title = $this->tryToResolveTitle($response, $contentType); return $title !== null ? $data->withResolvedTitle($title) : $data; } @@ -76,7 +80,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH } } - private function tryToResolveTitle(ResponseInterface $response): ?string + private function tryToResolveTitle(ResponseInterface $response, string $contentType): ?string { $collectedBody = ''; $body = $response->getBody(); @@ -84,12 +88,19 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH while (! str_contains($collectedBody, '') && ! $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 - { + // Try to match the title from the tag + preg_match(self::TITLE_TAG_VALUE, $collectedBody, $titleMatches); + if (! isset($titleMatches[1])) { + return null; + } + + // Get the page's charset from Content-Type header + preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches); + + $title = isset($charsetMatches[1]) + ? mb_convert_encoding($titleMatches[1], 'utf8', $charsetMatches[1]) + : $titleMatches[1]; return html_entity_decode(trim($title)); } } diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php index 06b47f8c..92fac8eb 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -12,6 +12,7 @@ use Laminas\Diactoros\Response; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Stream; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\Builder\InvocationMocker; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -89,10 +90,12 @@ class ShortUrlTitleResolutionHelperTest extends TestCase } #[Test] - public function titleIsUpdatedWhenItCanBeResolvedFromResponse(): void + #[TestWith(['TEXT/html; charset=utf-8'], name: 'charset')] + #[TestWith(['TEXT/html'], name: 'no charset')] + public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType): void { $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); - $this->expectRequestToBeCalled()->willReturn($this->respWithTitle()); + $this->expectRequestToBeCalled()->willReturn($this->respWithTitle($contentType)); $result = $this->helper(autoResolveTitles: true)->processTitle($data); @@ -122,10 +125,10 @@ class ShortUrlTitleResolutionHelperTest extends TestCase return new Response($body, 200, ['Content-Type' => 'text/html']); } - private function respWithTitle(): Response + private function respWithTitle(string $contentType): Response { $body = $this->createStreamWithContent('<title data-foo="bar"> Resolved "title" '); - return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']); + return new Response($body, 200, ['Content-Type' => $contentType]); } private function createStreamWithContent(string $content): Stream From 67ae05f473c521b7294d010cca3d86414c09e177 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 23 May 2024 09:18:58 +0200 Subject: [PATCH 8/8] Add v4.1.1 to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2caa4152..cbbccc47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [4.1.1] - 2024-05-23 ### Added * *Nothing*