mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 04:03:12 +08:00
17
CHANGELOG.md
17
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).
|
||||
|
||||
## [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
36
bin/frankenphp-worker.php
Normal 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;
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
55
data/infra/frankenphp.Dockerfile
Normal file
55
data/infra/frankenphp.Dockerfile
Normal 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
|
||||
2
data/infra/frankenphp_caddy_config/.gitignore
vendored
Executable file
2
data/infra/frankenphp_caddy_config/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
data/infra/frankenphp_caddy_data/.gitignore
vendored
Executable file
2
data/infra/frankenphp_caddy_data/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
data/temp-geolite/.gitignore
vendored
Executable file
2
data/temp-geolite/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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', '*');
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user