mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-09 16:53:11 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
888dc84d3f | ||
|
|
ed09bf90eb | ||
|
|
0ddfcb75dd | ||
|
|
193be55f0c | ||
|
|
3ba7ad3839 | ||
|
|
7ffb64eee1 | ||
|
|
0a2cc554c6 | ||
|
|
7c2b918d5d | ||
|
|
af783dea57 | ||
|
|
a68a17f6b4 | ||
|
|
e9fe1ac5d4 | ||
|
|
88e97f18ad | ||
|
|
3372a2a9c8 | ||
|
|
f02a8c876c | ||
|
|
1549509eb8 | ||
|
|
62fde5a8e2 | ||
|
|
221e061ea6 | ||
|
|
9ad565f8c8 |
2
.github/workflows/ci-docker-image-build.yml
vendored
2
.github/workflows/ci-docker-image-build.yml
vendored
@@ -8,3 +8,5 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-docker-image:
|
build-docker-image:
|
||||||
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
|
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
|
||||||
|
with:
|
||||||
|
platforms: 'linux/arm64/v8,linux/amd64'
|
||||||
|
|||||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -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).
|
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
|
### Added
|
||||||
* [#2265](https://github.com/shlinkio/shlink/issues/2265) Add a new `REDIRECT_EXTRA_PATH_MODE` option that accepts three values:
|
* [#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*
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
# [4.3.1] - 2024-11-25
|
## [4.3.1] - 2024-11-25
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
||||||
|
|||||||
@@ -43,12 +43,12 @@
|
|||||||
"pagerfanta/core": "^3.8",
|
"pagerfanta/core": "^3.8",
|
||||||
"ramsey/uuid": "^4.7",
|
"ramsey/uuid": "^4.7",
|
||||||
"shlinkio/doctrine-specification": "^2.2",
|
"shlinkio/doctrine-specification": "^2.2",
|
||||||
"shlinkio/shlink-common": "^6.6",
|
"shlinkio/shlink-common": "^7.0",
|
||||||
"shlinkio/shlink-config": "^3.4",
|
"shlinkio/shlink-config": "^4.0",
|
||||||
"shlinkio/shlink-event-dispatcher": "^4.1",
|
"shlinkio/shlink-event-dispatcher": "^4.2",
|
||||||
"shlinkio/shlink-importer": "^5.5",
|
"shlinkio/shlink-importer": "^5.6",
|
||||||
"shlinkio/shlink-installer": "^9.4",
|
"shlinkio/shlink-installer": "^9.5",
|
||||||
"shlinkio/shlink-ip-geolocation": "^4.2",
|
"shlinkio/shlink-ip-geolocation": "^4.3",
|
||||||
"shlinkio/shlink-json": "^1.2",
|
"shlinkio/shlink-json": "^1.2",
|
||||||
"spiral/roadrunner": "^2024.3",
|
"spiral/roadrunner": "^2024.3",
|
||||||
"spiral/roadrunner-cli": "^2.6",
|
"spiral/roadrunner-cli": "^2.6",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ server:
|
|||||||
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
|
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
|
||||||
|
|
||||||
http:
|
http:
|
||||||
address: '0.0.0.0:${PORT:-8080}'
|
address: '${ADDRESS:-0.0.0.0}:${PORT:-8080}'
|
||||||
middleware: ['static']
|
middleware: ['static']
|
||||||
static:
|
static:
|
||||||
dir: '../../public'
|
dir: '../../public'
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ services:
|
|||||||
|
|
||||||
shlink_mercure:
|
shlink_mercure:
|
||||||
container_name: shlink_mercure
|
container_name: shlink_mercure
|
||||||
image: dunglas/mercure:v0.15
|
image: dunglas/mercure:v0.18
|
||||||
ports:
|
ports:
|
||||||
- "3080:80"
|
- "3080:80"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ return [
|
|||||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [
|
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [
|
||||||
'httpClient',
|
'httpClient',
|
||||||
Config\Options\UrlShortenerOptions::class,
|
Config\Options\UrlShortenerOptions::class,
|
||||||
|
'Logger_Shlink',
|
||||||
],
|
],
|
||||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [
|
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [
|
||||||
Config\Options\TrackingOptions::class,
|
Config\Options\TrackingOptions::class,
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ return (static function (): array {
|
|||||||
],
|
],
|
||||||
|
|
||||||
'delegators' => [
|
'delegators' => [
|
||||||
|
EventDispatcher\Matomo\SendVisitToMatomo::class => [
|
||||||
|
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||||
|
],
|
||||||
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
|
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
|
||||||
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||||
],
|
],
|
||||||
@@ -94,6 +97,9 @@ return (static function (): array {
|
|||||||
EventDispatcher\LocateUnlocatedVisits::class => [
|
EventDispatcher\LocateUnlocatedVisits::class => [
|
||||||
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||||
],
|
],
|
||||||
|
EventDispatcher\UpdateGeoLiteDb::class => [
|
||||||
|
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class CloseDbConnectionEventListener
|
|||||||
/** @var callable */
|
/** @var callable */
|
||||||
private $wrapped;
|
private $wrapped;
|
||||||
|
|
||||||
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
|
public function __construct(private readonly ReopeningEntityManagerInterface $em, callable $wrapped)
|
||||||
{
|
{
|
||||||
$this->wrapped = $wrapped;
|
$this->wrapped = $wrapped;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
|
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use Fig\Http\Message\RequestMethodInterface;
|
use Fig\Http\Message\RequestMethodInterface;
|
||||||
use GuzzleHttp\ClientInterface;
|
use GuzzleHttp\ClientInterface;
|
||||||
use GuzzleHttp\RequestOptions;
|
use GuzzleHttp\RequestOptions;
|
||||||
|
use Laminas\Stdlib\ErrorHandler;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
use function function_exists;
|
||||||
use function html_entity_decode;
|
use function html_entity_decode;
|
||||||
|
use function iconv;
|
||||||
use function mb_convert_encoding;
|
use function mb_convert_encoding;
|
||||||
use function preg_match;
|
use function preg_match;
|
||||||
use function str_contains;
|
use function str_contains;
|
||||||
@@ -30,9 +35,14 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
|
|||||||
// Matches the charset inside a Content-Type header
|
// Matches the charset inside a Content-Type header
|
||||||
private const string CHARSET_VALUE = '/charset=([^;]+)/i';
|
private const string CHARSET_VALUE = '/charset=([^;]+)/i';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (Closure(): bool)|null $isIconvInstalled
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ClientInterface $httpClient,
|
private ClientInterface $httpClient,
|
||||||
private UrlShortenerOptions $options,
|
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);
|
$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
|
private function fetchUrl(string $url): ResponseInterface|null
|
||||||
@@ -84,6 +94,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
|
|||||||
{
|
{
|
||||||
$collectedBody = '';
|
$collectedBody = '';
|
||||||
$body = $response->getBody();
|
$body = $response->getBody();
|
||||||
|
|
||||||
// With streaming enabled, we can walk the body until the </title> tag is found, and then stop
|
// With streaming enabled, we can walk the body until the </title> tag is found, and then stop
|
||||||
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
|
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
|
||||||
$collectedBody .= $body->read(1024);
|
$collectedBody .= $body->read(1024);
|
||||||
@@ -95,12 +106,48 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the page's charset from Content-Type header
|
$titleInOriginalEncoding = $titleMatches[1];
|
||||||
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
|
|
||||||
|
|
||||||
$title = isset($charsetMatches[1])
|
// Get the page's charset from Content-Type header, or return title as is if not found
|
||||||
? mb_convert_encoding($titleMatches[1], 'utf8', $charsetMatches[1])
|
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
|
||||||
: $titleMatches[1];
|
if (! isset($charsetMatches[1])) {
|
||||||
return html_entity_decode(trim($title));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\TestWith;
|
|||||||
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
|
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper;
|
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
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 const string LONG_URL = 'http://foobar.com/12345/hello?foo=bar';
|
||||||
|
|
||||||
private MockObject & ClientInterface $httpClient;
|
private MockObject & ClientInterface $httpClient;
|
||||||
|
private MockObject & LoggerInterface $logger;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->httpClient = $this->createMock(ClientInterface::class);
|
$this->httpClient = $this->createMock(ClientInterface::class);
|
||||||
|
$this->logger = $this->createMock(LoggerInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -90,14 +93,59 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestWith(['TEXT/html; charset=utf-8'], 'charset')]
|
#[TestWith(['TEXT/html', false], 'no charset')]
|
||||||
#[TestWith(['TEXT/html'], 'no charset')]
|
#[TestWith(['TEXT/html; charset=utf-8', false], 'mbstring-supported charset')]
|
||||||
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType): void
|
#[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));
|
$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::assertNotSame($data, $result);
|
||||||
self::assertEquals('Resolved "title"', $result->title);
|
self::assertEquals('Resolved "title"', $result->title);
|
||||||
@@ -143,11 +191,13 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
|
|||||||
return $body;
|
return $body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function helper(bool $autoResolveTitles = false): ShortUrlTitleResolutionHelper
|
private function helper(bool $autoResolveTitles = false, bool $iconvEnabled = false): ShortUrlTitleResolutionHelper
|
||||||
{
|
{
|
||||||
return new ShortUrlTitleResolutionHelper(
|
return new ShortUrlTitleResolutionHelper(
|
||||||
$this->httpClient,
|
$this->httpClient,
|
||||||
new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles),
|
new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles),
|
||||||
|
$this->logger,
|
||||||
|
fn () => $iconvEnabled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user