Compare commits

..

18 Commits

Author SHA1 Message Date
Alejandro Celaya
888dc84d3f Merge pull request #2348 from shlinkio/develop
Release 4.4.2
2025-01-29 12:08:51 +01:00
Alejandro Celaya
ed09bf90eb Tag v4.4.2 in changelog 2025-01-29 12:05:53 +01:00
Alejandro Celaya
0ddfcb75dd Merge pull request #2347 from acelaya-forks/feature/docker-arm
Get back docker image building for ARM architecture
2025-01-29 12:02:19 +01:00
Alejandro Celaya
193be55f0c Get back docker image building for ARM architecture 2025-01-29 11:59:42 +01:00
Alejandro Celaya
3ba7ad3839 Merge pull request #2345 from shlinkio/develop
Release 4.4.1 - fixes
2025-01-28 15:53:49 +01:00
Alejandro Celaya
7ffb64eee1 Do not build docker image for ARM 2025-01-28 15:51:20 +01:00
Alejandro Celaya
0a2cc554c6 Build docker image with buildx 0.19.2 2025-01-28 15:38:47 +01:00
Alejandro Celaya
7c2b918d5d Merge pull request #2344 from shlinkio/develop
Release 4.4.1
2025-01-28 10:15:24 +01:00
Alejandro Celaya
af783dea57 Add v4.4.1 to changelog 2025-01-28 10:12:15 +01:00
Alejandro Celaya
a68a17f6b4 Merge pull request #2343 from acelaya-forks/feature/defensive-title-encoding
Fix error when creating short URL for page with unsupported encoding
2025-01-28 10:11:04 +01:00
Alejandro Celaya
e9fe1ac5d4 Fix error when creating short URL for page with unsupported encoding 2025-01-28 10:04:30 +01:00
Alejandro Celaya
88e97f18ad Merge pull request #2342 from acelaya-forks/feature/too-many-connections
Close connections after every async job that uses the db
2025-01-27 15:48:22 +01:00
Alejandro Celaya
3372a2a9c8 Close connections after every async job that uses the db 2025-01-27 15:45:37 +01:00
Alejandro Celaya
f02a8c876c Merge pull request #2340 from acelaya-forks/feature/update-shlink-deps
Update shlink packages
2025-01-25 16:16:42 +01:00
Alejandro Celaya
1549509eb8 Update shlink packages 2025-01-25 16:13:40 +01:00
Alejandro Celaya
62fde5a8e2 Update changelog 2025-01-13 08:47:19 +01:00
Alejandro Celaya
221e061ea6 Merge pull request #2332 from MaZe3D/develop
Add ADDRESS environment vairable to define the listening interface.
2025-01-13 08:45:20 +01:00
Mark Orlando Zeller
9ad565f8c8 Add ADDRESS environment vairable to define the listening interface. 2025-01-10 22:10:51 +01:00
10 changed files with 165 additions and 24 deletions

View File

@@ -8,3 +8,5 @@ on:
jobs:
build-docker-image:
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
with:
platforms: 'linux/arm64/v8,linux/amd64'

View File

@@ -4,7 +4,42 @@ 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.4.0] - 2024-12-27
## [4.4.2] - 2025-01-29
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2346](https://github.com/shlinkio/shlink/issues/2346) Get back docker images for ARM architectures.
## [4.4.1] - 2025-01-28
### Added
* [#2331](https://github.com/shlinkio/shlink/issues/2331) Add `ADDRESS` env var which allows to customize the IP address to which RoadRunner binds, when using the official docker image.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2341](https://github.com/shlinkio/shlink/issues/2341) Ensure all asynchronous jobs that interact with the database do not leave idle connections open.
* [#2334](https://github.com/shlinkio/shlink/issues/2334) Improve how page titles are encoded to UTF-8, falling back from mbstring to iconv if available, and ultimately using the original title in case of error, but never causing the short URL creation to fail.
## [4.4.0] - 2024-12-27
### Added
* [#2265](https://github.com/shlinkio/shlink/issues/2265) Add a new `REDIRECT_EXTRA_PATH_MODE` option that accepts three values:
@@ -40,7 +75,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* *Nothing*
# [4.3.1] - 2024-11-25
## [4.3.1] - 2024-11-25
### Added
* *Nothing*

View File

@@ -43,12 +43,12 @@
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "^6.6",
"shlinkio/shlink-config": "^3.4",
"shlinkio/shlink-event-dispatcher": "^4.1",
"shlinkio/shlink-importer": "^5.5",
"shlinkio/shlink-installer": "^9.4",
"shlinkio/shlink-ip-geolocation": "^4.2",
"shlinkio/shlink-common": "^7.0",
"shlinkio/shlink-config": "^4.0",
"shlinkio/shlink-event-dispatcher": "^4.2",
"shlinkio/shlink-importer": "^5.6",
"shlinkio/shlink-installer": "^9.5",
"shlinkio/shlink-ip-geolocation": "^4.3",
"shlinkio/shlink-json": "^1.2",
"spiral/roadrunner": "^2024.3",
"spiral/roadrunner-cli": "^2.6",

View File

@@ -7,7 +7,7 @@ server:
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
http:
address: '0.0.0.0:${PORT:-8080}'
address: '${ADDRESS:-0.0.0.0}:${PORT:-8080}'
middleware: ['static']
static:
dir: '../../public'

View File

@@ -144,7 +144,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.15
image: dunglas/mercure:v0.18
ports:
- "3080:80"
environment:

View File

@@ -227,6 +227,7 @@ return [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [
'httpClient',
Config\Options\UrlShortenerOptions::class,
'Logger_Shlink',
],
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [
Config\Options\TrackingOptions::class,

View File

@@ -73,6 +73,9 @@ return (static function (): array {
],
'delegators' => [
EventDispatcher\Matomo\SendVisitToMatomo::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
@@ -94,6 +97,9 @@ return (static function (): array {
EventDispatcher\LocateUnlocatedVisits::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\UpdateGeoLiteDb::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
],
],

View File

@@ -11,7 +11,7 @@ class CloseDbConnectionEventListener
/** @var callable */
private $wrapped;
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
public function __construct(private readonly ReopeningEntityManagerInterface $em, callable $wrapped)
{
$this->wrapped = $wrapped;
}

View File

@@ -4,14 +4,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Closure;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\RequestOptions;
use Laminas\Stdlib\ErrorHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Throwable;
use function function_exists;
use function html_entity_decode;
use function iconv;
use function mb_convert_encoding;
use function preg_match;
use function str_contains;
@@ -30,9 +35,14 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
// Matches the charset inside a Content-Type header
private const string CHARSET_VALUE = '/charset=([^;]+)/i';
/**
* @param (Closure(): bool)|null $isIconvInstalled
*/
public function __construct(
private ClientInterface $httpClient,
private UrlShortenerOptions $options,
private LoggerInterface $logger,
private Closure|null $isIconvInstalled = null,
) {
}
@@ -58,7 +68,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
}
$title = $this->tryToResolveTitle($response, $contentType);
return $title !== null ? $data->withResolvedTitle($title) : $data;
return $title !== null ? $data->withResolvedTitle(html_entity_decode(trim($title))) : $data;
}
private function fetchUrl(string $url): ResponseInterface|null
@@ -84,6 +94,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
{
$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);
@@ -95,12 +106,48 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
return null;
}
// Get the page's charset from Content-Type header
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
$titleInOriginalEncoding = $titleMatches[1];
$title = isset($charsetMatches[1])
? mb_convert_encoding($titleMatches[1], 'utf8', $charsetMatches[1])
: $titleMatches[1];
return html_entity_decode(trim($title));
// Get the page's charset from Content-Type header, or return title as is if not found
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
if (! isset($charsetMatches[1])) {
return $titleInOriginalEncoding;
}
$pageCharset = $charsetMatches[1];
return $this->encodeToUtf8WithMbString($titleInOriginalEncoding, $pageCharset)
?? $this->encodeToUtf8WithIconv($titleInOriginalEncoding, $pageCharset)
?? $titleInOriginalEncoding;
}
private function encodeToUtf8WithMbString(string $titleInOriginalEncoding, string $pageCharset): string|null
{
try {
return mb_convert_encoding($titleInOriginalEncoding, 'utf-8', $pageCharset);
} catch (Throwable $e) {
$this->logger->warning('It was impossible to encode page title in UTF-8 with mb_convert_encoding. {e}', [
'e' => $e,
]);
return null;
}
}
private function encodeToUtf8WithIconv(string $titleInOriginalEncoding, string $pageCharset): string|null
{
$isIconvInstalled = ($this->isIconvInstalled ?? fn () => function_exists('iconv'))();
if (! $isIconvInstalled) {
$this->logger->warning('Missing iconv extension. Skipping title encoding');
return null;
}
try {
ErrorHandler::start();
$title = iconv($pageCharset, 'utf-8', $titleInOriginalEncoding);
ErrorHandler::stop(throw: true);
return $title ?: null;
} catch (Throwable $e) {
$this->logger->warning('It was impossible to encode page title in UTF-8 with iconv. {e}', ['e' => $e]);
return null;
}
}
}

View File

@@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
@@ -25,10 +26,12 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
private const string LONG_URL = 'http://foobar.com/12345/hello?foo=bar';
private MockObject & ClientInterface $httpClient;
private MockObject & LoggerInterface $logger;
protected function setUp(): void
{
$this->httpClient = $this->createMock(ClientInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
}
#[Test]
@@ -90,14 +93,59 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
}
#[Test]
#[TestWith(['TEXT/html; charset=utf-8'], 'charset')]
#[TestWith(['TEXT/html'], 'no charset')]
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType): void
#[TestWith(['TEXT/html', false], 'no charset')]
#[TestWith(['TEXT/html; charset=utf-8', false], 'mbstring-supported charset')]
#[TestWith(['TEXT/html; charset=Windows-1255', true], 'mbstring-unsupported charset')]
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType, bool $expectsWarning): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$this->expectRequestToBeCalled()->willReturn($this->respWithTitle($contentType));
if ($expectsWarning) {
$this->logger->expects($this->once())->method('warning')->with(
'It was impossible to encode page title in UTF-8 with mb_convert_encoding. {e}',
$this->isArray(),
);
} else {
$this->logger->expects($this->never())->method('warning');
}
$result = $this->helper(autoResolveTitles: true)->processTitle($data);
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$result = $this->helper(autoResolveTitles: true, iconvEnabled: true)->processTitle($data);
self::assertNotSame($data, $result);
self::assertEquals('Resolved "title"', $result->title);
}
#[Test]
#[TestWith([
'contentType' => 'text/html; charset=Windows-1255',
'iconvEnabled' => false,
'expectedSecondMessage' => 'Missing iconv extension. Skipping title encoding',
])]
#[TestWith([
'contentType' => 'text/html; charset=foo',
'iconvEnabled' => true,
'expectedSecondMessage' => 'It was impossible to encode page title in UTF-8 with iconv. {e}',
])]
public function warningsLoggedWhenTitleCannotBeEncodedToUtf8(
string $contentType,
bool $iconvEnabled,
string $expectedSecondMessage,
): void {
$this->expectRequestToBeCalled()->willReturn($this->respWithTitle($contentType));
$callCount = 0;
$this->logger->expects($this->exactly(2))->method('warning')->with($this->callback(
function (string $message) use (&$callCount, $expectedSecondMessage): bool {
$callCount++;
if ($callCount === 1) {
return $message === 'It was impossible to encode page title in UTF-8 with mb_convert_encoding. {e}';
}
return $message === $expectedSecondMessage;
},
));
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$result = $this->helper(autoResolveTitles: true, iconvEnabled: $iconvEnabled)->processTitle($data);
self::assertNotSame($data, $result);
self::assertEquals('Resolved "title"', $result->title);
@@ -143,11 +191,13 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
return $body;
}
private function helper(bool $autoResolveTitles = false): ShortUrlTitleResolutionHelper
private function helper(bool $autoResolveTitles = false, bool $iconvEnabled = false): ShortUrlTitleResolutionHelper
{
return new ShortUrlTitleResolutionHelper(
$this->httpClient,
new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles),
$this->logger,
fn () => $iconvEnabled,
);
}
}