Merge pull request #2491 from shlinkio/develop

Release 4.5.3
This commit is contained in:
Alejandro Celaya
2025-10-10 10:39:17 +02:00
committed by GitHub
16 changed files with 206 additions and 24 deletions

View File

@@ -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).
## [4.5.3] - 2025-10-10
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2488](https://github.com/shlinkio/shlink/issues/2488) Ensure `Access-Control-Allow-Credentials` is set in all cross-origin responses when `CORS_ALLOW_ORIGIN=true`.
## [4.5.2] - 2025-08-27
### Added
* *Nothing*

36
bin/frankenphp-worker.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\EmitterInterface;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use function frankenphp_handle_request;
use function gc_collect_cycles;
(static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$app = $container->get(Application::class);
$responseEmitter = $container->get(EmitterInterface::class);
$handler = static function () use ($app, $responseEmitter): void {
$response = $app->handle(ServerRequestFactory::fromGlobals());
$responseEmitter->emit($response);
};
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
for ($nbRequests = 0; !$maxRequests || $nbRequests < $maxRequests; ++$nbRequests) {
$keepRunning = frankenphp_handle_request($handler);
// Call the garbage collector to reduce the chances of it being triggered in the middle of a page generation
gc_collect_cycles();
if (! $keepRunning) {
break;
}
}
})();

View File

@@ -48,7 +48,7 @@
"shlinkio/shlink-event-dispatcher": "^4.3",
"shlinkio/shlink-importer": "^5.6",
"shlinkio/shlink-installer": "^9.6",
"shlinkio/shlink-ip-geolocation": "^4.3",
"shlinkio/shlink-ip-geolocation": "^4.4",
"shlinkio/shlink-json": "^1.2",
"spiral/roadrunner": "^2025.1",
"spiral/roadrunner-cli": "^2.7",
@@ -71,7 +71,7 @@
"phpunit/phpcov": "^11.0",
"phpunit/phpunit": "^12.0.10",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.4.2",
"shlinkio/php-coding-standard": "~2.5.0",
"shlinkio/shlink-test-utils": "^4.3.1",
"symfony/var-dumper": "^7.3",
"veewee/composer-run-parallel": "^1.4"

View File

@@ -8,7 +8,7 @@ return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data',
'temp_dir' => __DIR__ . '/../../data/temp-geolite',
'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(),
],

View File

@@ -4,13 +4,15 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
EnvVars::APP_ENV->value => 'dev',
// EnvVars::GEOLITE_LICENSE_KEY->value => '',
// URL shortener
EnvVars::DEFAULT_DOMAIN->value => 'localhost:8800',
EnvVars::DEFAULT_DOMAIN->value => runningInRoadRunner() ? 'localhost:8800' : 'localhost:8008',
EnvVars::IS_HTTPS_ENABLED->value => false,
// Database - MySQL

View File

@@ -0,0 +1,55 @@
FROM dunglas/frankenphp:1-php8.4-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install calendar
RUN apk add --no-cache oniguruma-dev
RUN docker-php-ext-install mbstring
RUN apk add --no-cache sqlite-libs
RUN apk add --no-cache sqlite-dev
RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install xdebug and sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
docker-php-ext-enable pdo_sqlsrv xdebug && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home
VOLUME /home/shlink
WORKDIR /home/shlink

View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
data/infra/frankenphp_caddy_data/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
data/temp-geolite/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -66,6 +66,37 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_frankenphp:
container_name: shlink_frankenphp
user: 1000:1000
build:
context: .
dockerfile: ./data/infra/frankenphp.Dockerfile
ports:
- "8008:8008"
volumes:
- ./:/home/shlink
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
- ./data/infra/frankenphp_caddy_data:/data
- ./data/infra/frankenphp_caddy_config:/config
links:
- shlink_db_mysql
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
FRANKENPHP_CONFIG: 'worker /home/shlink/bin/frankenphp-worker.php'
SERVER_NAME: ':8008 https:8009'
extra_hosts:
- 'host.docker.internal:host-gateway'
tty: true
shlink_db_mysql:
container_name: shlink_db_mysql
user: 1000:1000

View File

@@ -4,7 +4,7 @@ set -e
cd /etc/shlink
# Create data directories if they do not exist. This allows data dir to be mounted as an empty dir if needed
mkdir -p data/cache data/locks data/log data/proxies
mkdir -p data/cache data/locks data/log data/proxies data/temp-geolite
flags="--no-interaction --clear-db-cache"

View File

@@ -37,7 +37,19 @@ final readonly class CorsOptions
);
}
public function responseWithAllowOrigin(RequestInterface $request, ResponseInterface $response): ResponseInterface
/**
* Creates a new response which contains the CORS headers that apply to provided request
*/
public function responseWithCorsHeaders(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
$response = $this->responseWithAllowOrigin($request, $response);
return $this->allowCredentials ? $response->withHeader('Access-Control-Allow-Credentials', 'true') : $response;
}
/**
* If applicable, a new response with the appropriate Access-Control-Allow-Origin header is returned
*/
private function responseWithAllowOrigin(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
if ($this->allowOrigins === '*') {
return $response->withHeader('Access-Control-Allow-Origin', '*');

View File

@@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Core\Config\Options\CorsOptions;
class CorsOptionsTest extends TestCase
@@ -28,10 +29,30 @@ class CorsOptionsTest extends TestCase
self::assertEquals($expectedAllowOrigins, $options->allowOrigins);
self::assertEquals(
$expectedAllowOriginsHeader,
$options->responseWithAllowOrigin(
ServerRequestFactory::fromGlobals()->withHeader('Origin', 'https://example.com'),
new Response(),
)->getHeaderLine('Access-Control-Allow-Origin'),
$this->responseFromOptions($options)->getHeaderLine('Access-Control-Allow-Origin'),
);
}
#[Test]
#[TestWith([true])]
#[TestWith([false])]
public function expectedAccessControlAllowCredentialsIsSet(bool $allowCredentials): void
{
$options = new CorsOptions(allowCredentials: $allowCredentials);
$resp = $this->responseFromOptions($options);
if ($allowCredentials) {
self::assertEquals('true', $resp->getHeaderLine('Access-Control-Allow-Credentials'));
} else {
self::assertFalse($resp->hasHeader('Access-Control-Allow-Credentials'));
}
}
private function responseFromOptions(CorsOptions $options): ResponseInterface
{
return $options->responseWithCorsHeaders(
ServerRequestFactory::fromGlobals()->withHeader('Origin', 'https://example.com'),
new Response(),
);
}
}

View File

@@ -28,7 +28,7 @@ readonly class CrossDomainMiddleware implements MiddlewareInterface, RequestMeth
}
// Add Allow-Origin header
$response = $this->options->responseWithAllowOrigin($request, $response);
$response = $this->options->responseWithCorsHeaders($request, $response);
if ($request->getMethod() !== self::METHOD_OPTIONS) {
return $response;
}
@@ -38,18 +38,13 @@ readonly class CrossDomainMiddleware implements MiddlewareInterface, RequestMeth
private function addOptionsHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$corsHeaders = [
// Options requests should always be empty and have a 204 status code
return EmptyResponse::withHeaders([
...$response->getHeaders(),
'Access-Control-Allow-Methods' => $this->resolveCorsAllowedMethods($response),
'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'),
'Access-Control-Max-Age' => $this->options->maxAge,
];
if ($this->options->allowCredentials) {
$corsHeaders['Access-Control-Allow-Credentials'] = 'true';
}
// Options requests should always be empty and have a 204 status code
return EmptyResponse::withHeaders([...$response->getHeaders(), ...$corsHeaders]);
]);
}
private function resolveCorsAllowedMethods(ResponseInterface $response): string

View File

@@ -21,6 +21,7 @@ class CorsTest extends ApiTestCase
self::assertFalse($resp->hasHeader('Access-Control-Allow-Methods'));
self::assertFalse($resp->hasHeader('Access-Control-Max-Age'));
self::assertFalse($resp->hasHeader('Access-Control-Allow-Headers'));
self::assertFalse($resp->hasHeader('Access-Control-Allow-Credentials'));
}
#[Test, DataProvider('provideOrigins')]
@@ -38,6 +39,7 @@ class CorsTest extends ApiTestCase
self::assertFalse($resp->hasHeader('Access-Control-Allow-Methods'));
self::assertFalse($resp->hasHeader('Access-Control-Max-Age'));
self::assertFalse($resp->hasHeader('Access-Control-Allow-Headers'));
self::assertFalse($resp->hasHeader('Access-Control-Allow-Credentials'));
}
public static function provideOrigins(): iterable

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Middleware;
use Fig\Http\Message\RequestMethodInterface;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -142,13 +143,17 @@ class CrossDomainMiddlewareTest extends TestCase
}
#[Test]
#[TestWith([true])]
#[TestWith([false])]
public function credentialsAreAllowedIfConfiguredSo(bool $allowCredentials): void
#[TestWith([true, RequestMethodInterface::METHOD_OPTIONS])]
#[TestWith([false, RequestMethodInterface::METHOD_OPTIONS])]
#[TestWith([true, RequestMethodInterface::METHOD_GET])]
#[TestWith([false, RequestMethodInterface::METHOD_GET])]
#[TestWith([true, RequestMethodInterface::METHOD_POST])]
#[TestWith([false, RequestMethodInterface::METHOD_POST])]
public function credentialsAreAllowedIfConfiguredSo(bool $allowCredentials, string $method): void
{
$originalResponse = new Response();
$request = (new ServerRequest())
->withMethod('OPTIONS')
->withMethod($method)
->withHeader('Origin', 'local');
$this->handler->method('handle')->willReturn($originalResponse);