Skip to content

Commit

Permalink
Merge pull request #2136 from acelaya-forks/release/4.1.1
Browse files Browse the repository at this point in the history
Release/4.1.1
  • Loading branch information
acelaya authored May 23, 2024
2 parents 35508e2 + 67ae05f commit b2dabf0
Show file tree
Hide file tree
Showing 15 changed files with 112 additions and 58 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/publish-docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ 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).

## [4.1.1] - 2024-05-23
### Added
* *Nothing*

### 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*

### Removed
* *Nothing*

### 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
### Added
* [#1330](https://github.com/shlinkio/shlink/issues/1330) All visit-related endpoints now expose the `visitedUrl` prop for any visit.
Expand Down Expand Up @@ -824,3 +844,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)
19 changes: 10 additions & 9 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -26,9 +27,10 @@
"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.13",
"laminas/laminas-config-aggregator": "^1.15",
"laminas/laminas-diactoros": "^3.3",
"laminas/laminas-inputfilter": "^2.27",
"laminas/laminas-servicemanager": "^3.21",
Expand All @@ -40,7 +42,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",
Expand All @@ -63,13 +64,13 @@
"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",
"phpunit/php-code-coverage": "^10.1",
"phpunit/phpcov": "^9.0",
"phpunit/phpunit": "^10.4",
"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",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^4.1",
Expand Down
1 change: 0 additions & 1 deletion config/constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
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[^>]*>(.*?)<\/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;
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ public function warningDisplayedIfIntegrationIsNotEnabled(): void
}

#[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());
Expand Down
1 change: 1 addition & 0 deletions module/CLI/test/Util/CliTestUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public static function createCommandMock(string $name): MockObject & Command
$command = $generator->testDouble(
Command::class,
mockObject: true,
markAsMockObject: true,
callOriginalConstructor: false,
callOriginalClone: false,
cloneArguments: false,
Expand Down
10 changes: 5 additions & 5 deletions module/Core/functions/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use GuzzleHttp\Psr7\Query;
use Hidehalo\Nanoid\Client as NanoidClient;
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;

Expand All @@ -37,15 +37,15 @@

function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
{
static $shortIdFactory;
if ($shortIdFactory === null) {
$shortIdFactory = new ShortIdFactory();
static $nanoIdClient;
if ($nanoIdClient === null) {
$nanoIdClient = new NanoidClient();
}

$alphabet = $mode === ShortUrlMode::STRICT
? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
: '0123456789abcdefghijklmnopqrstuvwxyz';
return $shortIdFactory->generate($length, $alphabet)->serialize();
return $nanoIdClient->formattedId($alphabet, $length);
}

function parseDateFromQuery(array $query, string $dateName): ?Chronos
Expand Down
29 changes: 20 additions & 9 deletions module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,24 @@
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[^>]*>(.*?)<\/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,
Expand Down Expand Up @@ -53,7 +57,7 @@ public function processTitle(TitleResolutionModelInterface $data): TitleResoluti
return $data;
}

$title = $this->tryToResolveTitle($response);
$title = $this->tryToResolveTitle($response, $contentType);
return $title !== null ? $data->withResolvedTitle($title) : $data;
}

Expand All @@ -76,20 +80,27 @@ private function fetchUrl(string $url): ?ResponseInterface
}
}

private function tryToResolveTitle(ResponseInterface $response): ?string
private function tryToResolveTitle(ResponseInterface $response, string $contentType): ?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
{
// Try to match the title from the <title /> 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));
}
}
17 changes: 13 additions & 4 deletions module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -60,14 +61,19 @@ public function nextIsCalledWhenNoRedirectIsResolved(callable $setUp): void

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),
Expand All @@ -76,12 +82,15 @@ public static function provideNonRedirectScenarios(): iterable
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -146,30 +147,34 @@ public function expectedPayloadIsPublishedDependingOnConfig(

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));
},
];
}
Expand Down
4 changes: 2 additions & 2 deletions module/Core/test/Matomo/MatomoTrackerBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ public function trackerIsCreated(): void
{
$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());
}
Expand Down
Loading

0 comments on commit b2dabf0

Please sign in to comment.