Merge pull request #1143 from shlinkio/develop

Release 2.8.0
This commit is contained in:
Alejandro Celaya 2021-08-04 15:43:59 +02:00 committed by GitHub
commit ff6747dab5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
257 changed files with 3362 additions and 1445 deletions

View file

@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -63,7 +63,7 @@ jobs:
- run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-unit
path: |
@ -74,7 +74,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -89,7 +89,7 @@ jobs:
- run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-db
path: |
@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -120,7 +120,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -140,7 +140,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -160,7 +160,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -184,7 +184,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -201,7 +201,7 @@ jobs:
- run: composer install --no-interaction --prefer-dist
- run: bin/test/run-api-tests.sh
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-api
path: |
@ -216,7 +216,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
test-group: ['unit', 'db']
steps:
- name: Checkout code

View file

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
swoole: ['yes', 'no']
steps:
- name: Checkout code
@ -53,7 +53,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: [ '7.4', '8.0' ]
php-version: [ '8.0' ]
swoole: [ 'yes', 'no' ]
steps:
- uses: geekyeggo/delete-artifact@v1

View file

@ -4,6 +4,38 @@ 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).
## [2.8.0] - 2021-08-04
### Added
* [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`.
* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes.
Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High.
* [#1080](https://github.com/shlinkio/shlink/issues/1080) Added support to redirect to URLs as soon as the path starts with a valid short code, appending the rest of the path to the redirected long URL.
With this, if you have the `https://example.com/abc123` short URL redirecting to `https://www.twitter.com`, a visit to `https://example.com/abc123/shlinkio` will take you to `https://www.twitter.com/shlinkio`.
This behavior needs to be actively opted in, via installer config options or env vars.
* [#943](https://github.com/shlinkio/shlink/issues/943) Added support to define different "not-found" redirects for every domain handled by Shlink.
Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command or the `PATCH /domains/redirects` REST endpoint to define specific values for every single domain.
### Changed
* [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8.
* [#1127](https://github.com/shlinkio/shlink/issues/1127) Updated to infection 0.24.
* [#1139](https://github.com/shlinkio/shlink/issues/1139) Updated project dependencies, including base docker image to use PHP 8.0.9 and Alpine 3.14.
### Deprecated
* *Nothing*
### Removed
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
### Fixed
* *Nothing*
## [2.7.3] - 2021-08-02
### Added
* *Nothing*

View file

@ -121,7 +121,7 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
For example, `test:db:postgres`.
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.

View file

@ -1,8 +1,8 @@
FROM php:8.0.6-alpine3.13 as base
FROM php:8.0.9-alpine3.14 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.6.7
ENV SWOOLE_VERSION 4.7.0
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.1
ENV LC_ALL "C"
@ -78,4 +78,14 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
# Change the ownership of /etc/shlink/data to be writable, then change the user to non-root
# FIXME Disabled for now, as it conflicts with ENABLE_PERIODIC_VISIT_LOCATE, which is used to configure a cron as root.
# Ref: https://github.com/shlinkio/shlink/issues/1132
#RUN chown 1001 /etc/shlink/data
#RUN chown 1001 /etc/shlink/data/locks
#RUN chown 1001 /etc/shlink/data/proxies
#RUN chown 1001 /etc/shlink/data/cache
#RUN chown 1001 /etc/shlink/data/log
#USER 1001
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View file

@ -33,7 +33,7 @@ The idea is that you can just generate a container using the image and provide t
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 7.4 or 8.0
* PHP 8.0
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
* apcu extension is recommended if you don't plan to use swoole.
* xml extension is required if you want to generate QR codes in svg format.

View file

@ -3,5 +3,8 @@
declare(strict_types=1);
$run = require __DIR__ . '/../config/run.php';
$run(true);
use Symfony\Component\Console\Application;
/** @var Application $app */
$app = require __DIR__ . '/../config/cli-app.php';
$app->run();

View file

@ -12,69 +12,70 @@
}
],
"require": {
"php": "^7.4 || ^8.0",
"php": "^8.0",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.0",
"cakephp/chronos": "^2.0",
"cakephp/chronos": "^2.2",
"cocur/slugify": "^4.0",
"doctrine/cache": "^1.9",
"doctrine/migrations": "^3.1.1",
"doctrine/orm": "^2.8.4",
"endroid/qr-code": "^4.0",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.0",
"guzzlehttp/psr7": "^1.7",
"doctrine/cache": "^1.12",
"doctrine/migrations": "^3.2",
"doctrine/orm": "^2.9",
"endroid/qr-code": "^4.2",
"geoip2/geoip2": "^2.11",
"guzzlehttp/guzzle": "^7.3",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2",
"laminas/laminas-config": "^3.3",
"laminas/laminas-config-aggregator": "^1.1",
"laminas/laminas-diactoros": "^2.1.3",
"laminas/laminas-inputfilter": "^2.10",
"laminas/laminas-servicemanager": "^3.6",
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0",
"league/uri": "^6.2",
"laminas/laminas-config": "^3.5",
"laminas/laminas-config-aggregator": "^1.5",
"laminas/laminas-diactoros": "^2.6",
"laminas/laminas-inputfilter": "^2.12",
"laminas/laminas-servicemanager": "^3.7",
"laminas/laminas-stdlib": "^3.5",
"lcobucci/jwt": "^4.1",
"league/uri": "^6.4",
"lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.3",
"mezzio/mezzio-fastroute": "^3.1",
"mezzio/mezzio-problem-details": "^1.3",
"mezzio/mezzio": "^3.5",
"mezzio/mezzio-fastroute": "^3.2",
"mezzio/mezzio-problem-details": "^1.4",
"mezzio/mezzio-swoole": "^3.3",
"monolog/monolog": "^2.0",
"monolog/monolog": "^2.3",
"nikolaposa/monolog-factory": "^3.1",
"ocramius/proxy-manager": "^2.11",
"pagerfanta/core": "^2.5",
"pagerfanta/core": "^2.7",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.7",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-config": "^1.2",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3.1",
"shlinkio/shlink-installer": "^6.0",
"shlinkio/shlink-installer": "^6.1",
"shlinkio/shlink-ip-geolocation": "^2.0",
"symfony/console": "^5.1",
"symfony/filesystem": "^5.1",
"symfony/lock": "^5.1",
"symfony/mercure": "^0.5.1",
"symfony/process": "^5.1",
"symfony/string": "^5.1"
"symfony/console": "^5.3",
"symfony/filesystem": "^5.3",
"symfony/lock": "^5.3",
"symfony/mercure": "^0.5.3",
"symfony/process": "^5.3",
"symfony/string": "^5.3"
},
"require-dev": {
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.2.1",
"dms/phpunit-arraysubset-asserts": "^0.3.0",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.21.0",
"infection/infection": "^0.24.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^0.12.64",
"phpstan/phpstan": "^0.12.94",
"phpstan/phpstan-doctrine": "^0.12.42",
"phpstan/phpstan-symfony": "^0.12.41",
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.1",
"shlinkio/shlink-test-utils": "^2.1",
"symfony/var-dumper": "^5.2",
"veewee/composer-run-parallel": "^0.1.0"
"shlinkio/shlink-test-utils": "^2.2",
"symfony/var-dumper": "^5.3",
"veewee/composer-run-parallel": "^1.0"
},
"autoload": {
"psr-4": {
@ -113,7 +114,7 @@
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8",
"test": [
"@test:unit",
"@test:db",
@ -135,7 +136,7 @@
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db",

View file

@ -42,6 +42,7 @@ return [
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\AppendExtraPathConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,

View file

@ -68,6 +68,7 @@ return [
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
IpAddress::class,
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
Core\ErrorHandler\NotFoundRedirectHandler::class,
Core\ErrorHandler\NotFoundTemplateHandler::class,

View file

@ -16,9 +16,12 @@ return [
'validate_url' => false, // Deprecated
'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
'auto_resolve_titles' => false,
'append_extra_path' => false,
// TODO Move these two options to their own config namespace. Maybe "redirects".
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
'auto_resolve_titles' => false,
],
];

View file

@ -2,13 +2,16 @@
declare(strict_types=1);
$isSwoole = extension_loaded('swoole');
return [
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => 'localhost:8080',
'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'),
],
'auto_resolve_titles' => true,
],
];

12
config/cli-app.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
return (static function () {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
return $container->get(CliApp::class);
})();

View file

@ -4,12 +4,9 @@ declare(strict_types=1);
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Psr\Container\ContainerInterface;
return (function () {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
$em = $container->get(EntityManager::class);
return (static function () {
/** @var EntityManager $em */
$em = include __DIR__ . '/entity-manager.php';
return ConsoleRunner::createHelperSet($em);
})();

12
config/entity-manager.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
return (static function () {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
return $container->get(EntityManager::class);
})();

View file

@ -4,12 +4,11 @@ declare(strict_types=1);
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
return function (bool $isCli = false): void {
return static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
$app = $container->get($isCli ? CliApp::class : Application::class);
$app = $container->get(Application::class);
$app->run();
};

View file

@ -35,26 +35,17 @@ if ($isApiTest) {
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
}
$buildDbConnection = function (): array {
$buildDbConnection = static function (): array {
$driver = env('DB_DRIVER', 'sqlite');
$isCi = env('CI', false);
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
$getMysqlHost = static fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = static fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
$driverConfigMap = [
return match ($driver) {
'sqlite' => [
'driver' => 'pdo_sqlite',
'path' => sys_get_temp_dir() . '/shlink-tests.db',
],
'mysql' => [
'driver' => 'pdo_mysql',
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
'user' => 'root',
'password' => 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
],
'postgres' => [
'driver' => 'pdo_pgsql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
@ -71,10 +62,16 @@ $buildDbConnection = function (): array {
'password' => 'Passw0rd!',
'dbname' => 'shlink_test',
],
];
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
return $driverConfigMap[$driver] ?? [];
default => [ // mysql and maria
'driver' => 'pdo_mysql',
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
'user' => 'root',
'password' => 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
],
};
};
$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [
@ -120,7 +117,7 @@ return [
'name' => 'start_collecting_coverage',
'path' => '/api-tests/start-coverage',
'middleware' => middleware(static function () use (&$coverage) {
if ($coverage) {
if ($coverage) { // @phpstan-ignore-line
$coverage->start('API tests');
}
return new EmptyResponse();
@ -131,7 +128,7 @@ return [
'name' => 'dump_coverage',
'path' => '/api-tests/stop-coverage',
'middleware' => middleware(static function () use (&$coverage) {
if ($coverage) {
if ($coverage) { // @phpstan-ignore-line
$basePath = __DIR__ . '/../../build/coverage-api';
$coverage->stop();
(new PHP())->process($coverage, $basePath . '.cov');

View file

@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

View file

@ -1,7 +1,7 @@
FROM php:8.0.6-fpm-alpine3.13
FROM php:8.0.9-fpm-alpine3.14
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.19
ENV APCU_VERSION 5.1.20
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.1

View file

@ -1,10 +1,10 @@
FROM php:8.0.6-alpine3.13
FROM php:8.0.9-alpine3.14
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.19
ENV APCU_VERSION 5.1.20
ENV PDO_SQLSRV_VERSION 5.9.0
ENV INOTIFY_VERSION 3.0.0
ENV SWOOLE_VERSION 4.6.7
ENV SWOOLE_VERSION 4.7.0
ENV MS_ODBC_SQL_VERSION 17.5.2.1
RUN apk update

View file

@ -57,7 +57,7 @@ final class Version20180913205455 extends AbstractMigration
try {
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) {
} catch (InvalidArgumentException) {
return null;
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210720143824 extends AbstractMigration
{
public function up(Schema $schema): void
{
$domainsTable = $schema->getTable('domains');
$this->skipIf($domainsTable->hasColumn('base_url_redirect'));
$this->createRedirectColumn($domainsTable, 'base_url_redirect');
$this->createRedirectColumn($domainsTable, 'regular_not_found_redirect');
$this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect');
}
private function createRedirectColumn(Table $table, string $columnName): void
{
$table->addColumn($columnName, Types::STRING, [
'notnull' => false,
'default' => null,
]);
}
public function down(Schema $schema): void
{
$domainsTable = $schema->getTable('domains');
$this->skipIf(! $domainsTable->hasColumn('base_url_redirect'));
$domainsTable->dropColumn('base_url_redirect');
$domainsTable->dropColumn('regular_not_found_redirect');
$domainsTable->dropColumn('invalid_short_url_redirect');
}
}

View file

@ -111,9 +111,10 @@ return [
'validate_url' => (bool) env('VALIDATE_URLS', false),
'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
],
'tracking' => [

View file

@ -21,6 +21,15 @@ if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
php bin/cli visit:download-db -n -q
fi
# Periodicaly run visit:locate every hour
# https://shlink.io/documentation/long-running-tasks/#locate-visits
# set env var "ENABLE_PERIODIC_VISIT_LOCATE=true" to enable
if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
echo "Configuring periodic visit locate..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
# When restarting the container, swoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View file

@ -0,0 +1,20 @@
{
"type": "object",
"properties": {
"baseUrlRedirect": {
"type": "string",
"nullable": true,
"description": "URL to redirect to when a user hits the domain's base URL"
},
"regular404Redirect": {
"type": "string",
"nullable": true,
"description": "URL to redirect to when a user hits a not found URL other than an invalid short URL"
},
"invalidShortUrlRedirect": {
"type": "string",
"nullable": true,
"description": "URL to redirect to when a user hits an invalid short URL"
}
}
}

View file

@ -18,7 +18,7 @@
],
"responses": {
"200": {
"description": "The list of tags",
"description": "The list of domains",
"content": {
"application/json": {
"schema": {
@ -33,13 +33,16 @@
"type": "array",
"items": {
"type": "object",
"required": ["domain", "isDefault"],
"required": ["domain", "isDefault", "redirects"],
"properties": {
"domain": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"redirects": {
"$ref": "../definitions/NotFoundRedirects.json"
}
}
}
@ -56,15 +59,30 @@
"data": [
{
"domain": "example.com",
"isDefault": true
"isDefault": true,
"redirects": {
"baseUrlRedirect": "https://example.com/my-landing-page",
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
},
{
"domain": "aaa.com",
"isDefault": false
"isDefault": false,
"redirects": {
"baseUrlRedirect": null,
"regular404Redirect": null,
"invalidShortUrlRedirect": null
}
},
{
"domain": "bbb.com",
"isDefault": false
"isDefault": false,
"redirects": {
"baseUrlRedirect": null,
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
}
]
}

View file

@ -0,0 +1,124 @@
{
"patch": {
"operationId": "setDomainRedirects",
"tags": [
"Domains"
],
"summary": "Sets domain \"not found\" redirects",
"description": "Sets the URLs that you want a visitor to get redirected to for \not found\" URLs for a specific domain",
"security": [
{
"ApiKey": []
}
],
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"allOf": [
{
"required": ["domain"],
"properties": {
"domain": {
"description": "The domain's authority for which you want to set redirects",
"type": "string"
}
}
},
{
"$ref": "../definitions/NotFoundRedirects.json"
}
]
}
}
}
},
"responses": {
"200": {
"description": "The domain's redirects after the update, when existing redirects have been merged with provided ones.",
"content": {
"application/json": {
"schema": {
"allOf": [
{
"required": ["baseUrlRedirect", "regular404Redirect", "invalidShortUrlRedirect"]
},
{
"$ref": "../definitions/NotFoundRedirects.json"
}
]
}
}
},
"examples": {
"application/json": {
"baseUrlRedirect": "https://example.com/my-landing-page",
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
}
},
"400": {
"description": "Provided data is invalid.",
"content": {
"application/problem+json": {
"schema": {
"type": "object",
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["invalidElements"],
"properties": {
"invalidElements": {
"type": "array",
"items": {
"type": "string",
"enum": [
"domain",
"baseUrlRedirect",
"regular404Redirect",
"invalidShortUrlRedirect"
]
}
}
}
}
]
}
}
}
},
"403": {
"description": "Default domain was provided, and it cannot be edited this way.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View file

@ -5,7 +5,7 @@
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL",
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
"parameters": [
{
"name": "shortCode",
@ -35,10 +35,8 @@
"required": false,
"schema": {
"type": "string",
"enum": [
"png",
"svg"
]
"enum": ["png", "svg"],
"default": "png"
}
},
{
@ -51,6 +49,17 @@
"minimum": 0,
"default": 0
}
},
{
"name": "errorCorrection",
"in": "query",
"description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
"required": false,
"schema": {
"type": "string",
"enum": ["L", "M", "Q", "H"],
"default": "L"
}
}
],
"responses": {

View file

@ -1,5 +1,5 @@
{
"openapi": "3.0.0",
"openapi": "3.0.3",
"info": {
"title": "Shlink",
"description": "Shlink, the self-hosted URL shortener",
@ -102,6 +102,9 @@
"/rest/v{version}/domains": {
"$ref": "paths/v2_domains.json"
},
"/rest/v{version}/domains/redirects": {
"$ref": "paths/v2_domains_redirects.json"
},
"/rest/v{version}/mercure-info": {
"$ref": "paths/v2_mercure-info.json"

View file

@ -27,6 +27,7 @@ return [
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class,
Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class,
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,

View file

@ -61,6 +61,7 @@ return [
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
],
],
@ -104,6 +105,7 @@ return [
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,

View file

@ -8,13 +8,12 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
use function is_string;
class RoleResolver implements RoleResolverInterface
{
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
public function __construct(private DomainServiceInterface $domainService)
{
$this->domainService = $domainService;
}
public function determineRoles(InputInterface $input): array
@ -26,7 +25,7 @@ class RoleResolver implements RoleResolverInterface
if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
}
if ($domainAuthority !== null) {
if (is_string($domainAuthority)) {
$domain = $this->domainService->getOrCreate($domainAuthority);
$roleDefinitions[] = RoleDefinition::forDomain($domain);
}

View file

@ -19,12 +19,9 @@ class DisableKeyCommand extends Command
{
public const NAME = 'api-key:disable';
private ApiKeyServiceInterface $apiKeyService;
public function __construct(ApiKeyServiceInterface $apiKeyService)
public function __construct(private ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void

View file

@ -23,14 +23,11 @@ class GenerateKeyCommand extends BaseCommand
{
public const NAME = 'api-key:generate';
private ApiKeyServiceInterface $apiKeyService;
private RoleResolverInterface $roleResolver;
public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
{
public function __construct(
private ApiKeyServiceInterface $apiKeyService,
private RoleResolverInterface $roleResolver
) {
parent::__construct();
$this->apiKeyService = $apiKeyService;
$this->roleResolver = $roleResolver;
}
protected function configure(): void
@ -100,7 +97,7 @@ class GenerateKeyCommand extends BaseCommand
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
if (! $apiKey->isAdmin()) {
ShlinkTable::fromOutput($io)->render(
ShlinkTable::default($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
null,

View file

@ -27,12 +27,9 @@ class ListKeysCommand extends BaseCommand
public const NAME = 'api-key:list';
private ApiKeyServiceInterface $apiKeyService;
public function __construct(ApiKeyServiceInterface $apiKeyService)
public function __construct(private ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void
@ -61,7 +58,7 @@ class ListKeysCommand extends BaseCommand
if (! $enabledOnly) {
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
$rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (string $roleName, array $meta) =>
empty($meta)
@ -72,7 +69,7 @@ class ListKeysCommand extends BaseCommand
return $rowData;
});
ShlinkTable::fromOutput($output)->render(array_filter([
ShlinkTable::withRowSeparators($output)->render(array_filter([
'Key',
'Name',
! $enabledOnly ? 'Is enabled' : null,

View file

@ -12,40 +12,36 @@ use function Shlinkio\Shlink\Core\kebabCaseToCamelCase;
use function sprintf;
use function str_contains;
/** @deprecated */
abstract class BaseCommand extends Command
{
/**
* @param mixed|null $default
* @param string|string[]|bool|null $default
*/
protected function addOptionWithDeprecatedFallback(
string $name,
?string $shortcut = null,
?int $mode = null,
string $description = '',
$default = null
bool|string|array|null $default = null,
): self {
$this->addOption($name, $shortcut, $mode, $description, $default);
if (str_contains($name, '-')) {
$camelCaseName = kebabCaseToCamelCase($name);
$this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Same as "%s".', $name), $default);
$this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Alias for "%s".', $name), $default);
}
return $this;
}
/**
* @return bool|string|string[]|null
*/
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name)
// @phpstan-ignore-next-line
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name) // phpcs:ignore
{
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
$camelCaseName = kebabCaseToCamelCase($name);
$resolvedOptionName = str_contains($rawInput, $camelCaseName) ? $camelCaseName : $name;
if (str_contains($rawInput, $camelCaseName)) {
return $input->getOption($camelCaseName);
}
return $input->getOption($name);
return $input->getOption($resolvedOptionName);
}
}

View file

@ -13,16 +13,14 @@ use Symfony\Component\Process\PhpExecutableFinder;
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
{
private ProcessRunnerInterface $processRunner;
private string $phpBinary;
public function __construct(
LockFactory $locker,
ProcessRunnerInterface $processRunner,
private ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder
) {
parent::__construct($locker);
$this->processRunner = $processRunner;
$this->phpBinary = $phpFinder->find(false) ?: 'php';
}
@ -34,6 +32,6 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
protected function getLockConfig(): LockedCommandConfig
{
return LockedCommandConfig::blocking($this->getName());
return LockedCommandConfig::blocking($this->getName() ?? static::class);
}
}

View file

@ -21,19 +21,14 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
private Connection $regularConn;
private Connection $noDbNameConn;
public function __construct(
LockFactory $locker,
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
Connection $conn,
Connection $noDbNameConn
private Connection $regularConn,
private Connection $noDbNameConn
) {
parent::__construct($locker, $processRunner, $phpFinder);
$this->regularConn = $conn;
$this->noDbNameConn = $noDbNameConn;
}
protected function configure(): void

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\filter;
use function Functional\invoke;
use function sprintf;
use function str_contains;
class DomainRedirectsCommand extends Command
{
public const NAME = 'domain:redirects';
public function __construct(private DomainServiceInterface $domainService)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Set specific "not found" redirects for individual domains.')
->addArgument(
'domain',
InputArgument::REQUIRED,
'The domain authority to which you want to set the specific redirects',
);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
/** @var string|null $domain */
$domain = $input->getArgument('domain');
if ($domain !== null) {
return;
}
$io = new SymfonyStyle($input, $output);
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
/** @var string[] $availableDomains */
$availableDomains = invoke(
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()),
'toString',
);
if (empty($availableDomains)) {
$input->setArgument('domain', $askNewDomain());
return;
}
$selectedOption = $io->choice(
'Select the domain to configure',
[...$availableDomains, '<options=bold>New domain</>'],
);
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$domainAuthority = $input->getArgument('domain');
$domain = $this->domainService->findByAuthority($domainAuthority);
$ask = static function (string $message, ?string $current) use ($io): ?string {
if ($current === null) {
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
}
$choice = $io->choice($message, [
sprintf('Keep current one: [%s]', $current),
'Set new redirect URL',
'Remove redirect',
]);
return match ($choice) {
'Set new redirect URL' => $io->ask('New redirect URL'),
'Remove redirect' => null,
default => $current,
};
};
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
$ask(
'URL to redirect to when a user hits this domain\'s base URL',
$domain?->baseUrlRedirect(),
),
$ask(
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
$domain?->regular404Redirect(),
),
$ask(
'URL to redirect to when a user hits an invalid short URL',
$domain?->invalidShortUrlRedirect(),
),
));
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
return ExitCodes::EXIT_SUCCESS;
}
}

View file

@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use function Functional\map;
@ -18,30 +20,58 @@ class ListDomainsCommand extends Command
{
public const NAME = 'domain:list';
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
public function __construct(private DomainServiceInterface $domainService)
{
parent::__construct();
$this->domainService = $domainService;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('List all domains that have been ever used for some short URL');
->setDescription('List all domains that have been ever used for some short URL')
->addOption(
'show-redirects',
'r',
InputOption::VALUE_NONE,
'Will display an extra column with the information of the "not found" redirects for every domain.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$domains = $this->domainService->listDomains();
$showRedirects = $input->getOption('show-redirects');
$commonFields = ['Domain', 'Is default'];
$table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
ShlinkTable::fromOutput($output)->render(
['Domain', 'Is default'],
map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
$table->render(
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
map($domains, function (DomainItem $domain) use ($showRedirects) {
$commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No'];
return $showRedirects
? [
...$commonValues,
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig()),
]
: $commonValues;
}),
);
return ExitCodes::EXIT_SUCCESS;
}
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
{
$baseUrl = $config->baseUrlRedirect() ?? 'N/A';
$regular404 = $config->regular404Redirect() ?? 'N/A';
$invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
return <<<EOL
* Base URL: {$baseUrl}
* Regular 404: {$regular404}
* Invalid short URL: {$invalidShortUrl}
EOL;
}
}

View file

@ -21,12 +21,9 @@ class DeleteShortUrlCommand extends Command
{
public const NAME = 'short-url:delete';
private DeleteShortUrlServiceInterface $deleteShortUrlService;
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService)
{
parent::__construct();
$this->deleteShortUrlService = $deleteShortUrlService;
}
protected function configure(): void

View file

@ -30,19 +30,12 @@ class GenerateShortUrlCommand extends BaseCommand
{
public const NAME = 'short-url:generate';
private UrlShortenerInterface $urlShortener;
private ShortUrlStringifierInterface $stringifier;
private int $defaultShortCodeLength;
public function __construct(
UrlShortenerInterface $urlShortener,
ShortUrlStringifierInterface $stringifier,
int $defaultShortCodeLength
private UrlShortenerInterface $urlShortener,
private ShortUrlStringifierInterface $stringifier,
private int $defaultShortCodeLength
) {
parent::__construct();
$this->urlShortener = $urlShortener;
$this->stringifier = $stringifier;
$this->defaultShortCodeLength = $defaultShortCodeLength;
}
protected function configure(): void

View file

@ -27,11 +27,8 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
{
public const NAME = 'short-url:visits';
private VisitsStatsHelperInterface $visitsHelper;
public function __construct(VisitsStatsHelperInterface $visitsHelper)
public function __construct(private VisitsStatsHelperInterface $visitsHelper)
{
$this->visitsHelper = $visitsHelper;
parent::__construct();
}
@ -84,7 +81,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
});
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
return ExitCodes::EXIT_SUCCESS;
}

View file

@ -33,14 +33,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
public const NAME = 'short-url:list';
private ShortUrlServiceInterface $shortUrlService;
private DataTransformerInterface $transformer;
public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer)
{
public function __construct(
private ShortUrlServiceInterface $shortUrlService,
private DataTransformerInterface $transformer
) {
parent::__construct();
$this->shortUrlService = $shortUrlService;
$this->transformer = $transformer;
}
protected function doConfigure(): void
@ -129,8 +126,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsOrdering::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
];
if ($all) {
@ -158,7 +155,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
OutputInterface $output,
array $columnsMap,
ShortUrlsParams $params,
bool $all
bool $all,
): Paginator {
$shortUrls = $this->shortUrlService->listShortUrls($params);
@ -167,7 +164,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl));
});
ShlinkTable::fromOutput($output)->render(
ShlinkTable::default($output)->render(
array_keys($columnsMap),
$rows,
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
@ -203,14 +200,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
(string) $shortUrl->authorApiKey();
(string) $shortUrl->authorApiKey();
}
if ($input->getOption('show-api-key-name')) {
$columnsMap['API Key Name'] = static function (array $_, ShortUrl $shortUrl): ?string {
$apiKey = $shortUrl->authorApiKey();
return $apiKey !== null ? $apiKey->name() : null;
};
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>
$shortUrl->authorApiKey()?->name();
}
return $columnsMap;

View file

@ -21,12 +21,9 @@ class ResolveUrlCommand extends Command
{
public const NAME = 'short-url:parse';
private ShortUrlResolverInterface $urlResolver;
public function __construct(ShortUrlResolverInterface $urlResolver)
public function __construct(private ShortUrlResolverInterface $urlResolver)
{
parent::__construct();
$this->urlResolver = $urlResolver;
}
protected function configure(): void

View file

@ -17,12 +17,9 @@ class CreateTagCommand extends Command
{
public const NAME = 'tag:create';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View file

@ -16,12 +16,9 @@ class DeleteTagsCommand extends Command
{
public const NAME = 'tag:delete';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View file

@ -18,12 +18,9 @@ class ListTagsCommand extends Command
{
public const NAME = 'tag:list';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void
@ -35,7 +32,7 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return ExitCodes::EXIT_SUCCESS;
}

View file

@ -19,12 +19,9 @@ class RenameTagCommand extends Command
{
public const NAME = 'tag:rename';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View file

@ -14,12 +14,9 @@ use function sprintf;
abstract class AbstractLockedCommand extends Command
{
private LockFactory $locker;
public function __construct(LockFactory $locker)
public function __construct(private LockFactory $locker)
{
parent::__construct();
$this->locker = $locker;
}
final protected function execute(InputInterface $input, OutputInterface $output): ?int

View file

@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function is_string;
use function sprintf;
abstract class AbstractWithDateRangeCommand extends BaseCommand
@ -49,7 +50,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
{
$value = $this->getOptionWithDeprecatedFallback($input, $key);
if (empty($value)) {
if (empty($value) || ! is_string($value)) {
return null;
}
@ -63,7 +64,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
));
if ($output->isVeryVerbose()) {
$this->getApplication()->renderThrowable($e, $output);
$this->getApplication()?->renderThrowable($e, $output);
}
return null;

View file

@ -8,15 +8,11 @@ final class LockedCommandConfig
{
public const DEFAULT_TTL = 600.0; // 10 minutes
private string $lockName;
private bool $isBlocking;
private float $ttl;
private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL)
{
$this->lockName = $lockName;
$this->isBlocking = $isBlocking;
$this->ttl = $ttl;
private function __construct(
private string $lockName,
private bool $isBlocking,
private float $ttl = self::DEFAULT_TTL
) {
}
public static function blocking(string $lockName): self

View file

@ -19,13 +19,11 @@ class DownloadGeoLiteDbCommand extends Command
{
public const NAME = 'visit:download-db';
private GeolocationDbUpdaterInterface $dbUpdater;
private ?ProgressBar $progressBar = null;
public function __construct(GeolocationDbUpdaterInterface $dbUpdater)
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
{
parent::__construct();
$this->dbUpdater = $dbUpdater;
}
protected function configure(): void
@ -47,8 +45,8 @@ class DownloadGeoLiteDbCommand extends Command
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
$this->progressBar = new ProgressBar($io);
}, function (int $total, int $downloaded): void {
$this->progressBar->setMaxSteps($total);
$this->progressBar->setProgress($downloaded);
$this->progressBar?->setMaxSteps($total);
$this->progressBar?->setProgress($downloaded);
});
if ($this->progressBar === null) {
@ -71,7 +69,7 @@ class DownloadGeoLiteDbCommand extends Command
}
if ($io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $io);
$this->getApplication()?->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;

View file

@ -30,19 +30,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
{
public const NAME = 'visit:locate';
private VisitLocatorInterface $visitLocator;
private IpLocationResolverInterface $ipLocationResolver;
private SymfonyStyle $io;
public function __construct(
VisitLocatorInterface $visitLocator,
IpLocationResolverInterface $ipLocationResolver,
private VisitLocatorInterface $visitLocator,
private IpLocationResolverInterface $ipLocationResolver,
LockFactory $locker
) {
parent::__construct($locker);
$this->visitLocator = $visitLocator;
$this->ipLocationResolver = $ipLocationResolver;
}
protected function configure(): void
@ -124,7 +119,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
} catch (Throwable $e) {
$this->io->error($e->getMessage());
if ($this->io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $this->io);
$this->getApplication()?->renderThrowable($e, $this->io);
}
return ExitCodes::EXIT_FAILURE;
@ -144,7 +139,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
throw IpCannotBeLocatedException::forEmptyAddress();
}
$ipAddr = $visit->getRemoteAddr();
$ipAddr = $visit->getRemoteAddr() ?? '';
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
@ -156,7 +151,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
} catch (WrongIpException $e) {
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
if ($this->io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $this->io);
$this->getApplication()?->renderThrowable($e, $this->io);
}
throw IpCannotBeLocatedException::forError($e);
@ -173,7 +168,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
private function checkDbUpdate(InputInterface $input): void
{
$downloadDbCommand = $this->getApplication()->find(DownloadGeoLiteDbCommand::NAME);
$cliApp = $this->getApplication();
if ($cliApp === null) {
return;
}
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
$exitCode = $downloadDbCommand->run($input, $this->io);
if ($exitCode === ExitCodes::EXIT_FAILURE) {
@ -183,6 +183,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
protected function getLockConfig(): LockedCommandConfig
{
return LockedCommandConfig::nonBlocking($this->getName());
return LockedCommandConfig::nonBlocking(self::NAME);
}
}

View file

@ -42,10 +42,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
return $e;
}
/**
* @param mixed $buildEpoch
*/
public static function withInvalidEpochInOldDb($buildEpoch): self
public static function withInvalidEpochInOldDb(mixed $buildEpoch): self
{
$e = new self(sprintf(
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',

View file

@ -19,21 +19,12 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
private const LOCK_NAME = 'geolocation-db-update';
private DbUpdaterInterface $dbUpdater;
private Reader $geoLiteDbReader;
private LockFactory $locker;
private TrackingOptions $trackingOptions;
public function __construct(
DbUpdaterInterface $dbUpdater,
Reader $geoLiteDbReader,
LockFactory $locker,
TrackingOptions $trackingOptions
private DbUpdaterInterface $dbUpdater,
private Reader $geoLiteDbReader,
private LockFactory $locker,
private TrackingOptions $trackingOptions
) {
$this->dbUpdater = $dbUpdater;
$this->geoLiteDbReader = $geoLiteDbReader;
$this->locker = $locker;
$this->trackingOptions = $trackingOptions;
}
/**

View file

@ -18,12 +18,10 @@ use function str_replace;
class ProcessRunner implements ProcessRunnerInterface
{
private ProcessHelper $helper;
private Closure $createProcess;
public function __construct(ProcessHelper $helper, ?callable $createProcess = null)
public function __construct(private ProcessHelper $helper, ?callable $createProcess = null)
{
$this->helper = $helper;
$this->createProcess = $createProcess !== null
? Closure::fromCallable($createProcess)
: static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);

View file

@ -5,23 +5,33 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Output\OutputInterface;
use function Functional\intersperse;
final class ShlinkTable
{
private const DEFAULT_STYLE_NAME = 'default';
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
private ?Table $baseTable;
public function __construct(Table $baseTable)
private function __construct(private Table $baseTable, private bool $withRowSeparators)
{
$this->baseTable = $baseTable;
}
public static function fromOutput(OutputInterface $output): self
public static function default(OutputInterface $output): self
{
return new self(new Table($output));
return new self(new Table($output), false);
}
public static function withRowSeparators(OutputInterface $output): self
{
return new self(new Table($output), true);
}
public static function fromBaseTable(Table $baseTable): self
{
return new self($baseTable, false);
}
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
@ -29,11 +39,12 @@ final class ShlinkTable
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
$tableRows = $this->withRowSeparators ? intersperse($rows, new TableSeparator()) : $rows;
$table = clone $this->baseTable;
$table->setStyle($style)
->setHeaders($headers)
->setRows($rows)
->setRows($tableRows)
->setFooterTitle($footerTitle)
->setHeaderTitle($headerTitle)
->render();

View file

@ -33,10 +33,10 @@ class RoleResolverTest extends TestCase
public function properRolesAreResolvedBasedOnInput(
InputInterface $input,
array $expectedRoles,
int $expectedDomainCalls
int $expectedDomainCalls,
): void {
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
(new Domain('example.com'))->setId('1'),
Domain::withAuthority('example.com')->setId('1'),
);
$result = $this->resolver->determineRoles($input);
@ -47,7 +47,7 @@ class RoleResolverTest extends TestCase
public function provideRoles(): iterable
{
$domain = (new Domain('example.com'))->setId('1');
$domain = Domain::withAuthority('example.com')->setId('1');
$buildInput = function (array $definition): InputInterface {
$input = $this->prophesize(InputInterface::class);
@ -68,6 +68,21 @@ class RoleResolverTest extends TestCase
[RoleDefinition::forDomain($domain)],
1,
];
yield 'false domain role' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => false]),
[],
0,
];
yield 'true domain role' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => true]),
[],
0,
];
yield 'string array domain role' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => ['foo', 'bar']]),
[],
0,
];
yield 'author role only' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
[RoleDefinition::forAuthoredShortUrls()],

View file

@ -53,7 +53,9 @@ class ListKeysCommandTest extends TestCase
| Key | Name | Is enabled | Expiration date | Roles |
+--------------------------------------+------+------------+-----------------+-------+
| {$apiKey1} | - | +++ | - | Admin |
+--------------------------------------+------+------------+-----------------+-------+
| {$apiKey2} | - | +++ | - | Admin |
+--------------------------------------+------+------------+-----------------+-------+
| {$apiKey3} | - | +++ | - | Admin |
+--------------------------------------+------+------------+-----------------+-------+
@ -67,6 +69,7 @@ class ListKeysCommandTest extends TestCase
| Key | Name | Expiration date | Roles |
+--------------------------------------+------+-----------------+-------+
| {$apiKey1} | - | - | Admin |
+--------------------------------------+------+-----------------+-------+
| {$apiKey2} | - | - | Admin |
+--------------------------------------+------+-----------------+-------+
@ -76,11 +79,13 @@ class ListKeysCommandTest extends TestCase
[
$apiKey1 = ApiKey::create(),
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
$apiKey3 = $this->apiKeyWithRoles([RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]),
$apiKey3 = $this->apiKeyWithRoles(
[RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1'))],
),
$apiKey4 = ApiKey::create(),
$apiKey5 = $this->apiKeyWithRoles([
RoleDefinition::forAuthoredShortUrls(),
RoleDefinition::forDomain((new Domain('example.com'))->setId('1')),
RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1')),
]),
$apiKey6 = ApiKey::create(),
],
@ -90,11 +95,16 @@ class ListKeysCommandTest extends TestCase
| Key | Name | Expiration date | Roles |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey1} | - | - | Admin |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey2} | - | - | Author only |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey3} | - | - | Domain only: example.com |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey4} | - | - | Admin |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey5} | - | - | Author only |
| | | | Domain only: example.com |
+--------------------------------------+------+-----------------+--------------------------+
| {$apiKey6} | - | - | Admin |
+--------------------------------------+------+-----------------+--------------------------+
@ -113,8 +123,11 @@ class ListKeysCommandTest extends TestCase
| Key | Name | Expiration date | Roles |
+--------------------------------------+---------------+-----------------+-------+
| {$apiKey1} | Alice | - | Admin |
+--------------------------------------+---------------+-----------------+-------+
| {$apiKey2} | Alice and Bob | - | Admin |
+--------------------------------------+---------------+-----------------+-------+
| {$apiKey3} | | - | Admin |
+--------------------------------------+---------------+-----------------+-------+
| {$apiKey4} | - | - | Admin |
+--------------------------------------+---------------+-----------------+-------+

View file

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
use function substr_count;
class DomainRedirectsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $domainService;
public function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal()));
}
/**
* @test
* @dataProvider provideDomains
*/
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
{
$domainAuthority = 'my-domain.com';
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$domainAuthority,
NotFoundRedirects::withRedirects('foo.com', null, 'baz.com'),
)->willReturn(Domain::withAuthority(''));
$this->commandTester->setInputs(['foo.com', '', 'baz.com']);
$this->commandTester->execute(['domain' => $domainAuthority]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('[OK] "Not found" redirects properly set for "my-domain.com"', $output);
self::assertStringContainsString('URL to redirect to when a user hits this domain\'s base URL', $output);
self::assertStringContainsString(
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
$output,
);
self::assertStringContainsString('URL to redirect to when a user hits an invalid short URL', $output);
self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)'));
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
}
public function provideDomains(): iterable
{
yield 'no domain' => [null];
yield 'domain without redirects' => [Domain::withAuthority('')];
}
/** @test */
public function offersNewOptionsForDomainsWithExistingRedirects(): void
{
$domainAuthority = 'example.com';
$domain = Domain::withAuthority($domainAuthority);
$domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com', 'baz.com'));
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$domainAuthority,
NotFoundRedirects::withRedirects(null, 'edited.com', 'baz.com'),
)->willReturn($domain);
$this->commandTester->setInputs(['2', '1', 'edited.com', '0']);
$this->commandTester->execute(['domain' => $domainAuthority]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('[OK] "Not found" redirects properly set for "example.com"', $output);
self::assertStringContainsString('Keep current one: [bar.com]', $output);
self::assertStringContainsString('Keep current one: [baz.com]', $output);
self::assertStringContainsString('Keep current one: [baz.com]', $output);
self::assertStringNotContainsStringIgnoringCase('(Leave empty for no redirect)', $output);
self::assertEquals(3, substr_count($output, 'Set new redirect URL'));
self::assertEquals(3, substr_count($output, 'Remove redirect'));
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
}
/** @test */
public function authorityIsRequestedWhenNotProvidedAndNoOtherDomainsExist(): void
{
$domainAuthority = 'example.com';
$domain = Domain::withAuthority($domainAuthority);
$listDomains = $this->domainService->listDomains()->willReturn([]);
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$domainAuthority,
NotFoundRedirects::withoutRedirects(),
)->willReturn($domain);
$this->commandTester->setInputs([$domainAuthority, '', '', '']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
$listDomains->shouldHaveBeenCalledOnce();
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
}
/** @test */
public function oneOfTheExistingDomainsCanBeSelected(): void
{
$domainAuthority = 'existing-two.com';
$domain = Domain::withAuthority($domainAuthority);
$listDomains = $this->domainService->listDomains()->willReturn([
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
DomainItem::forExistingDomain(Domain::withAuthority($domainAuthority)),
]);
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$domainAuthority,
NotFoundRedirects::withoutRedirects(),
)->willReturn($domain);
$this->commandTester->setInputs(['1', '', '', '']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringNotContainsString('Domain authority for which you want to set specific redirects', $output);
self::assertStringNotContainsString('default-domain.com', $output);
self::assertStringContainsString('existing-one.com', $output);
self::assertStringContainsString($domainAuthority, $output);
$listDomains->shouldHaveBeenCalledOnce();
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
}
/** @test */
public function aNewDomainCanBeCreatedEvenIfOthersAlreadyExist(): void
{
$domainAuthority = 'new-domain.com';
$domain = Domain::withAuthority($domainAuthority);
$listDomains = $this->domainService->listDomains()->willReturn([
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
DomainItem::forExistingDomain(Domain::withAuthority('existing-two.com')),
]);
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
$domainAuthority,
NotFoundRedirects::withoutRedirects(),
)->willReturn($domain);
$this->commandTester->setInputs(['2', $domainAuthority, '', '', '']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
self::assertStringNotContainsString('default-domain.com', $output);
self::assertStringContainsString('existing-one.com', $output);
self::assertStringContainsString('existing-two.com', $output);
$listDomains->shouldHaveBeenCalledOnce();
$findDomain->shouldHaveBeenCalledOnce();
$configureRedirects->shouldHaveBeenCalledOnce();
}
}

View file

@ -8,8 +8,11 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
@ -26,10 +29,38 @@ class ListDomainsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
}
/** @test */
public function allDomainsAreProperlyPrinted(): void
/**
* @test
* @dataProvider provideInputsAndOutputs
*/
public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void
{
$expectedOutput = <<<OUTPUT
$bazDomain = Domain::withAuthority('baz.com');
$bazDomain->configureNotFoundRedirects(NotFoundRedirects::withRedirects(
null,
'https://foo.com/baz-domain/regular',
'https://foo.com/baz-domain/invalid',
));
$listDomains = $this->domainService->listDomains()->willReturn([
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions([
'base_url' => 'https://foo.com/default/base',
'invalid_short_url' => 'https://foo.com/default/invalid',
])),
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
DomainItem::forExistingDomain($bazDomain),
]);
$this->commandTester->execute($input);
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$listDomains->shouldHaveBeenCalledOnce();
}
public function provideInputsAndOutputs(): iterable
{
$withoutRedirectsOutput = <<<OUTPUT
+---------+------------+
| Domain | Is default |
+---------+------------+
@ -39,16 +70,27 @@ class ListDomainsCommandTest extends TestCase
+---------+------------+
OUTPUT;
$listDomains = $this->domainService->listDomains()->willReturn([
new DomainItem('foo.com', true),
new DomainItem('bar.com', false),
new DomainItem('baz.com', false),
]);
$withRedirectsOutput = <<<OUTPUT
+---------+------------+---------------------------------------------------------+
| Domain | Is default | "Not found" redirects |
+---------+------------+---------------------------------------------------------+
| foo.com | Yes | * Base URL: https://foo.com/default/base |
| | | * Regular 404: N/A |
| | | * Invalid short URL: https://foo.com/default/invalid |
+---------+------------+---------------------------------------------------------+
| bar.com | No | * Base URL: N/A |
| | | * Regular 404: N/A |
| | | * Invalid short URL: N/A |
+---------+------------+---------------------------------------------------------+
| baz.com | No | * Base URL: N/A |
| | | * Regular 404: https://foo.com/baz-domain/regular |
| | | * Invalid short URL: https://foo.com/baz-domain/invalid |
+---------+------------+---------------------------------------------------------+
$this->commandTester->execute([]);
OUTPUT;
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$listDomains->shouldHaveBeenCalledOnce();
yield 'no args' => [[], $withoutRedirectsOutput];
yield 'no show redirects' => [['--show-redirects' => false], $withoutRedirectsOutput];
yield 'show redirects' => [['--show-redirects' => true], $withRedirectsOutput];
}
}

View file

@ -74,7 +74,7 @@ class DeleteShortUrlCommandTest extends TestCase
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
array $retryAnswer,
int $expectedDeleteCalls,
string $expectedMessage
string $expectedMessage,
): void {
$shortCode = 'abc123';
$identifier = new ShortUrlIdentifier($shortCode);

View file

@ -110,7 +110,7 @@ class ListShortUrlsCommandTest extends TestCase
array $input,
array $expectedContents,
array $notExpectedContents,
ApiKey $apiKey
ApiKey $apiKey,
): void {
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
->willReturn(new Paginator(new ArrayAdapter([
@ -185,7 +185,7 @@ class ListShortUrlsCommandTest extends TestCase
?string $searchTerm,
array $tags,
?string $startDate = null,
?string $endDate = null
?string $endDate = null,
): void {
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
'page' => $page,

View file

@ -36,7 +36,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
public function showsProperMessageWhenGeoLiteUpdateFails(
bool $olderDbExists,
string $expectedMessage,
int $expectedExitCode
int $expectedExitCode,
): void {
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($olderDbExists): void {

View file

@ -73,7 +73,7 @@ class LocateVisitsCommandTest extends TestCase
int $expectedEmptyCalls,
int $expectedAllCalls,
bool $expectWarningPrint,
array $args
array $args,
): void {
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());

View file

@ -24,7 +24,7 @@ class ShlinkTableTest extends TestCase
public function setUp(): void
{
$this->baseTable = $this->prophesize(Table::class);
$this->shlinkTable = new ShlinkTable($this->baseTable->reveal());
$this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable->reveal());
}
/** @test */
@ -57,7 +57,7 @@ class ShlinkTableTest extends TestCase
/** @test */
public function newTableIsCreatedForFactoryMethod(): void
{
$instance = ShlinkTable::fromOutput($this->prophesize(OutputInterface::class)->reveal());
$instance = ShlinkTable::default($this->prophesize(OutputInterface::class)->reveal());
$ref = new ReflectionObject($instance);
$baseTable = $ref->getProperty('baseTable');

View file

@ -37,6 +37,7 @@ return [
Domain\DomainService::class => ConfigAbstractFactory::class,
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
Visit\RequestTracker::class => ConfigAbstractFactory::class,
Visit\VisitLocator::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
@ -45,6 +46,8 @@ return [
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
Config\NotFoundRedirectResolver::class => ConfigAbstractFactory::class,
Action\RedirectAction::class => ConfigAbstractFactory::class,
Action\PixelAction::class => ConfigAbstractFactory::class,
Action\QrCodeAction::class => ConfigAbstractFactory::class,
@ -53,7 +56,9 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => ConfigAbstractFactory::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
@ -69,10 +74,11 @@ return [
ConfigAbstractFactory::class => [
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class],
ErrorHandler\NotFoundRedirectHandler::class => [
NotFoundRedirectOptions::class,
Util\RedirectResponseHelper::class,
Config\NotFoundRedirectResolver::class,
Domain\DomainService::class,
],
Options\AppOptions::class => ['config.app_options'],
@ -92,6 +98,7 @@ return [
EventDispatcherInterface::class,
Options\TrackingOptions::class,
],
Visit\RequestTracker::class => [Visit\VisitsTracker::class, Options\TrackingOptions::class],
Service\ShortUrlService::class => [
'em',
Service\ShortUrl\ShortUrlResolver::class,
@ -108,25 +115,25 @@ return [
],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
Service\ShortUrl\ShortCodeHelper::class => ['em'],
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
Domain\DomainService::class => [
'em',
'config.url_shortener.domain.hostname',
Options\NotFoundRedirectOptions::class,
],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],
Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class],
Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
Visit\VisitsTracker::class,
Options\TrackingOptions::class,
Visit\RequestTracker::class,
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
Util\RedirectResponseHelper::class,
'Logger_Shlink',
],
Action\PixelAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
Visit\VisitsTracker::class,
Options\TrackingOptions::class,
'Logger_Shlink',
],
Action\PixelAction::class => [Service\ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class],
Action\QrCodeAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
ShortUrl\Helper\ShortUrlStringifier::class,
@ -137,7 +144,15 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [
Service\ShortUrl\ShortUrlResolver::class,
Visit\RequestTracker::class,
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
Util\RedirectResponseHelper::class,
Options\UrlShortenerOptions::class,
],
Mercure\MercureUpdatesGenerator::class => [
ShortUrl\Transformer\ShortUrlDataTransformer::class,

View file

@ -24,4 +24,19 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
$builder->createField('authority', Types::STRING)
->unique()
->build();
$builder->createField('baseUrlRedirect', Types::STRING)
->columnName('base_url_redirect')
->nullable()
->build();
$builder->createField('regular404Redirect', Types::STRING)
->columnName('regular_not_found_redirect')
->nullable()
->build();
$builder->createField('invalidShortUrlRedirect', Types::STRING)
->columnName('invalid_short_url_redirect')
->nullable()
->build();
};

View file

@ -51,20 +51,12 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
$startDate = parseDateFromQuery($query, $startDateName);
$endDate = parseDateFromQuery($query, $endDateName);
// TODO Use match expression when migrating to PHP8
if ($startDate === null && $endDate === null) {
return DateRange::emptyInstance();
}
if ($startDate !== null && $endDate !== null) {
return DateRange::withStartAndEndDate($startDate, $endDate);
}
if ($startDate !== null) {
return DateRange::withStartDate($startDate);
}
return DateRange::withEndDate($endDate);
return match (true) {
$startDate === null && $endDate === null => DateRange::emptyInstance(),
$startDate !== null && $endDate !== null => DateRange::withStartAndEndDate($startDate, $endDate),
$startDate !== null => DateRange::withStartDate($startDate),
default => DateRange::withEndDate($endDate),
};
}
/**

View file

@ -5,91 +5,46 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\Psr7\Query;
use League\Uri\Uri;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
use function array_key_exists;
use function array_merge;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
{
private ShortUrlResolverInterface $urlResolver;
private VisitsTrackerInterface $visitTracker;
private TrackingOptions $trackingOptions;
private LoggerInterface $logger;
public function __construct(
ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker,
TrackingOptions $trackingOptions,
?LoggerInterface $logger = null
private ShortUrlResolverInterface $urlResolver,
private RequestTrackerInterface $requestTracker,
) {
$this->urlResolver = $urlResolver;
$this->visitTracker = $visitTracker;
$this->trackingOptions = $trackingOptions;
$this->logger = $logger ?? new NullLogger();
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
$query = $request->getQueryParams();
$disableTrackParam = $this->trackingOptions->getDisableTrackParam();
try {
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
$this->requestTracker->trackIfApplicable($shortUrl, $request);
if ($this->shouldTrackRequest($request, $query, $disableTrackParam)) {
$this->visitTracker->track($shortUrl, Visitor::fromRequest($request));
}
return $this->createSuccessResp($this->buildUrlToRedirectTo($shortUrl, $query, $disableTrackParam));
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
return $this->createSuccessResp($shortUrl, $request);
} catch (ShortUrlNotFoundException) {
return $this->createErrorResp($request, $handler);
}
}
private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string
{
$uri = Uri::createFromString($shortUrl->getLongUrl());
$hardcodedQuery = Query::parse($uri->getQuery() ?? '');
if ($disableTrackParam !== null) {
unset($currentQuery[$disableTrackParam]);
}
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
return (string) (empty($mergedQuery) ? $uri : $uri->withQuery(Query::build($mergedQuery)));
}
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool
{
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
if ($forwardedMethod === self::METHOD_HEAD) {
return false;
}
return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query);
}
abstract protected function createSuccessResp(string $longUrl): ResponseInterface;
abstract protected function createErrorResp(
abstract protected function createSuccessResp(
ShortUrl $shortUrl,
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface;
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
{
return $handler->handle($request);
}
}

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action\Model;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile;
use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\Writer\SvgWriter;
use Endroid\QrCode\Writer\WriterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use function strtolower;
use function trim;
final class QrCodeParams
{
private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
private function __construct(
private int $size,
private int $margin,
private WriterInterface $writer,
private ErrorCorrectionLevelInterface $errorCorrectionLevel
) {
}
public static function fromRequest(ServerRequestInterface $request): self
{
$query = $request->getQueryParams();
return new self(
self::resolveSize($request, $query),
self::resolveMargin($query),
self::resolveWriter($query),
self::resolveErrorCorrection($query),
);
}
private static function resolveSize(Request $request, array $query): int
{
// FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
}
private static function resolveMargin(array $query): int
{
$margin = $query['margin'] ?? null;
if ($margin === null) {
return 0;
}
$intMargin = (int) $margin;
if ($margin !== (string) $intMargin) {
return 0;
}
return $intMargin < 0 ? 0 : $intMargin;
}
private static function resolveWriter(array $query): WriterInterface
{
$format = strtolower(trim($query['format'] ?? 'png'));
return match ($format) {
'svg' => new SvgWriter(),
default => new PngWriter(),
};
}
private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface
{
$errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l'));
return match ($errorCorrectionLevel) {
'h' => new ErrorCorrectionLevelHigh(),
'q' => new ErrorCorrectionLevelQuartile(),
'm' => new ErrorCorrectionLevelMedium(),
default => new ErrorCorrectionLevelLow(), // 'l'
};
}
public function size(): int
{
return $this->size;
}
public function margin(): int
{
return $this->margin;
}
public function writer(): WriterInterface
{
return $this->writer;
}
public function errorCorrectionLevel(): ErrorCorrectionLevelInterface
{
return $this->errorCorrectionLevel;
}
}

View file

@ -8,17 +8,18 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\PixelResponse;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
class PixelAction extends AbstractTrackingAction
{
protected function createSuccessResp(string $longUrl): ResponseInterface
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): ResponseInterface
{
return new PixelResponse();
}
protected function createErrorResp(
ServerRequestInterface $request,
RequestHandlerInterface $handler
RequestHandlerInterface $handler,
): ResponseInterface {
return new PixelResponse();
}

View file

@ -5,14 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\SvgWriter;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
@ -20,22 +19,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
class QrCodeAction implements MiddlewareInterface
{
private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
private ShortUrlResolverInterface $urlResolver;
private ShortUrlStringifierInterface $stringifier;
private LoggerInterface $logger;
public function __construct(
ShortUrlResolverInterface $urlResolver,
ShortUrlStringifierInterface $stringifier,
?LoggerInterface $logger = null
private ShortUrlResolverInterface $urlResolver,
private ShortUrlStringifierInterface $stringifier,
private LoggerInterface $logger
) {
$this->urlResolver = $urlResolver;
$this->logger = $logger ?? new NullLogger();
$this->stringifier = $stringifier;
}
public function process(Request $request, RequestHandlerInterface $handler): Response
@ -49,43 +37,14 @@ class QrCodeAction implements MiddlewareInterface
return $handler->handle($request);
}
$query = $request->getQueryParams();
$qrCode = Builder::create()
$params = QrCodeParams::fromRequest($request);
$qrCodeBuilder = Builder::create()
->data($this->stringifier->stringify($shortUrl))
->size($this->resolveSize($request, $query))
->margin($this->resolveMargin($query));
->size($params->size())
->margin($params->margin())
->writer($params->writer())
->errorCorrectionLevel($params->errorCorrectionLevel());
$format = $query['format'] ?? 'png';
if ($format === 'svg') {
$qrCode->writer(new SvgWriter());
}
return new QrCodeResponse($qrCode->build());
}
private function resolveSize(Request $request, array $query): int
{
// Size attribute is deprecated. After v3.0.0, always use the query param instead
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
}
private function resolveMargin(array $query): int
{
if (! isset($query['margin'])) {
return 0;
}
$margin = $query['margin'];
$intMargin = (int) $margin;
if ($margin !== (string) $intMargin) {
return 0;
}
return $intMargin < 0 ? 0 : $intMargin;
return new QrCodeResponse($qrCodeBuilder->build());
}
}

View file

@ -7,35 +7,26 @@ namespace Shlinkio\Shlink\Core\Action;
use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
{
private RedirectResponseHelperInterface $redirectResponseHelper;
public function __construct(
ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker,
Options\TrackingOptions $trackingOptions,
RedirectResponseHelperInterface $redirectResponseHelper,
?LoggerInterface $logger = null
RequestTrackerInterface $requestTracker,
private ShortUrlRedirectionBuilderInterface $redirectionBuilder,
private RedirectResponseHelperInterface $redirectResponseHelper,
) {
parent::__construct($urlResolver, $visitTracker, $trackingOptions, $logger);
$this->redirectResponseHelper = $redirectResponseHelper;
parent::__construct($urlResolver, $requestTracker);
}
protected function createSuccessResp(string $longUrl): Response
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response
{
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request->getQueryParams());
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
}
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
{
return $handler->handle($request);
}
}

View file

@ -17,15 +17,13 @@ use const PHP_EOL;
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
{
private CrawlingHelperInterface $crawlingHelper;
public function __construct(CrawlingHelperInterface $crawlingHelper)
public function __construct(private CrawlingHelperInterface $crawlingHelper)
{
$this->crawlingHelper = $crawlingHelper;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
// @phpstan-ignore-next-line The "Response" phpdoc is wrong
return new Response(self::STATUS_OK, ['Content-type' => 'text/plain'], $this->buildRobots());
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
interface NotFoundRedirectConfigInterface
{
public function invalidShortUrlRedirect(): ?string;
public function hasInvalidShortUrlRedirect(): bool;
public function regular404Redirect(): ?string;
public function hasRegular404Redirect(): bool;
public function baseUrlRedirect(): ?string;
public function hasBaseUrlRedirect(): bool;
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
{
public function __construct(private RedirectResponseHelperInterface $redirectResponseHelper)
{
}
public function resolveRedirectResponse(
NotFoundType $notFoundType,
NotFoundRedirectConfigInterface $config
): ?ResponseInterface {
return match (true) {
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() =>
// @phpstan-ignore-next-line Create custom PHPStan rule
$this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()),
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() =>
// @phpstan-ignore-next-line Create custom PHPStan rule
$this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()),
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
// @phpstan-ignore-next-line Create custom PHPStan rule
$this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()),
default => null,
};
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
interface NotFoundRedirectResolverInterface
{
public function resolveRedirectResponse(
NotFoundType $notFoundType,
NotFoundRedirectConfigInterface $config
): ?ResponseInterface;
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use JsonSerializable;
final class NotFoundRedirects implements JsonSerializable
{
private function __construct(
private ?string $baseUrlRedirect,
private ?string $regular404Redirect,
private ?string $invalidShortUrlRedirect,
) {
}
public static function withRedirects(
?string $baseUrlRedirect,
?string $regular404Redirect = null,
?string $invalidShortUrlRedirect = null,
): self {
return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect);
}
public static function withoutRedirects(): self
{
return new self(null, null, null);
}
public static function fromConfig(NotFoundRedirectConfigInterface $config): self
{
return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect());
}
public function baseUrlRedirect(): ?string
{
return $this->baseUrlRedirect;
}
public function regular404Redirect(): ?string
{
return $this->regular404Redirect;
}
public function invalidShortUrlRedirect(): ?string
{
return $this->invalidShortUrlRedirect;
}
public function jsonSerialize(): array
{
return [
'baseUrlRedirect' => $this->baseUrlRedirect,
'regular404Redirect' => $this->regular404Redirect,
'invalidShortUrlRedirect' => $this->invalidShortUrlRedirect,
];
}
}

View file

@ -10,11 +10,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
class CrawlingHelper implements CrawlingHelperInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
public function __construct(private EntityManagerInterface $em)
{
$this->em = $em;
}
public function listCrawlableShortCodes(): iterable

View file

@ -5,10 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -16,13 +19,11 @@ use function Functional\map;
class DomainService implements DomainServiceInterface
{
private EntityManagerInterface $em;
private string $defaultDomain;
public function __construct(EntityManagerInterface $em, string $defaultDomain)
{
$this->em = $em;
$this->defaultDomain = $defaultDomain;
public function __construct(
private EntityManagerInterface $em,
private string $defaultDomain,
private NotFoundRedirectOptions $redirectOptions,
) {
}
/**
@ -33,14 +34,14 @@ class DomainService implements DomainServiceInterface
/** @var DomainRepositoryInterface $repo */
$repo = $this->em->getRepository(Domain::class);
$domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey);
$mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false));
$mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forExistingDomain($domain));
if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) {
if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
return $mappedDomains;
}
return [
new DomainItem($this->defaultDomain, true),
DomainItem::forDefaultDomain($this->defaultDomain, $this->redirectOptions),
...$mappedDomains,
];
}
@ -59,15 +60,58 @@ class DomainService implements DomainServiceInterface
return $domain;
}
public function getOrCreate(string $authority): Domain
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
{
$repo = $this->em->getRepository(Domain::class);
/** @var Domain|null $domain */
$domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority);
return $repo->findOneByAuthority($authority, $apiKey);
}
$this->em->persist($domain);
/**
* @throws DomainNotFoundException
*/
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain
{
$domain = $this->getPersistedDomain($authority, $apiKey);
$this->em->flush();
return $domain;
}
/**
* @throws DomainNotFoundException
* @throws InvalidDomainException
*/
public function configureNotFoundRedirects(
string $authority,
NotFoundRedirects $notFoundRedirects,
?ApiKey $apiKey = null
): Domain {
if ($authority === $this->defaultDomain) {
throw InvalidDomainException::forDefaultDomainRedirects();
}
$domain = $this->getPersistedDomain($authority, $apiKey);
$domain->configureNotFoundRedirects($notFoundRedirects);
$this->em->flush();
return $domain;
}
/**
* @throws DomainNotFoundException
*/
private function getPersistedDomain(string $authority, ?ApiKey $apiKey): Domain
{
$domain = $this->findByAuthority($authority, $apiKey);
if ($domain === null && $apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
// This API key is restricted to one domain and a different one was tried to be fetched
throw DomainNotFoundException::fromAuthority($authority);
}
$domain = $domain ?? Domain::withAuthority($authority);
$this->em->persist($domain);
return $domain;
}
}

View file

@ -4,9 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface DomainServiceInterface
@ -21,5 +23,20 @@ interface DomainServiceInterface
*/
public function getDomain(string $domainId): Domain;
public function getOrCreate(string $authority): Domain;
/**
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
*/
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain;
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
/**
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
* @throws InvalidDomainException If default domain is provided
*/
public function configureNotFoundRedirects(
string $authority,
NotFoundRedirects $notFoundRedirects,
?ApiKey $apiKey = null,
): Domain;
}

View file

@ -5,33 +5,50 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Model;
use JsonSerializable;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Entity\Domain;
final class DomainItem implements JsonSerializable
{
private string $domain;
private bool $isDefault;
private function __construct(
private string $authority,
private NotFoundRedirectConfigInterface $notFoundRedirectConfig,
private bool $isDefault
) {
}
public function __construct(string $domain, bool $isDefault)
public static function forExistingDomain(Domain $domain): self
{
$this->domain = $domain;
$this->isDefault = $isDefault;
return new self($domain->getAuthority(), $domain, false);
}
public static function forDefaultDomain(string $authority, NotFoundRedirectConfigInterface $config): self
{
return new self($authority, $config, true);
}
public function jsonSerialize(): array
{
return [
'domain' => $this->domain,
'domain' => $this->authority,
'isDefault' => $this->isDefault,
'redirects' => NotFoundRedirects::fromConfig($this->notFoundRedirectConfig),
];
}
public function toString(): string
{
return $this->domain;
return $this->authority;
}
public function isDefault(): bool
{
return $this->isDefault;
}
public function notFoundRedirectConfig(): NotFoundRedirectConfigInterface
{
return $this->notFoundRedirectConfig;
}
}

View file

@ -6,8 +6,13 @@ namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\ORM\Query\Expr\Join;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Domain\Spec\IsDomain;
use Shlinkio\Shlink\Core\Domain\Spec\IsNotAuthority;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
@ -18,18 +23,51 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array
{
$qb = $this->createQueryBuilder('d');
$qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d')
->orderBy('d.authority', 'ASC');
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
->groupBy('d')
->orderBy('d.authority', 'ASC')
->having($qb->expr()->gt('COUNT(s.id)', '0'))
->orHaving($qb->expr()->isNotNull('d.baseUrlRedirect'))
->orHaving($qb->expr()->isNotNull('d.regular404Redirect'))
->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect'));
if ($excludedAuthority !== null) {
$qb->where($qb->expr()->neq('d.authority', ':excludedAuthority'))
->setParameter('excludedAuthority', $excludedAuthority);
}
if ($apiKey !== null) {
$this->applySpecification($qb, $apiKey->spec(), 's');
$specs = $this->determineExtraSpecs($excludedAuthority, $apiKey);
foreach ($specs as [$alias, $spec]) {
$this->applySpecification($qb, $spec, $alias);
}
return $qb->getQuery()->getResult();
}
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
{
$qb = $this->createQueryBuilder('d');
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
->where($qb->expr()->eq('d.authority', ':authority'))
->setParameter('authority', $authority)
->setMaxResults(1);
$specs = $this->determineExtraSpecs(null, $apiKey);
foreach ($specs as [$alias, $spec]) {
$this->applySpecification($qb, $spec, $alias);
}
return $qb->getQuery()->getOneOrNullResult();
}
private function determineExtraSpecs(?string $excludedAuthority, ?ApiKey $apiKey): iterable
{
if ($excludedAuthority !== null) {
yield ['d', new IsNotAuthority($excludedAuthority)];
}
// FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the
// ShortUrl is the root entity. Here, the Domain is the root entity.
// Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible.
yield from $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) {
Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))],
Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)],
default => [null, Spec::andX()],
}) ?? [];
}
}

View file

@ -15,4 +15,6 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio
* @return Domain[]
*/
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array;
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Spec;
use Happyr\DoctrineSpecification\Filter\Filter;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
class IsDomain extends BaseSpecification
{
public function __construct(private string $domainId, ?string $context = null)
{
parent::__construct($context);
}
protected function getSpec(): Filter
{
return Spec::eq('id', $this->domainId);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Spec;
use Happyr\DoctrineSpecification\Filter\Filter;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
class IsNotAuthority extends BaseSpecification
{
public function __construct(private string $authority, ?string $context = null)
{
parent::__construct($context);
}
protected function getSpec(): Filter
{
return Spec::not(Spec::eq('authority', $this->authority));
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Validation;
use Laminas\InputFilter\InputFilter;
use Shlinkio\Shlink\Common\Validation;
class DomainRedirectsInputFilter extends InputFilter
{
use Validation\InputFactoryTrait;
public const DOMAIN = 'domain';
public const BASE_URL_REDIRECT = 'baseUrlRedirect';
public const REGULAR_404_REDIRECT = 'regular404Redirect';
public const INVALID_SHORT_URL_REDIRECT = 'invalidShortUrlRedirect';
private function __construct()
{
}
public static function withData(array $data): self
{
$instance = new self();
$instance->initializeInputs();
$instance->setData($data);
return $instance;
}
private function initializeInputs(): void
{
$domain = $this->createInput(self::DOMAIN);
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$this->add($domain);
$this->add($this->createInput(self::BASE_URL_REDIRECT, false));
$this->add($this->createInput(self::REGULAR_404_REDIRECT, false));
$this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false));
}
}

View file

@ -6,14 +6,22 @@ namespace Shlinkio\Shlink\Core\Entity;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
class Domain extends AbstractEntity implements JsonSerializable
class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface
{
private string $authority;
private ?string $baseUrlRedirect = null;
private ?string $regular404Redirect = null;
private ?string $invalidShortUrlRedirect = null;
public function __construct(string $authority)
private function __construct(private string $authority)
{
$this->authority = $authority;
}
public static function withAuthority(string $authority): self
{
return new self($authority);
}
public function getAuthority(): string
@ -25,4 +33,41 @@ class Domain extends AbstractEntity implements JsonSerializable
{
return $this->getAuthority();
}
public function invalidShortUrlRedirect(): ?string
{
return $this->invalidShortUrlRedirect;
}
public function hasInvalidShortUrlRedirect(): bool
{
return $this->invalidShortUrlRedirect !== null;
}
public function regular404Redirect(): ?string
{
return $this->regular404Redirect;
}
public function hasRegular404Redirect(): bool
{
return $this->regular404Redirect !== null;
}
public function baseUrlRedirect(): ?string
{
return $this->baseUrlRedirect;
}
public function hasBaseUrlRedirect(): bool
{
return $this->baseUrlRedirect !== null;
}
public function configureNotFoundRedirects(NotFoundRedirects $redirects): void
{
$this->baseUrlRedirect = $redirects->baseUrlRedirect();
$this->regular404Redirect = $redirects->regular404Redirect();
$this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect();
}
}

View file

@ -60,7 +60,7 @@ class ShortUrl extends AbstractEntity
public static function fromMeta(
ShortUrlMeta $meta,
?ShortUrlRelationResolverInterface $relationResolver = null
?ShortUrlRelationResolverInterface $relationResolver = null,
): self {
$instance = new self();
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
@ -87,7 +87,7 @@ class ShortUrl extends AbstractEntity
public static function fromImport(
ImportedShlinkUrl $url,
bool $importShortCode,
?ShortUrlRelationResolverInterface $relationResolver = null
?ShortUrlRelationResolverInterface $relationResolver = null,
): self {
$meta = [
ShortUrlInputFilter::VALIDATE_URL => false,
@ -209,7 +209,7 @@ class ShortUrl extends AbstractEntity
public function update(
ShortUrlEdit $shortUrlEdit,
?ShortUrlRelationResolverInterface $relationResolver = null
?ShortUrlRelationResolverInterface $relationResolver = null,
): void {
if ($shortUrlEdit->validSinceWasProvided()) {
$this->validSince = $shortUrlEdit->validSince();

View file

@ -10,12 +10,10 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Tag extends AbstractEntity implements JsonSerializable
{
private string $name;
private Collections\Collection $shortUrls;
public function __construct(string $name)
public function __construct(private string $name)
{
$this->name = $name;
$this->shortUrls = new Collections\ArrayCollection();
}

View file

@ -104,7 +104,7 @@ class Visit extends AbstractEntity implements JsonSerializable
try {
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) {
} catch (InvalidArgumentException) {
return null;
}
}

View file

@ -13,31 +13,24 @@ use function rtrim;
class NotFoundType
{
private string $type;
private function __construct(string $type)
private function __construct(private string $type)
{
$this->type = $type;
}
public static function fromRequest(ServerRequestInterface $request, string $basePath): self
{
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
if ($isBaseUrl) {
return new self(Visit::TYPE_BASE_URL);
}
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
if ($routeResult->isFailure()) {
return new self(Visit::TYPE_REGULAR_404);
}
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
if ($routeResult->getMatchedRouteName() === RedirectAction::class) {
return new self(Visit::TYPE_INVALID_SHORT_URL);
}
$type = match (true) {
$isBaseUrl => Visit::TYPE_BASE_URL,
$routeResult->isFailure() => Visit::TYPE_REGULAR_404,
$routeResult->getMatchedRouteName() === RedirectAction::class => Visit::TYPE_INVALID_SHORT_URL,
default => self::class,
};
return new self(self::class);
return new self($type);
}
public function isBaseUrl(): bool

View file

@ -8,44 +8,35 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
class NotFoundRedirectHandler implements MiddlewareInterface
{
private Options\NotFoundRedirectOptions $redirectOptions;
private RedirectResponseHelperInterface $redirectResponseHelper;
public function __construct(
Options\NotFoundRedirectOptions $redirectOptions,
RedirectResponseHelperInterface $redirectResponseHelper
private Options\NotFoundRedirectOptions $redirectOptions,
private NotFoundRedirectResolverInterface $redirectResolver,
private DomainServiceInterface $domainService,
) {
$this->redirectOptions = $redirectOptions;
$this->redirectResponseHelper = $redirectResponseHelper;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var NotFoundType $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
$authority = $request->getUri()->getAuthority();
$domainSpecificRedirect = $this->resolveDomainSpecificRedirect($authority, $notFoundType);
if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) {
return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect());
}
return $domainSpecificRedirect
?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions)
?? $handler->handle($request);
}
if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) {
return $this->redirectResponseHelper->buildRedirectResponse(
$this->redirectOptions->getRegular404Redirect(),
);
}
if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) {
return $this->redirectResponseHelper->buildRedirectResponse(
$this->redirectOptions->getInvalidShortUrlRedirect(),
);
}
return $handler->handle($request);
private function resolveDomainSpecificRedirect(string $authority, NotFoundType $notFoundType): ?ResponseInterface
{
$domain = $this->domainService->findByAuthority($authority);
return $domain === null ? null : $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain);
}
}

View file

@ -20,6 +20,7 @@ class NotFoundTemplateHandler implements RequestHandlerInterface
private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates';
public const NOT_FOUND_TEMPLATE = '404.html';
public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
private Closure $readFile;
public function __construct(?callable $readFile = null)

View file

@ -8,33 +8,17 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
class NotFoundTrackerMiddleware implements MiddlewareInterface
{
private VisitsTrackerInterface $visitsTracker;
public function __construct(VisitsTrackerInterface $visitsTracker)
public function __construct(private RequestTrackerInterface $requestTracker)
{
$this->visitsTracker = $visitsTracker;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var NotFoundType $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
$visitor = Visitor::fromRequest($request);
if ($notFoundType->isBaseUrl()) {
$this->visitsTracker->trackBaseUrlVisit($visitor);
} elseif ($notFoundType->isRegularNotFound()) {
$this->visitsTracker->trackRegularNotFoundVisit($visitor);
} elseif ($notFoundType->isInvalidShortUrl()) {
$this->visitsTracker->trackInvalidShortUrlVisit($visitor);
}
$this->requestTracker->trackNotFoundIfApplicable($request);
return $handler->handle($request);
}
}

View file

@ -12,11 +12,8 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
class NotFoundTypeResolverMiddleware implements MiddlewareInterface
{
private string $shlinkBasePath;
public function __construct(string $shlinkBasePath)
public function __construct(private string $shlinkBasePath)
{
$this->shlinkBasePath = $shlinkBasePath;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface

View file

@ -8,13 +8,11 @@ use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
class CloseDbConnectionEventListener
{
private ReopeningEntityManagerInterface $em;
/** @var callable */
private $wrapped;
public function __construct(ReopeningEntityManagerInterface $em, callable $wrapped)
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
{
$this->em = $em;
$this->wrapped = $wrapped;
}

View file

@ -12,7 +12,7 @@ class CloseDbConnectionEventListenerDelegator
public function __invoke(
ContainerInterface $container,
string $name,
callable $callback
callable $callback,
): CloseDbConnectionEventListener {
/** @var callable $wrapped */
$wrapped = $callback();

Some files were not shown because too many files have changed in this diff Show more