From 766b227e475a2eb6e30d7bc56b275f572ec7d441 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Aug 2025 08:21:44 +0200 Subject: [PATCH 1/6] Add a development FrankenPHP server --- bin/frankenphp-worker.php | 36 ++++++++++++ config/params/shlink_dev_env.php.dist | 4 +- data/infra/frankenphp.Dockerfile | 55 +++++++++++++++++++ data/infra/frankenphp_caddy_config/.gitignore | 2 + data/infra/frankenphp_caddy_data/.gitignore | 2 + docker-compose.yml | 31 +++++++++++ 6 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 bin/frankenphp-worker.php create mode 100644 data/infra/frankenphp.Dockerfile create mode 100755 data/infra/frankenphp_caddy_config/.gitignore create mode 100755 data/infra/frankenphp_caddy_data/.gitignore diff --git a/bin/frankenphp-worker.php b/bin/frankenphp-worker.php new file mode 100644 index 00000000..61740b4d --- /dev/null +++ b/bin/frankenphp-worker.php @@ -0,0 +1,36 @@ +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; + } + } +})(); diff --git a/config/params/shlink_dev_env.php.dist b/config/params/shlink_dev_env.php.dist index d9de7022..e5dddf35 100644 --- a/config/params/shlink_dev_env.php.dist +++ b/config/params/shlink_dev_env.php.dist @@ -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 diff --git a/data/infra/frankenphp.Dockerfile b/data/infra/frankenphp.Dockerfile new file mode 100644 index 00000000..22e3e580 --- /dev/null +++ b/data/infra/frankenphp.Dockerfile @@ -0,0 +1,55 @@ +FROM dunglas/frankenphp:1-php8.4-alpine +MAINTAINER Alejandro Celaya + +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 diff --git a/data/infra/frankenphp_caddy_config/.gitignore b/data/infra/frankenphp_caddy_config/.gitignore new file mode 100755 index 00000000..d6b7ef32 --- /dev/null +++ b/data/infra/frankenphp_caddy_config/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/data/infra/frankenphp_caddy_data/.gitignore b/data/infra/frankenphp_caddy_data/.gitignore new file mode 100755 index 00000000..d6b7ef32 --- /dev/null +++ b/data/infra/frankenphp_caddy_data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/docker-compose.yml b/docker-compose.yml index cc6966f0..847c257e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 From 075e6347b6efd378590a887e6d81cf0dd769f41e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 11 Sep 2025 09:28:44 +0200 Subject: [PATCH 2/6] Make GeoLite db download memory efficient --- composer.json | 2 +- config/autoload/geolite2.global.php | 2 +- data/temp-geolite/.gitignore | 2 ++ docker/docker-entrypoint.sh | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) create mode 100755 data/temp-geolite/.gitignore diff --git a/composer.json b/composer.json index 6c41d517..fd9c133b 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/autoload/geolite2.global.php b/config/autoload/geolite2.global.php index b31cfc6d..7c9df892 100644 --- a/config/autoload/geolite2.global.php +++ b/config/autoload/geolite2.global.php @@ -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(), ], diff --git a/data/temp-geolite/.gitignore b/data/temp-geolite/.gitignore new file mode 100755 index 00000000..d6b7ef32 --- /dev/null +++ b/data/temp-geolite/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index e1acd118..aca0e98c 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -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" From b01f271f72a926830c10e19cd2d4162f1c09560b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 3 Oct 2025 10:03:42 +0200 Subject: [PATCH 3/6] Make sure Access-Control-Allow-Credentials is always set if configured --- .../Core/src/Config/Options/CorsOptions.php | 14 ++++++++- .../test/Config/Options/CorsOptionsTest.php | 29 ++++++++++++++++--- .../src/Middleware/CrossDomainMiddleware.php | 15 ++++------ module/Rest/test-api/Middleware/CorsTest.php | 2 ++ .../Middleware/CrossDomainMiddlewareTest.php | 13 ++++++--- 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/module/Core/src/Config/Options/CorsOptions.php b/module/Core/src/Config/Options/CorsOptions.php index f4a23139..841bf9fc 100644 --- a/module/Core/src/Config/Options/CorsOptions.php +++ b/module/Core/src/Config/Options/CorsOptions.php @@ -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', '*'); diff --git a/module/Core/test/Config/Options/CorsOptionsTest.php b/module/Core/test/Config/Options/CorsOptionsTest.php index b02799b0..f7420269 100644 --- a/module/Core/test/Config/Options/CorsOptionsTest.php +++ b/module/Core/test/Config/Options/CorsOptionsTest.php @@ -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(), ); } } diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index 37360e2e..e54c07a7 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -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 diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php index 8198a21c..4ed3f2cb 100644 --- a/module/Rest/test-api/Middleware/CorsTest.php +++ b/module/Rest/test-api/Middleware/CorsTest.php @@ -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 diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index 5893ca8c..0b3abe3f 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -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); From 0bcb9e0438ef7b2a6ed66063f381a9fae8bd3b45 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 3 Oct 2025 10:24:38 +0200 Subject: [PATCH 4/6] Update changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6c1ea3..a7e3b6b1 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 +* *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* From 98bbb011658f11a88f02e9d5b145c00168d6a5b2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 6 Oct 2025 08:46:34 +0200 Subject: [PATCH 5/6] Update coding standard --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fd9c133b..f04124d4 100644 --- a/composer.json +++ b/composer.json @@ -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" From 774a579a946de69a258166fa593fea4c8669ec7a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 10 Oct 2025 10:29:06 +0200 Subject: [PATCH 6/6] Add v4.5.3 to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e3b6b1..6bc198d6 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.5.3] - 2025-10-10 ### Added * *Nothing*