diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1aefb80..741f80c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b1db6cd7..b45ee370 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 594680a8..46a3b884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 837f7593..28f174dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/Dockerfile b/Dockerfile index c07adc28..dcfb030b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 8d51f05b..cd15bb5c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/cli b/bin/cli index c185efd3..56437c8b 100755 --- a/bin/cli +++ b/bin/cli @@ -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(); diff --git a/composer.json b/composer.json index c5e8b757..17a5e21f 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 0a72c6fa..0a3374e1 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -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, diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index c60e1ba7..0466ebc5 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -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, diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index d7cd8b02..4a6afbc2 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -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, ], ]; diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index c686137f..f34245fb 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -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, ], ]; diff --git a/config/cli-app.php b/config/cli-app.php new file mode 100644 index 00000000..a2272852 --- /dev/null +++ b/config/cli-app.php @@ -0,0 +1,12 @@ +get(CliApp::class); +})(); diff --git a/config/cli-config.php b/config/cli-config.php index 71c7a75e..52659e4e 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -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); })(); diff --git a/config/entity-manager.php b/config/entity-manager.php new file mode 100644 index 00000000..2b4794f7 --- /dev/null +++ b/config/entity-manager.php @@ -0,0 +1,12 @@ +get(EntityManager::class); +})(); diff --git a/config/run.php b/config/run.php index 80116e24..5de0df16 100644 --- a/config/run.php +++ b/config/run.php @@ -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(); }; diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index c2375cba..68d1011c 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -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'); diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index d5bb0a2c..80ff8afd 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -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; } diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 8972e1ac..02e815b1 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,7 +1,7 @@ -FROM php:8.0.6-fpm-alpine3.13 +FROM php:8.0.9-fpm-alpine3.14 MAINTAINER Alejandro Celaya -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 diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index f0f2ca74..3170729b 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:8.0.6-alpine3.13 +FROM php:8.0.9-alpine3.14 MAINTAINER Alejandro Celaya -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 diff --git a/data/migrations/Version20180913205455.php b/data/migrations/Version20180913205455.php index ee6cd861..727e4400 100644 --- a/data/migrations/Version20180913205455.php +++ b/data/migrations/Version20180913205455.php @@ -57,7 +57,7 @@ final class Version20180913205455 extends AbstractMigration try { return (string) IpAddress::fromString($addr)->getAnonymizedCopy(); - } catch (InvalidArgumentException $e) { + } catch (InvalidArgumentException) { return null; } } diff --git a/data/migrations/Version20210720143824.php b/data/migrations/Version20210720143824.php new file mode 100644 index 00000000..66e03be5 --- /dev/null +++ b/data/migrations/Version20210720143824.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 2f1c9499..d4526f50 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -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' => [ diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 1f9337c4..8847b757 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -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 diff --git a/docs/swagger/definitions/NotFoundRedirects.json b/docs/swagger/definitions/NotFoundRedirects.json new file mode 100644 index 00000000..6887ed0c --- /dev/null +++ b/docs/swagger/definitions/NotFoundRedirects.json @@ -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" + } + } +} diff --git a/docs/swagger/paths/v2_domains.json b/docs/swagger/paths/v2_domains.json index d92ae995..ef63ee4e 100644 --- a/docs/swagger/paths/v2_domains.json +++ b/docs/swagger/paths/v2_domains.json @@ -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" + } } ] } diff --git a/docs/swagger/paths/v2_domains_redirects.json b/docs/swagger/paths/v2_domains_redirects.json new file mode 100644 index 00000000..9bf16841 --- /dev/null +++ b/docs/swagger/paths/v2_domains_redirects.json @@ -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" + } + } + } + } + } + } +} diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 00502ad5..04a88fd7 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -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.
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": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 21547f90..705069cc 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -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" diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 6043833b..46bb90ef 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -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, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 5f51d6c2..95ea1bbc 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -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, diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index 67747983..c8cccfc6 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -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); } diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 1a8024ec..7296632a 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -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 diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 31df82a1..d39c05fa 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -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, diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index e8326826..23258993 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -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, diff --git a/module/CLI/src/Command/BaseCommand.php b/module/CLI/src/Command/BaseCommand.php index 443b37ec..fbee8681 100644 --- a/module/CLI/src/Command/BaseCommand.php +++ b/module/CLI/src/Command/BaseCommand.php @@ -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); } } diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index e4515ab5..9cd6e9ea 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -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); } } diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index ca68f818..ad3959ca 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -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 diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php new file mode 100644 index 00000000..90cfd1f7 --- /dev/null +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -0,0 +1,114 @@ +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, '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; + } +} diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index ddcfa1bd..447bf92f 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -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 <<deleteShortUrlService = $deleteShortUrlService; } protected function configure(): void diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index cafd0e5a..e0d2babc 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -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 diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index 7b020356..5113debc 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -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; } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 0d637f5f..ff01030a 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -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; diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index e6fdef3d..47a30c8e 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -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 diff --git a/module/CLI/src/Command/Tag/CreateTagCommand.php b/module/CLI/src/Command/Tag/CreateTagCommand.php index 0003319d..99eef614 100644 --- a/module/CLI/src/Command/Tag/CreateTagCommand.php +++ b/module/CLI/src/Command/Tag/CreateTagCommand.php @@ -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 diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 2b3eae14..5a4f81ac 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -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 diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 11e22a4f..61d4e6e0 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -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; } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 8bfb0242..23c1568d 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -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 diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index 8a43653d..9482694b 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -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 diff --git a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php index 39e60c9a..9d7f5723 100644 --- a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php +++ b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php @@ -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; diff --git a/module/CLI/src/Command/Util/LockedCommandConfig.php b/module/CLI/src/Command/Util/LockedCommandConfig.php index 8de204c5..af9d704d 100644 --- a/module/CLI/src/Command/Util/LockedCommandConfig.php +++ b/module/CLI/src/Command/Util/LockedCommandConfig.php @@ -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 diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index 3d76663a..41fb5f8d 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -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('%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; diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 0bcfb1d7..7352211e 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -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 %s', $ipAddr)); if ($ipAddr === IpAddress::LOCALHOST) { $this->io->writeln(' [Ignored localhost address]'); @@ -156,7 +151,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat } catch (WrongIpException $e) { $this->io->writeln(' [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); } } diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index 07d66855..0c5ef184 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -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.', diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index 2c4ef0e2..afa0a864 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -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; } /** diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index 1a6b826e..66e94eb6 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -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); diff --git a/module/CLI/src/Util/ShlinkTable.php b/module/CLI/src/Util/ShlinkTable.php index ac8733ad..1d4143c1 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -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 = ' %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(); diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index a50c2b12..5353ca72 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -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()], diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index fc845ff7..a124993f 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -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 | +--------------------------------------+---------------+-----------------+-------+ diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php new file mode 100644 index 00000000..9801930e --- /dev/null +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -0,0 +1,180 @@ +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(); + } +} diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 04f7eb5d..13e6d062 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -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 = <<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 = <<domainService->listDomains()->willReturn([ - new DomainItem('foo.com', true), - new DomainItem('bar.com', false), - new DomainItem('baz.com', false), - ]); + $withRedirectsOutput = <<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]; } } diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index a6b6fc78..765a1c4b 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -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); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 6f7b11a6..f4ba2bb1 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -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, diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php index 7ead517d..62ea161a 100644 --- a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -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 { diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 74148f9c..fa666516 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -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()); diff --git a/module/CLI/test/Util/ShlinkTableTest.php b/module/CLI/test/Util/ShlinkTableTest.php index 71bff82b..1ca612d4 100644 --- a/module/CLI/test/Util/ShlinkTableTest.php +++ b/module/CLI/test/Util/ShlinkTableTest.php @@ -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'); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 7dfd5df2..7f28b14d 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -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, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php index e3d8c3cf..596f41da 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php @@ -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(); }; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 867f7c7d..7910ad1a 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -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), + }; } /** diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 567e930c..8e9aaa09 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -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); + } } diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php new file mode 100644 index 00000000..742d3f07 --- /dev/null +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -0,0 +1,112 @@ +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; + } +} diff --git a/module/Core/src/Action/PixelAction.php b/module/Core/src/Action/PixelAction.php index 5435c582..0cf2a801 100644 --- a/module/Core/src/Action/PixelAction.php +++ b/module/Core/src/Action/PixelAction.php @@ -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(); } diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 1b2b5012..2f816c98 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -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()); } } diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 7da67b59..8126a85a 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -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); - } } diff --git a/module/Core/src/Action/RobotsAction.php b/module/Core/src/Action/RobotsAction.php index 31539b92..12baa7b3 100644 --- a/module/Core/src/Action/RobotsAction.php +++ b/module/Core/src/Action/RobotsAction.php @@ -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()); } diff --git a/module/Core/src/Config/NotFoundRedirectConfigInterface.php b/module/Core/src/Config/NotFoundRedirectConfigInterface.php new file mode 100644 index 00000000..bbdfa9c5 --- /dev/null +++ b/module/Core/src/Config/NotFoundRedirectConfigInterface.php @@ -0,0 +1,20 @@ +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, + }; + } +} diff --git a/module/Core/src/Config/NotFoundRedirectResolverInterface.php b/module/Core/src/Config/NotFoundRedirectResolverInterface.php new file mode 100644 index 00000000..a5c55f3d --- /dev/null +++ b/module/Core/src/Config/NotFoundRedirectResolverInterface.php @@ -0,0 +1,16 @@ +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, + ]; + } +} diff --git a/module/Core/src/Crawling/CrawlingHelper.php b/module/Core/src/Crawling/CrawlingHelper.php index 5f688645..e620370f 100644 --- a/module/Core/src/Crawling/CrawlingHelper.php +++ b/module/Core/src/Crawling/CrawlingHelper.php @@ -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 diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 5a573799..6051c254 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -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; + } } diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 3588fbc6..7748284d 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -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; } diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 4006b186..cfd09d90 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -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; + } } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 2e4f3bb2..1741cea7 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -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()], + }) ?? []; + } } diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index 1d201520..123e349d 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -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; } diff --git a/module/Core/src/Domain/Spec/IsDomain.php b/module/Core/src/Domain/Spec/IsDomain.php new file mode 100644 index 00000000..cf7463cc --- /dev/null +++ b/module/Core/src/Domain/Spec/IsDomain.php @@ -0,0 +1,22 @@ +domainId); + } +} diff --git a/module/Core/src/Domain/Spec/IsNotAuthority.php b/module/Core/src/Domain/Spec/IsNotAuthority.php new file mode 100644 index 00000000..0f0f0653 --- /dev/null +++ b/module/Core/src/Domain/Spec/IsNotAuthority.php @@ -0,0 +1,22 @@ +authority)); + } +} diff --git a/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php new file mode 100644 index 00000000..de627c1c --- /dev/null +++ b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php @@ -0,0 +1,43 @@ +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)); + } +} diff --git a/module/Core/src/Entity/Domain.php b/module/Core/src/Entity/Domain.php index f836f7ed..65ca8ce6 100644 --- a/module/Core/src/Entity/Domain.php +++ b/module/Core/src/Entity/Domain.php @@ -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(); + } } diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 6f502da3..78527115 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -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(); diff --git a/module/Core/src/Entity/Tag.php b/module/Core/src/Entity/Tag.php index 54c05c56..8dc5cf29 100644 --- a/module/Core/src/Entity/Tag.php +++ b/module/Core/src/Entity/Tag.php @@ -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(); } diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index 358bedde..8174e8be 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -104,7 +104,7 @@ class Visit extends AbstractEntity implements JsonSerializable try { return (string) IpAddress::fromString($address)->getAnonymizedCopy(); - } catch (InvalidArgumentException $e) { + } catch (InvalidArgumentException) { return null; } } diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php index 57176e84..39970dea 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -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 diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index 1f3b4fed..44cd2ddd 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -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); } } diff --git a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php index 61d67403..cd0f60be 100644 --- a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php @@ -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) diff --git a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php index b81e55de..f3342c5a 100644 --- a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php @@ -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); } } diff --git a/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php index 6f13db73..7e36135a 100644 --- a/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php @@ -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 diff --git a/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php b/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php index 7f2c7297..079c6195 100644 --- a/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php +++ b/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php @@ -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; } diff --git a/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php b/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php index cbfc7208..6f8f00d1 100644 --- a/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php +++ b/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php @@ -12,7 +12,7 @@ class CloseDbConnectionEventListenerDelegator public function __invoke( ContainerInterface $container, string $name, - callable $callback + callable $callback, ): CloseDbConnectionEventListener { /** @var callable $wrapped */ $wrapped = $callback(); diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 09869cb2..c4bc1818 100644 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -8,11 +8,8 @@ use JsonSerializable; abstract class AbstractVisitEvent implements JsonSerializable { - protected string $visitId; - - public function __construct(string $visitId) + public function __construct(protected string $visitId) { - $this->visitId = $visitId; } public function visitId(): string diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index 87b9e4cb..633b439e 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -6,12 +6,9 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event; final class UrlVisited extends AbstractVisitEvent { - private ?string $originalIpAddress; - - public function __construct(string $visitId, ?string $originalIpAddress = null) + public function __construct(string $visitId, private ?string $originalIpAddress = null) { parent::__construct($visitId); - $this->originalIpAddress = $originalIpAddress; } public function originalIpAddress(): ?string diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index 0150c529..bb6ba1d0 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -19,24 +19,13 @@ use Throwable; class LocateVisit { - private IpLocationResolverInterface $ipLocationResolver; - private EntityManagerInterface $em; - private LoggerInterface $logger; - private DbUpdaterInterface $dbUpdater; - private EventDispatcherInterface $eventDispatcher; - public function __construct( - IpLocationResolverInterface $ipLocationResolver, - EntityManagerInterface $em, - LoggerInterface $logger, - DbUpdaterInterface $dbUpdater, - EventDispatcherInterface $eventDispatcher + private IpLocationResolverInterface $ipLocationResolver, + private EntityManagerInterface $em, + private LoggerInterface $logger, + private DbUpdaterInterface $dbUpdater, + private EventDispatcherInterface $eventDispatcher ) { - $this->ipLocationResolver = $ipLocationResolver; - $this->em = $em; - $this->logger = $logger; - $this->dbUpdater = $dbUpdater; - $this->eventDispatcher = $eventDispatcher; } public function __invoke(UrlVisited $shortUrlVisited): void @@ -66,7 +55,7 @@ class LocateVisit } $isLocatable = $originalIpAddress !== null || $visit->isLocatable(); - $addr = $originalIpAddress ?? $visit->getRemoteAddr(); + $addr = $originalIpAddress ?? $visit->getRemoteAddr() ?? ''; try { $location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance(); diff --git a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php index 33adf965..d1ad8201 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php @@ -17,21 +17,12 @@ use function Functional\each; class NotifyVisitToMercure { - private HubInterface $hub; - private MercureUpdatesGeneratorInterface $updatesGenerator; - private EntityManagerInterface $em; - private LoggerInterface $logger; - public function __construct( - HubInterface $hub, - MercureUpdatesGeneratorInterface $updatesGenerator, - EntityManagerInterface $em, - LoggerInterface $logger + private HubInterface $hub, + private MercureUpdatesGeneratorInterface $updatesGenerator, + private EntityManagerInterface $em, + private LoggerInterface $logger ) { - $this->hub = $hub; - $this->em = $em; - $this->logger = $logger; - $this->updatesGenerator = $updatesGenerator; } public function __invoke(VisitLocated $shortUrlLocated): void diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index b236a1c1..5b4e2818 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -24,28 +24,15 @@ use function Functional\partial_left; class NotifyVisitToWebHooks { - private ClientInterface $httpClient; - private EntityManagerInterface $em; - private LoggerInterface $logger; - /** @var string[] */ - private array $webhooks; - private DataTransformerInterface $transformer; - private AppOptions $appOptions; - public function __construct( - ClientInterface $httpClient, - EntityManagerInterface $em, - LoggerInterface $logger, - array $webhooks, - DataTransformerInterface $transformer, - AppOptions $appOptions + private ClientInterface $httpClient, + private EntityManagerInterface $em, + private LoggerInterface $logger, + /** @var string[] */ + private array $webhooks, + private DataTransformerInterface $transformer, + private AppOptions $appOptions ) { - $this->httpClient = $httpClient; - $this->em = $em; - $this->logger = $logger; - $this->webhooks = $webhooks; - $this->transformer = $transformer; - $this->appOptions = $appOptions; } public function __invoke(VisitLocated $shortUrlLocated): void diff --git a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php index f17a7ffb..13941f43 100644 --- a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php +++ b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php @@ -12,13 +12,8 @@ use function sprintf; class UpdateGeoLiteDb { - private GeolocationDbUpdaterInterface $dbUpdater; - private LoggerInterface $logger; - - public function __construct(GeolocationDbUpdaterInterface $dbUpdater, LoggerInterface $logger) + public function __construct(private GeolocationDbUpdaterInterface $dbUpdater, private LoggerInterface $logger) { - $this->dbUpdater = $dbUpdater; - $this->logger = $logger; } public function __invoke(): void diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index f98b7e14..600fca57 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -15,7 +15,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE use CommonProblemDetailsExceptionTrait; private const TITLE = 'Cannot delete short URL'; - private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION + private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Deprecated: Should be INVALID_SHORT_URL_DELETION public static function fromVisitsThreshold(int $threshold, string $shortCode): self { diff --git a/module/Core/src/Exception/DomainNotFoundException.php b/module/Core/src/Exception/DomainNotFoundException.php index b1b97c91..cb19608a 100644 --- a/module/Core/src/Exception/DomainNotFoundException.php +++ b/module/Core/src/Exception/DomainNotFoundException.php @@ -17,16 +17,27 @@ class DomainNotFoundException extends DomainException implements ProblemDetailsE private const TITLE = 'Domain not found'; private const TYPE = 'DOMAIN_NOT_FOUND'; + private function __construct(string $message, array $additional) + { + parent::__construct($message); + + $this->detail = $message; + $this->title = self::TITLE; + $this->type = self::TYPE; + $this->status = StatusCodeInterface::STATUS_NOT_FOUND; + $this->additional = $additional; + } + public static function fromId(string $id): self { - $e = new self(sprintf('Domain with id "%s" could not be found', $id)); + return new self(sprintf('Domain with id "%s" could not be found', $id), ['id' => $id]); + } - $e->detail = $e->getMessage(); - $e->title = self::TITLE; - $e->type = self::TYPE; - $e->status = StatusCodeInterface::STATUS_NOT_FOUND; - $e->additional = ['id' => $id]; - - return $e; + public static function fromAuthority(string $authority): self + { + return new self( + sprintf('Domain with authority "%s" could not be found', $authority), + ['authority' => $authority], + ); } } diff --git a/module/Core/src/Exception/InvalidDomainException.php b/module/Core/src/Exception/InvalidDomainException.php new file mode 100644 index 00000000..d41e71ac --- /dev/null +++ b/module/Core/src/Exception/InvalidDomainException.php @@ -0,0 +1,33 @@ +detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_FORBIDDEN; + + return $e; + } +} diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index e700e8a8..b153430b 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -20,23 +20,15 @@ use function sprintf; class ImportedLinksProcessor implements ImportedLinksProcessorInterface { - private EntityManagerInterface $em; - private ShortUrlRelationResolverInterface $relationResolver; - private ShortCodeHelperInterface $shortCodeHelper; - private DoctrineBatchHelperInterface $batchHelper; private ShortUrlRepositoryInterface $shortUrlRepo; public function __construct( - EntityManagerInterface $em, - ShortUrlRelationResolverInterface $relationResolver, - ShortCodeHelperInterface $shortCodeHelper, - DoctrineBatchHelperInterface $batchHelper + private EntityManagerInterface $em, + private ShortUrlRelationResolverInterface $relationResolver, + private ShortCodeHelperInterface $shortCodeHelper, + private DoctrineBatchHelperInterface $batchHelper ) { - $this->em = $em; - $this->relationResolver = $relationResolver; - $this->shortCodeHelper = $shortCodeHelper; - $this->batchHelper = $batchHelper; - $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line + $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); } /** @@ -64,7 +56,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface try { $shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict); - } catch (NonUniqueSlugException $e) { + } catch (NonUniqueSlugException) { $io->text(sprintf('%s: Error', $longUrl)); continue; } @@ -77,7 +69,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface private function resolveShortUrl( ImportedShlinkUrl $importedUrl, bool $importShortCodes, - callable $skipOnShortCodeConflict + callable $skipOnShortCodeConflict, ): ShortUrlImporting { $alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl); if ($alreadyImportedShortUrl !== null) { @@ -96,7 +88,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface private function handleShortCodeUniqueness( ShortUrl $shortUrl, bool $importShortCodes, - callable $skipOnShortCodeConflict + callable $skipOnShortCodeConflict, ): bool { if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) { return true; diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index b5ae4651..a925c5d5 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -14,13 +14,8 @@ use function sprintf; final class ShortUrlImporting { - private ShortUrl $shortUrl; - private bool $isNew; - - private function __construct(ShortUrl $shortUrl, bool $isNew) + private function __construct(private ShortUrl $shortUrl, private bool $isNew) { - $this->shortUrl = $shortUrl; - $this->isNew = $isNew; } public static function fromExistingShortUrl(ShortUrl $shortUrl): self @@ -43,10 +38,7 @@ final class ShortUrlImporting $importedVisits = 0; foreach ($visits as $importedVisit) { // Skip visits which are older than the most recent already imported visit's date - if ( - $mostRecentImportedDate !== null - && $mostRecentImportedDate->gte(Chronos::instance($importedVisit->date())) - ) { + if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date()))) { continue; } diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php index 23b3796c..f2489da3 100644 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -18,15 +18,10 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit'; private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit'; - private DataTransformerInterface $shortUrlTransformer; - private DataTransformerInterface $orphanVisitTransformer; - public function __construct( - DataTransformerInterface $shortUrlTransformer, - DataTransformerInterface $orphanVisitTransformer + private DataTransformerInterface $shortUrlTransformer, + private DataTransformerInterface $orphanVisitTransformer ) { - $this->shortUrlTransformer = $shortUrlTransformer; - $this->orphanVisitTransformer = $orphanVisitTransformer; } public function newVisitUpdate(Visit $visit): Update @@ -47,7 +42,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface public function newShortUrlVisitUpdate(Visit $visit): Update { $shortUrl = $visit->getShortUrl(); - $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode()); + $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode()); return new Update($topic, $this->serialize([ 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), diff --git a/module/Core/src/Model/ShortUrlIdentifier.php b/module/Core/src/Model/ShortUrlIdentifier.php index a277782c..815a5313 100644 --- a/module/Core/src/Model/ShortUrlIdentifier.php +++ b/module/Core/src/Model/ShortUrlIdentifier.php @@ -10,13 +10,8 @@ use Symfony\Component\Console\Input\InputInterface; final class ShortUrlIdentifier { - private string $shortCode; - private ?string $domain; - - public function __construct(string $shortCode, ?string $domain = null) + public function __construct(private string $shortCode, private ?string $domain = null) { - $this->shortCode = $shortCode; - $this->domain = $domain; } public static function fromApiRequest(ServerRequestInterface $request): self @@ -37,7 +32,11 @@ final class ShortUrlIdentifier public static function fromCli(InputInterface $input): self { + // Using getArguments and getOptions instead of getArgument(...) and getOption(...) because + // the later throw an exception if requested options are not defined + /** @var string $shortCode */ $shortCode = $input->getArguments()['shortCode'] ?? ''; + /** @var string|null $domain */ $domain = $input->getOptions()['domain'] ?? null; return new self($shortCode, $domain); @@ -46,7 +45,7 @@ final class ShortUrlIdentifier public static function fromShortUrl(ShortUrl $shortUrl): self { $domain = $shortUrl->getDomain(); - $domainAuthority = $domain !== null ? $domain->getAuthority() : null; + $domainAuthority = $domain?->getAuthority(); return new self($shortUrl->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/Model/ShortUrlsOrdering.php b/module/Core/src/Model/ShortUrlsOrdering.php index b59435ca..4184fcc6 100644 --- a/module/Core/src/Model/ShortUrlsOrdering.php +++ b/module/Core/src/Model/ShortUrlsOrdering.php @@ -49,7 +49,6 @@ final class ShortUrlsOrdering ]); } - /** @var string|array $orderBy */ if (! $isArray) { [$field, $dir] = array_pad(explode('-', $orderBy), 2, null); $this->orderField = $field; diff --git a/module/Core/src/Model/ShortUrlsParams.php b/module/Core/src/Model/ShortUrlsParams.php index 4ac69985..2336b18a 100644 --- a/module/Core/src/Model/ShortUrlsParams.php +++ b/module/Core/src/Model/ShortUrlsParams.php @@ -15,7 +15,7 @@ final class ShortUrlsParams public const DEFAULT_ITEMS_PER_PAGE = 10; private int $page; - private ?int $itemsPerPage = null; + private int $itemsPerPage; private ?string $searchTerm; private array $tags; private ShortUrlsOrdering $orderBy; diff --git a/module/Core/src/Model/Visitor.php b/module/Core/src/Model/Visitor.php index b73ed68a..9436e900 100644 --- a/module/Core/src/Model/Visitor.php +++ b/module/Core/src/Model/Visitor.php @@ -29,13 +29,16 @@ final class Visitor $this->userAgent = $this->cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH); $this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH); $this->visitedUrl = $this->cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH); - $this->remoteAddress = $this->cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH); + $this->remoteAddress = $remoteAddress === null ? null : $this->cropToLength( + $remoteAddress, + self::REMOTE_ADDRESS_MAX_LENGTH, + ); $this->potentialBot = isCrawler($userAgent); } - private function cropToLength(?string $value, int $length): ?string + private function cropToLength(string $value, int $length): string { - return $value === null ? null : substr($value, 0, $length); + return substr($value, 0, $length); } public static function fromRequest(ServerRequestInterface $request): self diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index abaa8fc1..5ace1d8d 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -13,21 +13,19 @@ final class VisitsParams private const FIRST_PAGE = 1; private const ALL_ITEMS = -1; - private ?DateRange $dateRange; + private DateRange $dateRange; private int $page; private int $itemsPerPage; - private bool $excludeBots; public function __construct( ?DateRange $dateRange = null, int $page = self::FIRST_PAGE, ?int $itemsPerPage = null, - bool $excludeBots = false + private bool $excludeBots = false ) { $this->dateRange = $dateRange ?? new DateRange(); $this->page = $this->determinePage($page); $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage); - $this->excludeBots = $excludeBots; } private function determinePage(int $page): int diff --git a/module/Core/src/Options/NotFoundRedirectOptions.php b/module/Core/src/Options/NotFoundRedirectOptions.php index 1bb3b828..2f2d813b 100644 --- a/module/Core/src/Options/NotFoundRedirectOptions.php +++ b/module/Core/src/Options/NotFoundRedirectOptions.php @@ -5,14 +5,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; use Laminas\Stdlib\AbstractOptions; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; -class NotFoundRedirectOptions extends AbstractOptions +class NotFoundRedirectOptions extends AbstractOptions implements NotFoundRedirectConfigInterface { private ?string $invalidShortUrl = null; private ?string $regular404 = null; private ?string $baseUrl = null; - public function getInvalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): ?string { return $this->invalidShortUrl; } @@ -28,7 +29,7 @@ class NotFoundRedirectOptions extends AbstractOptions return $this; } - public function getRegular404Redirect(): ?string + public function regular404Redirect(): ?string { return $this->regular404; } @@ -44,7 +45,7 @@ class NotFoundRedirectOptions extends AbstractOptions return $this; } - public function getBaseUrlRedirect(): ?string + public function baseUrlRedirect(): ?string { return $this->baseUrl; } diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index a0005da2..31ecc137 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -19,6 +19,7 @@ class UrlShortenerOptions extends AbstractOptions private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; private bool $autoResolveTitles = false; + private bool $appendExtraPath = false; public function isUrlValidationEnabled(): bool { @@ -67,6 +68,16 @@ class UrlShortenerOptions extends AbstractOptions $this->autoResolveTitles = $autoResolveTitles; } + public function appendExtraPath(): bool + { + return $this->appendExtraPath; + } + + protected function setAppendExtraPath(bool $appendExtraPath): void + { + $this->appendExtraPath = $appendExtraPath; + } + /** @deprecated */ protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void { diff --git a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index d7361fb3..18c2c435 100644 --- a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -11,13 +11,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - private VisitRepositoryInterface $repo; - private VisitsParams $params; - - public function __construct(VisitRepositoryInterface $repo, VisitsParams $params) + public function __construct(private VisitRepositoryInterface $repo, private VisitsParams $params) { - $this->repo = $repo; - $this->params = $params; } protected function doCount(): int diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 093bd8fd..e297b6c0 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; -use Happyr\DoctrineSpecification\Specification\Specification; use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; @@ -12,15 +11,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapter implements AdapterInterface { - private ShortUrlRepositoryInterface $repository; - private ShortUrlsParams $params; - private ?ApiKey $apiKey; - - public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params, ?ApiKey $apiKey) - { - $this->repository = $repository; - $this->params = $params; - $this->apiKey = $apiKey; + public function __construct( + private ShortUrlRepositoryInterface $repository, + private ShortUrlsParams $params, + private ?ApiKey $apiKey + ) { } public function getSlice($offset, $length): array // phpcs:ignore @@ -32,7 +27,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->tags(), $this->params->orderBy(), $this->params->dateRange(), - $this->resolveSpec(), + $this->apiKey?->spec(), ); } @@ -42,12 +37,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->searchTerm(), $this->params->tags(), $this->params->dateRange(), - $this->resolveSpec(), + $this->apiKey?->spec(), ); } - - private function resolveSpec(): ?Specification - { - return $this->apiKey !== null ? $this->apiKey->spec() : null; - } } diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php index d7c0580f..dbbf8bb9 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; -use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; @@ -13,21 +12,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - private VisitRepositoryInterface $visitRepository; - private string $tag; - private VisitsParams $params; - private ?ApiKey $apiKey; - public function __construct( - VisitRepositoryInterface $visitRepository, - string $tag, - VisitsParams $params, - ?ApiKey $apiKey + private VisitRepositoryInterface $visitRepository, + private string $tag, + private VisitsParams $params, + private ?ApiKey $apiKey ) { - $this->visitRepository = $visitRepository; - $this->params = $params; - $this->tag = $tag; - $this->apiKey = $apiKey; } public function getSlice($offset, $length): array // phpcs:ignore @@ -37,7 +27,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte new VisitsListFiltering( $this->params->getDateRange(), $this->params->excludeBots(), - $this->resolveSpec(), + $this->apiKey?->spec(true), $length, $offset, ), @@ -51,13 +41,8 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte new VisitsCountFiltering( $this->params->getDateRange(), $this->params->excludeBots(), - $this->resolveSpec(), + $this->apiKey?->spec(true), ), ); } - - private function resolveSpec(): ?Specification - { - return $this->apiKey !== null ? $this->apiKey->spec(true) : null; - } } diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php index d651b1b5..fa6833f8 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php @@ -13,21 +13,12 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - private VisitRepositoryInterface $visitRepository; - private ShortUrlIdentifier $identifier; - private VisitsParams $params; - private ?Specification $spec; - public function __construct( - VisitRepositoryInterface $visitRepository, - ShortUrlIdentifier $identifier, - VisitsParams $params, - ?Specification $spec + private VisitRepositoryInterface $visitRepository, + private ShortUrlIdentifier $identifier, + private VisitsParams $params, + private ?Specification $spec ) { - $this->visitRepository = $visitRepository; - $this->params = $params; - $this->identifier = $identifier; - $this->spec = $spec; } public function getSlice($offset, $length): array // phpcs:ignore diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index c658d478..4c3a4e9c 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -18,7 +18,6 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function array_column; -use function array_key_exists; use function count; use function Functional\contains; @@ -35,7 +34,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU array $tags = [], ?ShortUrlsOrdering $orderBy = null, ?DateRange $dateRange = null, - ?Specification $spec = null + ?Specification $spec = null, ): array { $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb->select('DISTINCT s') @@ -43,7 +42,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ->setFirstResult($offset); // In case the ordering has been specified, the query could be more complex. Process it - if ($orderBy !== null && $orderBy->hasOrderField()) { + if ($orderBy?->hasOrderField()) { return $this->processOrderByForList($qb, $orderBy); } @@ -59,6 +58,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU // visitsCount and visitCount are deprecated. Only visits should work if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) { + // FIXME This query is inefficient. Debug it. $qb->addSelect('COUNT(DISTINCT v) AS totalVisits') ->leftJoin('s.visits', 'v') ->groupBy('s') @@ -75,9 +75,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU 'dateCreated' => 'dateCreated', 'title' => 'title', ]; - if (array_key_exists($fieldName, $fieldNameMap)) { - $qb->orderBy('s.' . $fieldNameMap[$fieldName], $order); + $resolvedFieldName = $fieldNameMap[$fieldName] ?? null; + if ($resolvedFieldName !== null) { + $qb->orderBy('s.' . $resolvedFieldName, $order); } + return $qb->getQuery()->getResult(); } @@ -85,7 +87,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null, - ?Specification $spec = null + ?Specification $spec = null, ): int { $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb->select('COUNT(DISTINCT s)'); @@ -97,17 +99,17 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ?string $searchTerm, array $tags, ?DateRange $dateRange, - ?Specification $spec + ?Specification $spec, ): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') ->where('1=1'); - if ($dateRange !== null && $dateRange->getStartDate() !== null) { + if ($dateRange?->getStartDate() !== null) { $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); $qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME); } - if ($dateRange !== null && $dateRange->getEndDate() !== null) { + if ($dateRange?->getEndDate() !== null) { $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); $qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME); } @@ -194,10 +196,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?int $lockMode): bool { - $qb = $this->createFindOneQueryBuilder($identifier, $spec); - $qb->select('s.id'); + $qb = $this->createFindOneQueryBuilder($identifier, $spec)->select('s.id'); + $query = $qb->getQuery(); - $query = $qb->getQuery()->setLockMode($lockMode); + if ($lockMode !== null) { + $query = $query->setLockMode($lockMode); + } return $query->getOneOrNullResult() !== null; } diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 7489f2a0..e2927286 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -23,14 +23,14 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat array $tags = [], ?ShortUrlsOrdering $orderBy = null, ?DateRange $dateRange = null, - ?Specification $spec = null + ?Specification $spec = null, ): array; public function countList( ?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null, - ?Specification $spec = null + ?Specification $spec = null, ): int; public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl; diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 61cd108e..6adba193 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -71,14 +71,14 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $iterator = $qb->getQuery()->toIterable(); $resultsFound = false; - /** @var Visit $visit */ foreach ($iterator as $key => $visit) { $resultsFound = true; yield $key => $visit; } // As the query is ordered by ID, we can take the last one every time in order to exclude the whole list - $lastId = isset($visit) ? $visit->getId() : $lastId; + /** @var Visit|null $visit */ + $lastId = $visit?->getId() ?? $lastId; } while ($resultsFound); } @@ -101,12 +101,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo private function createVisitsByShortCodeQueryBuilder( ShortUrlIdentifier $identifier, - VisitsCountFiltering $filtering + VisitsCountFiltering $filtering, ): QueryBuilder { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); $shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec()); - $shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1; + $shortUrlId = $shortUrl?->getId() ?? '-1'; // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later // Since they are not strictly provided by the caller, it's reasonably safe @@ -187,10 +187,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void { - if ($dateRange !== null && $dateRange->getStartDate() !== null) { + if ($dateRange?->getStartDate() !== null) { $qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\'')); } - if ($dateRange !== null && $dateRange->getEndDate() !== null) { + if ($dateRange?->getEndDate() !== null) { $qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\'')); } } diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php index 07af448d..1bcd5ccb 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php @@ -13,18 +13,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteShortUrlService implements DeleteShortUrlServiceInterface { - private EntityManagerInterface $em; - private DeleteShortUrlsOptions $deleteShortUrlsOptions; - private ShortUrlResolverInterface $urlResolver; - public function __construct( - EntityManagerInterface $em, - DeleteShortUrlsOptions $deleteShortUrlsOptions, - ShortUrlResolverInterface $urlResolver + private EntityManagerInterface $em, + private DeleteShortUrlsOptions $deleteShortUrlsOptions, + private ShortUrlResolverInterface $urlResolver ) { - $this->em = $em; - $this->deleteShortUrlsOptions = $deleteShortUrlsOptions; - $this->urlResolver = $urlResolver; } /** @@ -34,7 +27,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface public function deleteByShortCode( ShortUrlIdentifier $identifier, bool $ignoreThreshold = false, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): void { $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php index b1f01839..0767c723 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php @@ -17,6 +17,6 @@ interface DeleteShortUrlServiceInterface public function deleteByShortCode( ShortUrlIdentifier $identifier, bool $ignoreThreshold = false, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): void; } diff --git a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php b/module/Core/src/Service/ShortUrl/ShortCodeHelper.php index 83c3397e..5bb992c5 100644 --- a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php +++ b/module/Core/src/Service/ShortUrl/ShortCodeHelper.php @@ -11,11 +11,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php index 1394e1ab..61c57d36 100644 --- a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php @@ -13,11 +13,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlResolver implements ShortUrlResolverInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } /** @@ -27,7 +24,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null); + $shortUrl = $shortUrlRepo->findOne($identifier, $apiKey?->spec()); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } @@ -43,7 +40,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier->shortCode(), $identifier->domain()); - if ($shortUrl === null || ! $shortUrl->isEnabled()) { + if (! $shortUrl?->isEnabled()) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index dcb1d8cc..2a576ce9 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -21,21 +21,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlService implements ShortUrlServiceInterface { - private ORM\EntityManagerInterface $em; - private ShortUrlResolverInterface $urlResolver; - private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; - private ShortUrlRelationResolverInterface $relationResolver; - public function __construct( - ORM\EntityManagerInterface $em, - ShortUrlResolverInterface $urlResolver, - ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - ShortUrlRelationResolverInterface $relationResolver + private ORM\EntityManagerInterface $em, + private ShortUrlResolverInterface $urlResolver, + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private ShortUrlRelationResolverInterface $relationResolver ) { - $this->em = $em; - $this->urlResolver = $urlResolver; - $this->titleResolutionHelper = $titleResolutionHelper; - $this->relationResolver = $relationResolver; } /** @@ -59,7 +50,7 @@ class ShortUrlService implements ShortUrlServiceInterface public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { /** @var ShortUrlEdit $shortUrlEdit */ diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 3884b55e..e0a73981 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -27,6 +27,6 @@ interface ShortUrlServiceInterface public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): ShortUrl; } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 78064259..24ac2c70 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -16,21 +16,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; class UrlShortener implements UrlShortenerInterface { - private EntityManagerInterface $em; - private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; - private ShortUrlRelationResolverInterface $relationResolver; - private ShortCodeHelperInterface $shortCodeHelper; - public function __construct( - ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - EntityManagerInterface $em, - ShortUrlRelationResolverInterface $relationResolver, - ShortCodeHelperInterface $shortCodeHelper + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private EntityManagerInterface $em, + private ShortUrlRelationResolverInterface $relationResolver, + private ShortCodeHelperInterface $shortCodeHelper ) { - $this->titleResolutionHelper = $titleResolutionHelper; - $this->em = $em; - $this->relationResolver = $relationResolver; - $this->shortCodeHelper = $shortCodeHelper; } /** @@ -78,7 +69,7 @@ class UrlShortener implements UrlShortenerInterface if (! $couldBeMadeUnique) { $domain = $shortUrlToBeCreated->getDomain(); - $domainAuthority = $domain !== null ? $domain->getAuthority() : null; + $domainAuthority = $domain?->getAuthority(); throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php new file mode 100644 index 00000000..43ea4993 --- /dev/null +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -0,0 +1,50 @@ +getLongUrl()); + + return $uri + ->withQuery($this->resolveQuery($uri, $currentQuery)) + ->withPath($this->resolvePath($uri, $extraPath)) + ->__toString(); + } + + private function resolveQuery(Uri $uri, array $currentQuery): ?string + { + $hardcodedQuery = Query::parse($uri->getQuery() ?? ''); + + $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); + if ($disableTrackParam !== null) { + unset($currentQuery[$disableTrackParam]); + } + + $mergedQuery = array_merge($hardcodedQuery, $currentQuery); + + return empty($mergedQuery) ? null : Query::build($mergedQuery); + } + + private function resolvePath(Uri $uri, ?string $extraPath): string + { + $hardcodedPath = $uri->getPath(); + return $extraPath === null ? $hardcodedPath : sprintf('%s%s', $hardcodedPath, $extraPath); + } +} diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php new file mode 100644 index 00000000..d957ad14 --- /dev/null +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php @@ -0,0 +1,12 @@ +domainConfig = $domainConfig; - $this->basePath = $basePath; } public function stringify(ShortUrl $shortUrl): string diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index 4615e45f..00eecc61 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -8,11 +8,8 @@ use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface { - private UrlValidatorInterface $urlValidator; - - public function __construct(UrlValidatorInterface $urlValidator) + public function __construct(private UrlValidatorInterface $urlValidator) { - $this->urlValidator = $urlValidator; } public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php new file mode 100644 index 00000000..9d92067c --- /dev/null +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -0,0 +1,73 @@ +getAttribute(NotFoundType::class); + + // We'll apply this logic only if actively opted in and current URL is potentially /{shortCode}/[...] + if (! $notFoundType?->isRegularNotFound() || ! $this->urlShortenerOptions->appendExtraPath()) { + return $handler->handle($request); + } + + $uri = $request->getUri(); + $query = $request->getQueryParams(); + [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority()); + + try { + $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); + $this->requestTracker->trackIfApplicable($shortUrl, $request); + + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); + return $this->redirectResponseHelper->buildRedirectResponse($longUrl); + } catch (ShortUrlNotFoundException) { + return $handler->handle($request); + } + } + + /** + * @return array{0: string, 1: string|null} + */ + private function resolvePotentialShortCodeAndExtraPath(UriInterface $uri): array + { + $pathParts = explode('/', trim($uri->getPath(), '/'), 2); + [$potentialShortCode, $extraPath] = array_pad($pathParts, 2, null); + + return [$potentialShortCode, $extraPath === null ? null : sprintf('/%s', $extraPath)]; + } +} diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 8601a045..c8367b49 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -16,16 +16,13 @@ use function Functional\unique; class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface { - private EntityManagerInterface $em; - /** @var array */ private array $memoizedNewDomains = []; /** @var array */ private array $memoizedNewTags = []; - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; $this->em->getEventManager()->addEventListener(Events::postFlush, $this); } @@ -44,7 +41,9 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt private function memoizeNewDomain(string $domain): Domain { - return $this->memoizedNewDomains[$domain] = $this->memoizedNewDomains[$domain] ?? new Domain($domain); + return $this->memoizedNewDomains[$domain] = $this->memoizedNewDomains[$domain] ?? Domain::withAuthority( + $domain, + ); } /** diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index 2cda44df..173b530c 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -15,7 +15,7 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac { public function resolveDomain(?string $domain): ?Domain { - return $domain !== null ? new Domain($domain) : null; + return $domain !== null ? Domain::withAuthority($domain) : null; } /** diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php index 4aa3579f..3c95593c 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -11,18 +11,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class BelongsToApiKey extends BaseSpecification { - private ApiKey $apiKey; - private ?string $dqlAlias; - - public function __construct(ApiKey $apiKey, ?string $dqlAlias = null) + public function __construct(private ApiKey $apiKey, ?string $context = null) { - $this->apiKey = $apiKey; - $this->dqlAlias = $dqlAlias; - parent::__construct(); + parent::__construct($context); } protected function getSpec(): Filter { - return Spec::eq('authorApiKey', $this->apiKey, $this->dqlAlias); + return Spec::eq('authorApiKey', $this->apiKey); } } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php index 579407cd..6b103058 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php @@ -10,11 +10,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class BelongsToApiKeyInlined implements Filter { - private ApiKey $apiKey; - - public function __construct(ApiKey $apiKey) + public function __construct(private ApiKey $apiKey) { - $this->apiKey = $apiKey; } public function getFilter(QueryBuilder $qb, string $dqlAlias): string diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php index 7745ff27..33eacec8 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -10,13 +10,8 @@ use Happyr\DoctrineSpecification\Specification\BaseSpecification; class BelongsToDomain extends BaseSpecification { - private string $domainId; - private ?string $dqlAlias; - - public function __construct(string $domainId, ?string $dqlAlias = null) + public function __construct(private string $domainId, private ?string $dqlAlias = null) { - $this->domainId = $domainId; - $this->dqlAlias = $dqlAlias; parent::__construct(); } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php index cb69a359..4ce130b7 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php @@ -9,14 +9,11 @@ use Happyr\DoctrineSpecification\Filter\Filter; class BelongsToDomainInlined implements Filter { - private string $domainId; - - public function __construct(string $domainId) + public function __construct(private string $domainId) { - $this->domainId = $domainId; } - public function getFilter(QueryBuilder $qb, string $dqlAlias): string + public function getFilter(QueryBuilder $qb, string $context): string { // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\''); diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index 52b98c36..61049626 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -13,11 +13,8 @@ use function Functional\invoke_if; class ShortUrlDataTransformer implements DataTransformerInterface { - private ShortUrlStringifierInterface $stringifier; - - public function __construct(ShortUrlStringifierInterface $stringifier) + public function __construct(private ShortUrlStringifierInterface $stringifier) { - $this->stringifier = $stringifier; } /** diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php index 953ed9f2..81d11b9e 100644 --- a/module/Core/src/Spec/InDateRange.php +++ b/module/Core/src/Spec/InDateRange.php @@ -11,25 +11,20 @@ use Shlinkio\Shlink\Common\Util\DateRange; class InDateRange extends BaseSpecification { - private ?DateRange $dateRange; - private string $field; - - public function __construct(?DateRange $dateRange, string $field = 'date') + public function __construct(private ?DateRange $dateRange, private string $field = 'date') { parent::__construct(); - $this->dateRange = $dateRange; - $this->field = $field; } protected function getSpec(): Specification { $criteria = []; - if ($this->dateRange !== null && $this->dateRange->getStartDate() !== null) { + if ($this->dateRange?->getStartDate() !== null) { $criteria[] = Spec::gte($this->field, $this->dateRange->getStartDate()->toDateTimeString()); } - if ($this->dateRange !== null && $this->dateRange->getEndDate() !== null) { + if ($this->dateRange?->getEndDate() !== null) { $criteria[] = Spec::lte($this->field, $this->dateRange->getEndDate()->toDateTimeString()); } diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index dbc51316..1a436cd4 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -9,15 +9,8 @@ use Shlinkio\Shlink\Core\Entity\Tag; final class TagInfo implements JsonSerializable { - private Tag $tag; - private int $shortUrlsCount; - private int $visitsCount; - - public function __construct(Tag $tag, int $shortUrlsCount, int $visitsCount) + public function __construct(private Tag $tag, private int $shortUrlsCount, private int $visitsCount) { - $this->tag = $tag; - $this->shortUrlsCount = $shortUrlsCount; - $this->visitsCount = $visitsCount; } public function tag(): Tag diff --git a/module/Core/src/Tag/Spec/CountTagsWithName.php b/module/Core/src/Tag/Spec/CountTagsWithName.php index 8dd3e44d..021507d1 100644 --- a/module/Core/src/Tag/Spec/CountTagsWithName.php +++ b/module/Core/src/Tag/Spec/CountTagsWithName.php @@ -10,12 +10,9 @@ use Happyr\DoctrineSpecification\Specification\Specification; class CountTagsWithName extends BaseSpecification { - private string $tagName; - - public function __construct(string $tagName) + public function __construct(private string $tagName) { parent::__construct(); - $this->tagName = $tagName; } protected function getSpec(): Specification diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 4619bd9d..61ed211d 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -23,11 +23,8 @@ class TagService implements TagServiceInterface { use TagManagerTrait; - private ORM\EntityManagerInterface $em; - - public function __construct(ORM\EntityManagerInterface $em) + public function __construct(private ORM\EntityManagerInterface $em) { - $this->em = $em; } /** diff --git a/module/Core/src/Util/CocurSymfonySluggerBridge.php b/module/Core/src/Util/CocurSymfonySluggerBridge.php index 9415e47c..da60836e 100644 --- a/module/Core/src/Util/CocurSymfonySluggerBridge.php +++ b/module/Core/src/Util/CocurSymfonySluggerBridge.php @@ -7,20 +7,16 @@ namespace Shlinkio\Shlink\Core\Util; use Cocur\Slugify\SlugifyInterface; use Symfony\Component\String\AbstractUnicodeString; use Symfony\Component\String\Slugger\SluggerInterface; - -use function Symfony\Component\String\s; +use Symfony\Component\String\UnicodeString; class CocurSymfonySluggerBridge implements SluggerInterface { - private SlugifyInterface $slugger; - - public function __construct(SlugifyInterface $slugger) + public function __construct(private SlugifyInterface $slugger) { - $this->slugger = $slugger; } public function slug(string $string, string $separator = '-', ?string $locale = null): AbstractUnicodeString { - return s($this->slugger->slugify($string, $separator)); + return new UnicodeString($this->slugger->slugify($string, $separator)); } } diff --git a/module/Core/src/Util/DoctrineBatchHelper.php b/module/Core/src/Util/DoctrineBatchHelper.php index 207d2093..5591ddb2 100644 --- a/module/Core/src/Util/DoctrineBatchHelper.php +++ b/module/Core/src/Util/DoctrineBatchHelper.php @@ -12,11 +12,8 @@ use Throwable; */ class DoctrineBatchHelper implements DoctrineBatchHelperInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } /** diff --git a/module/Core/src/Util/RedirectResponseHelper.php b/module/Core/src/Util/RedirectResponseHelper.php index 58f6e145..5f9edf99 100644 --- a/module/Core/src/Util/RedirectResponseHelper.php +++ b/module/Core/src/Util/RedirectResponseHelper.php @@ -13,11 +13,8 @@ use function sprintf; class RedirectResponseHelper implements RedirectResponseHelperInterface { - private UrlShortenerOptions $options; - - public function __construct(UrlShortenerOptions $options) + public function __construct(private UrlShortenerOptions $options) { - $this->options = $options; } public function buildRedirectResponse(string $location): ResponseInterface diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index 23de39ef..0756f55e 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -23,13 +23,8 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' . 'Chrome/51.0.2704.103 Safari/537.36'; - private ClientInterface $httpClient; - private UrlShortenerOptions $options; - - public function __construct(ClientInterface $httpClient, UrlShortenerOptions $options) + public function __construct(private ClientInterface $httpClient, private UrlShortenerOptions $options) { - $this->httpClient = $httpClient; - $this->options = $options; } /** diff --git a/module/Core/src/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php index c7cdaa43..b969d95e 100644 --- a/module/Core/src/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/Validation/ShortUrlInputFilter.php @@ -75,7 +75,7 @@ class ShortUrlInputFilter extends InputFilter $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([ 'regexp' => CUSTOM_SLUGS_REGEXP, - 'lowercase' => false, // We want to keep it case sensitive + 'lowercase' => false, // We want to keep it case-sensitive 'rulesets' => ['default'], ])))); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index 982f03c4..475a25b5 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -8,13 +8,8 @@ use JsonSerializable; final class VisitsStats implements JsonSerializable { - private int $visitsCount; - private int $orphanVisitsCount; - - public function __construct(int $visitsCount, int $orphanVisitsCount) + public function __construct(private int $visitsCount, private int $orphanVisitsCount) { - $this->visitsCount = $visitsCount; - $this->orphanVisitsCount = $orphanVisitsCount; } public function jsonSerialize(): array diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index bc9ac5de..9f48275f 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -9,15 +9,11 @@ use Shlinkio\Shlink\Common\Util\DateRange; class VisitsCountFiltering { - private ?DateRange $dateRange; - private bool $excludeBots; - private ?Specification $spec; - - public function __construct(?DateRange $dateRange = null, bool $excludeBots = false, ?Specification $spec = null) - { - $this->dateRange = $dateRange; - $this->excludeBots = $excludeBots; - $this->spec = $spec; + public function __construct( + private ?DateRange $dateRange = null, + private bool $excludeBots = false, + private ?Specification $spec = null + ) { } public function dateRange(): ?DateRange diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php index 4f67967d..173e308e 100644 --- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -9,19 +9,14 @@ use Shlinkio\Shlink\Common\Util\DateRange; final class VisitsListFiltering extends VisitsCountFiltering { - private ?int $limit; - private ?int $offset; - public function __construct( ?DateRange $dateRange = null, bool $excludeBots = false, ?Specification $spec = null, - ?int $limit = null, - ?int $offset = null + private ?int $limit = null, + private ?int $offset = null ) { parent::__construct($dateRange, $excludeBots, $spec); - $this->limit = $limit; - $this->offset = $offset; } public function limit(): ?int diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php new file mode 100644 index 00000000..3e5bfb51 --- /dev/null +++ b/module/Core/src/Visit/RequestTracker.php @@ -0,0 +1,60 @@ +shouldTrackRequest($request)) { + $this->visitsTracker->track($shortUrl, Visitor::fromRequest($request)); + } + } + + public function trackNotFoundIfApplicable(ServerRequestInterface $request): void + { + if (! $this->shouldTrackRequest($request)) { + return; + } + + /** @var NotFoundType|null $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); + } + } + + private function shouldTrackRequest(ServerRequestInterface $request): bool + { + $query = $request->getQueryParams(); + $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); + $forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE); + if ($forwardedMethod === self::METHOD_HEAD) { + return false; + } + + return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query); + } +} diff --git a/module/Core/src/Visit/RequestTrackerInterface.php b/module/Core/src/Visit/RequestTrackerInterface.php new file mode 100644 index 00000000..ec2c4cb1 --- /dev/null +++ b/module/Core/src/Visit/RequestTrackerInterface.php @@ -0,0 +1,15 @@ +filtering = $filtering; } protected function getSpec(): Specification diff --git a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php b/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php index ea4a4800..49d8db93 100644 --- a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php +++ b/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php @@ -12,12 +12,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class CountOfShortUrlVisits extends BaseSpecification { - private ?ApiKey $apiKey; - - public function __construct(?ApiKey $apiKey) + public function __construct(private ?ApiKey $apiKey) { parent::__construct(); - $this->apiKey = $apiKey; } protected function getSpec(): Specification diff --git a/module/Core/src/Visit/VisitLocator.php b/module/Core/src/Visit/VisitLocator.php index d7f0e426..e9d0a8f9 100644 --- a/module/Core/src/Visit/VisitLocator.php +++ b/module/Core/src/Visit/VisitLocator.php @@ -13,13 +13,10 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; class VisitLocator implements VisitLocatorInterface { - private EntityManagerInterface $em; private VisitRepositoryInterface $repo; - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; - /** @var VisitRepositoryInterface $repo */ $repo = $em->getRepository(Visit::class); $this->repo = $repo; diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index dfa00a4c..8138d170 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -27,11 +27,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsStatsHelper implements VisitsStatsHelperInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats @@ -52,9 +49,9 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): Paginator { - $spec = $apiKey !== null ? $apiKey->spec() : null; + $spec = $apiKey?->spec(); /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index d2bf6032..5e15be4f 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -24,7 +24,7 @@ interface VisitsStatsHelperInterface public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): Paginator; /** diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index f77cd624..523454fc 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -14,18 +14,11 @@ use Shlinkio\Shlink\Core\Options\TrackingOptions; class VisitsTracker implements VisitsTrackerInterface { - private ORM\EntityManagerInterface $em; - private EventDispatcherInterface $eventDispatcher; - private TrackingOptions $options; - public function __construct( - ORM\EntityManagerInterface $em, - EventDispatcherInterface $eventDispatcher, - TrackingOptions $options + private ORM\EntityManagerInterface $em, + private EventDispatcherInterface $eventDispatcher, + private TrackingOptions $options ) { - $this->em = $em; - $this->eventDispatcher = $eventDispatcher; - $this->options = $options; } public function track(ShortUrl $shortUrl, Visitor $visitor): void diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index aaa63d9f..1eaf6ea9 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Domain\Repository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -26,51 +27,83 @@ class DomainRepositoryTest extends DatabaseTestCase } /** @test */ - public function findDomainsReturnsExpectedResult(): void + public function expectedDomainsAreFoundWhenNoApiKeyIsInvolved(): void { - $fooDomain = new Domain('foo.com'); + $fooDomain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($fooDomain); $this->getEntityManager()->persist($this->createShortUrl($fooDomain)); - $barDomain = new Domain('bar.com'); + $barDomain = Domain::withAuthority('bar.com'); $this->getEntityManager()->persist($barDomain); $this->getEntityManager()->persist($this->createShortUrl($barDomain)); - $bazDomain = new Domain('baz.com'); + $bazDomain = Domain::withAuthority('baz.com'); $this->getEntityManager()->persist($bazDomain); $this->getEntityManager()->persist($this->createShortUrl($bazDomain)); - $detachedDomain = new Domain('detached.com'); + $detachedDomain = Domain::withAuthority('detached.com'); $this->getEntityManager()->persist($detachedDomain); + $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); + $detachedWithRedirects->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com')); + $this->getEntityManager()->persist($detachedWithRedirects); + $this->getEntityManager()->flush(); - self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout(null)); - self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com')); - self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com')); - self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com')); + self::assertEquals( + [$barDomain, $bazDomain, $detachedWithRedirects, $fooDomain], + $this->repo->findDomainsWithout(null), + ); + self::assertEquals( + [$barDomain, $bazDomain, $detachedWithRedirects], + $this->repo->findDomainsWithout('foo.com'), + ); + self::assertEquals( + [$bazDomain, $detachedWithRedirects, $fooDomain], + $this->repo->findDomainsWithout('bar.com'), + ); + self::assertEquals( + [$barDomain, $detachedWithRedirects, $fooDomain], + $this->repo->findDomainsWithout('baz.com'), + ); + self::assertEquals( + [$barDomain, $bazDomain, $fooDomain], + $this->repo->findDomainsWithout('detached-with-redirects.com'), + ); + + self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com')); + self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com')); + self::assertNull($this->repo->findOneByAuthority('does-not-exist.com')); + self::assertEquals($detachedDomain, $this->repo->findOneByAuthority('detached.com')); } /** @test */ - public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void + public function expectedDomainsAreFoundWhenApiKeyIsProvided(): void { $authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($authorApiKey); $authorAndDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($authorAndDomainApiKey); - $fooDomain = new Domain('foo.com'); + $fooDomain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($fooDomain); $this->getEntityManager()->persist($this->createShortUrl($fooDomain, $authorApiKey)); - $barDomain = new Domain('bar.com'); + $barDomain = Domain::withAuthority('bar.com'); $this->getEntityManager()->persist($barDomain); $this->getEntityManager()->persist($this->createShortUrl($barDomain, $authorAndDomainApiKey)); - $bazDomain = new Domain('baz.com'); + $bazDomain = Domain::withAuthority('baz.com'); $this->getEntityManager()->persist($bazDomain); $this->getEntityManager()->persist($this->createShortUrl($bazDomain, $authorApiKey)); + $detachedDomain = Domain::withAuthority('detached.com'); + $this->getEntityManager()->persist($detachedDomain); + + $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); + $detachedWithRedirects->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com')); + $this->getEntityManager()->persist($detachedWithRedirects); + $this->getEntityManager()->flush(); $authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain)); @@ -79,14 +112,32 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($fooDomainApiKey); $barDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($barDomain))); - $this->getEntityManager()->persist($fooDomainApiKey); + $this->getEntityManager()->persist($barDomainApiKey); + + $detachedWithRedirectsApiKey = ApiKey::fromMeta( + ApiKeyMeta::withRoles(RoleDefinition::forDomain($detachedWithRedirects)), + ); + $this->getEntityManager()->persist($detachedWithRedirectsApiKey); $this->getEntityManager()->flush(); self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey)); self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey)); + self::assertEquals( + [$detachedWithRedirects], + $this->repo->findDomainsWithout(null, $detachedWithRedirectsApiKey), + ); self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey)); self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey)); + + self::assertEquals($fooDomain, $this->repo->findOneByAuthority('foo.com', $authorApiKey)); + self::assertNull($this->repo->findOneByAuthority('bar.com', $authorApiKey)); + self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com', $barDomainApiKey)); + self::assertEquals( + $detachedWithRedirects, + $this->repo->findOneByAuthority('detached-with-redirects.com', $detachedWithRedirectsApiKey), + ); + self::assertNull($this->repo->findOneByAuthority('foo.com', $detachedWithRedirectsApiKey)); } private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl @@ -94,11 +145,8 @@ class DomainRepositoryTest extends DatabaseTestCase return ShortUrl::fromMeta( ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'apiKey' => $apiKey, 'longUrl' => 'foo']), new class ($domain) implements ShortUrlRelationResolverInterface { - private Domain $domain; - - public function __construct(Domain $domain) + public function __construct(private Domain $domain) { - $this->domain = $domain; } public function resolveDomain(?string $domain): ?Domain diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 867ff3f2..adc3d67f 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -340,9 +340,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase { $start = Chronos::parse('2020-03-05 20:18:30'); - $wrongDomain = new Domain('wrong.com'); + $wrongDomain = Domain::withAuthority('wrong.com'); $this->getEntityManager()->persist($wrongDomain); - $rightDomain = new Domain('right.com'); + $rightDomain = Domain::withAuthority('right.com'); $this->getEntityManager()->persist($rightDomain); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index eea2ed8c..92498d9a 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -97,7 +97,7 @@ class TagRepositoryTest extends DatabaseTestCase /** @test */ public function tagExistsReturnsExpectedResultBasedOnApiKey(): void { - $domain = new Domain('foo.com'); + $domain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($domain); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 15fe34f4..9f7859ff 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -222,7 +222,7 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function countVisitsReturnsExpectedResultBasedOnApiKey(): void { - $domain = new Domain('foo.com'); + $domain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($domain); $this->getEntityManager()->flush(); diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index 6df2498a..3eb1ad79 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -14,9 +14,8 @@ use Shlinkio\Shlink\Common\Response\PixelResponse; use Shlinkio\Shlink\Core\Action\PixelAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\Visit\VisitsTracker; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class PixelActionTest extends TestCase { @@ -24,18 +23,14 @@ class PixelActionTest extends TestCase private PixelAction $action; private ObjectProphecy $urlResolver; - private ObjectProphecy $visitTracker; + private ObjectProphecy $requestTracker; public function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->visitTracker = $this->prophesize(VisitsTracker::class); + $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); - $this->action = new PixelAction( - $this->urlResolver->reveal(), - $this->visitTracker->reveal(), - new TrackingOptions(), - ); + $this->action = new PixelAction($this->urlResolver->reveal(), $this->requestTracker->reveal()); } /** @test */ @@ -45,7 +40,7 @@ class PixelActionTest extends TestCase $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn( ShortUrl::withLongUrl('http://domain.com/foo/bar'), )->shouldBeCalledOnce(); - $this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce(); + $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldBeCalledOnce(); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index aeaec13f..0595734e 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -14,6 +14,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Core\Action\QrCodeAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -41,6 +42,7 @@ class QrCodeActionTest extends TestCase $this->action = new QrCodeAction( $this->urlResolver->reveal(), new ShortUrlStringifier(['domain' => 'doma.in']), + new NullLogger(), ); } @@ -84,7 +86,7 @@ class QrCodeActionTest extends TestCase */ public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat( array $query, - string $expectedContentType + string $expectedContentType, ): void { $code = 'abc123'; $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index dde9144c..b3017fad 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Action; -use Fig\Http\Message\RequestMethodInterface; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; -use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -17,69 +15,59 @@ use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Options; 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 function array_key_exists; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class RedirectActionTest extends TestCase { use ProphecyTrait; + private const LONG_URL = 'https://domain.com/foo/bar?some=thing'; + private RedirectAction $action; private ObjectProphecy $urlResolver; - private ObjectProphecy $visitTracker; + private ObjectProphecy $requestTracker; private ObjectProphecy $redirectRespHelper; public function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->visitTracker = $this->prophesize(VisitsTrackerInterface::class); + $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); $this->redirectRespHelper = $this->prophesize(RedirectResponseHelperInterface::class); + $redirectBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); + $redirectBuilder->buildShortUrlRedirect(Argument::cetera())->willReturn(self::LONG_URL); + $this->action = new RedirectAction( $this->urlResolver->reveal(), - $this->visitTracker->reveal(), - new Options\TrackingOptions(['disableTrackParam' => 'foobar']), + $this->requestTracker->reveal(), + $redirectBuilder->reveal(), $this->redirectRespHelper->reveal(), ); } - /** - * @test - * @dataProvider provideQueries - */ - public function redirectionIsPerformedToLongUrl(string $expectedUrl, array $query): void + /** @test */ + public function redirectionIsPerformedToLongUrl(): void { $shortCode = 'abc123'; - $shortUrl = ShortUrl::withLongUrl('http://domain.com/foo/bar?some=thing'); + $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); $shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl( new ShortUrlIdentifier($shortCode, ''), )->willReturn($shortUrl); - $track = $this->visitTracker->track(Argument::cetera())->will(function (): void { + $track = $this->requestTracker->trackIfApplicable(Argument::cetera())->will(function (): void { }); - $expectedResp = new Response\RedirectResponse($expectedUrl); - $buildResp = $this->redirectRespHelper->buildRedirectResponse($expectedUrl)->willReturn($expectedResp); + $expectedResp = new Response\RedirectResponse(self::LONG_URL); + $buildResp = $this->redirectRespHelper->buildRedirectResponse(self::LONG_URL)->willReturn($expectedResp); - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withQueryParams($query); + $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); self::assertSame($expectedResp, $response); $buildResp->shouldHaveBeenCalledOnce(); $shortCodeToUrl->shouldHaveBeenCalledOnce(); - $track->shouldHaveBeenCalledTimes(array_key_exists('foobar', $query) ? 0 : 1); - } - - public function provideQueries(): iterable - { - yield ['http://domain.com/foo/bar?some=thing', []]; - yield ['http://domain.com/foo/bar?some=thing', ['foobar' => 'notrack']]; - yield ['http://domain.com/foo/bar?some=thing&else', ['else' => null]]; - yield ['http://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar']]; - yield ['http://domain.com/foo/bar?some=overwritten&foo=bar', ['foo' => 'bar', 'some' => 'overwritten']]; - yield ['http://domain.com/foo/bar?some=overwritten', ['foobar' => 'notrack', 'some' => 'overwritten']]; + $track->shouldHaveBeenCalledOnce(); } /** @test */ @@ -89,7 +77,7 @@ class RedirectActionTest extends TestCase $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) ->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); - $this->visitTracker->track(Argument::cetera())->shouldNotBeCalled(); + $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotBeCalled(); $handler = $this->prophesize(RequestHandlerInterface::class); $handle = $handler->handle(Argument::any())->willReturn(new Response()); @@ -99,27 +87,4 @@ class RedirectActionTest extends TestCase $handle->shouldHaveBeenCalledOnce(); } - - /** @test */ - public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void - { - $shortCode = 'abc123'; - $shortUrl = ShortUrl::withLongUrl('http://domain.com/foo/bar?some=thing'); - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl); - $track = $this->visitTracker->track(Argument::cetera())->will(function (): void { - }); - $buildResp = $this->redirectRespHelper->buildRedirectResponse( - 'http://domain.com/foo/bar?some=thing', - )->willReturn(new Response\RedirectResponse('')); - - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode) - ->withAttribute( - ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE, - RequestMethodInterface::METHOD_HEAD, - ); - $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); - - $buildResp->shouldHaveBeenCalled(); - $track->shouldNotHaveBeenCalled(); - } } diff --git a/module/Core/test/Config/BasePathPrefixerTest.php b/module/Core/test/Config/BasePathPrefixerTest.php index e0949514..f01b9195 100644 --- a/module/Core/test/Config/BasePathPrefixerTest.php +++ b/module/Core/test/Config/BasePathPrefixerTest.php @@ -24,7 +24,7 @@ class BasePathPrefixerTest extends TestCase array $originalConfig, array $expectedRoutes, array $expectedMiddlewares, - string $expectedHostname + string $expectedHostname, ): void { [ 'routes' => $routes, diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php new file mode 100644 index 00000000..fe482a41 --- /dev/null +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -0,0 +1,114 @@ +helper = $this->prophesize(RedirectResponseHelperInterface::class); + $this->resolver = new NotFoundRedirectResolver($this->helper->reveal()); + + $this->config = new NotFoundRedirectOptions([ + 'invalidShortUrl' => 'invalidShortUrl', + 'regular404' => 'regular404', + 'baseUrl' => 'baseUrl', + ]); + } + + /** + * @test + * @dataProvider provideRedirects + */ + public function expectedRedirectionIsReturnedDependingOnTheCase( + NotFoundType $notFoundType, + string $expectedRedirectTo, + ): void { + $expectedResp = new Response(); + $buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp); + + $resp = $this->resolver->resolveRedirectResponse($notFoundType, $this->config); + + self::assertSame($expectedResp, $resp); + $buildResp->shouldHaveBeenCalledOnce(); + } + + public function provideRedirects(): iterable + { + yield 'base URL with trailing slash' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))), + 'baseUrl', + ]; + yield 'base URL without trailing slash' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))), + 'baseUrl', + ]; + yield 'regular 404' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))), + 'regular404', + ]; + yield 'invalid short URL' => [ + $this->notFoundType($this->requestForRoute(RedirectAction::class)), + 'invalidShortUrl', + ]; + } + + /** @test */ + public function noResponseIsReturnedIfNoConditionsMatch(): void + { + $notFoundType = $this->notFoundType($this->requestForRoute('foo')); + + $result = $this->resolver->resolveRedirectResponse($notFoundType, $this->config); + + self::assertNull($result); + $this->helper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + private function notFoundType(ServerRequestInterface $req): NotFoundType + { + return NotFoundType::fromRequest($req, ''); + } + + private function requestForRoute(string $routeName): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals() + ->withAttribute( + RouteResult::class, + RouteResult::fromRoute( + new Route( + '', + $this->prophesize(MiddlewareInterface::class)->reveal(), + ['GET'], + $routeName, + ), + ), + ) + ->withUri(new Uri('/abc123')); + } +} diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 0306f387..159fb6ca 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -9,11 +9,14 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainService; 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\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -28,7 +31,7 @@ class DomainServiceTest extends TestCase public function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); - $this->domainService = new DomainService($this->em->reveal(), 'default.com'); + $this->domainService = new DomainService($this->em->reveal(), 'default.com', new NotFoundRedirectOptions()); } /** @@ -50,45 +53,56 @@ class DomainServiceTest extends TestCase public function provideExcludedDomains(): iterable { - $default = new DomainItem('default.com', true); + $default = DomainItem::forDefaultDomain('default.com', new NotFoundRedirectOptions()); $adminApiKey = ApiKey::create(); $domainSpecificApiKey = ApiKey::fromMeta( - ApiKeyMeta::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123'))), + ApiKeyMeta::withRoles(RoleDefinition::forDomain(Domain::withAuthority('')->setId('123'))), ); yield 'empty list without API key' => [[], [$default], null]; yield 'one item without API key' => [ - [new Domain('bar.com')], - [$default, new DomainItem('bar.com', false)], + [Domain::withAuthority('bar.com')], + [$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], null, ]; yield 'multiple items without API key' => [ - [new Domain('foo.com'), new Domain('bar.com')], - [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], + [ + $default, + DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), + DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), + ], null, ]; yield 'empty list with admin API key' => [[], [$default], $adminApiKey]; yield 'one item with admin API key' => [ - [new Domain('bar.com')], - [$default, new DomainItem('bar.com', false)], + [Domain::withAuthority('bar.com')], + [$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], $adminApiKey, ]; yield 'multiple items with admin API key' => [ - [new Domain('foo.com'), new Domain('bar.com')], - [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], + [ + $default, + DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), + DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), + ], $adminApiKey, ]; yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey]; yield 'one item with domain-specific API key' => [ - [new Domain('bar.com')], - [new DomainItem('bar.com', false)], + [Domain::withAuthority('bar.com')], + [DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], $domainSpecificApiKey, ]; yield 'multiple items with domain-specific API key' => [ - [new Domain('foo.com'), new Domain('bar.com')], - [new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], + [ + DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), + DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), + ], $domainSpecificApiKey, ]; } @@ -107,7 +121,7 @@ class DomainServiceTest extends TestCase /** @test */ public function getDomainReturnsEntityWhenFound(): void { - $domain = new Domain(''); + $domain = Domain::withAuthority(''); $find = $this->em->find(Domain::class, '123')->willReturn($domain); $result = $this->domainService->getDomain('123'); @@ -120,16 +134,16 @@ class DomainServiceTest extends TestCase * @test * @dataProvider provideFoundDomains */ - public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain): void + public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; $repo = $this->prophesize(DomainRepositoryInterface::class); - $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); + $repo->findOneByAuthority($authority, $apiKey)->willReturn($foundDomain); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $persist = $this->em->persist($foundDomain !== null ? $foundDomain : Argument::type(Domain::class)); + $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); $flush = $this->em->flush(); - $result = $this->domainService->getOrCreate($authority); + $result = $this->domainService->getOrCreate($authority, $apiKey); if ($foundDomain !== null) { self::assertSame($result, $foundDomain); @@ -139,9 +153,76 @@ class DomainServiceTest extends TestCase $flush->shouldHaveBeenCalledOnce(); } + /** @test */ + public function getOrCreateThrowsExceptionForApiKeysWithDomainRole(): void + { + $authority = 'example.com'; + $domain = Domain::withAuthority($authority)->setId('1'); + $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); + $repo = $this->prophesize(DomainRepositoryInterface::class); + $repo->findOneByAuthority($authority, $apiKey)->willReturn(null); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $this->expectException(DomainNotFoundException::class); + $getRepo->shouldBeCalledOnce(); + $this->em->persist(Argument::cetera())->shouldNotBeCalled(); + $this->em->flush()->shouldNotBeCalled(); + + $this->domainService->getOrCreate($authority, $apiKey); + } + + /** + * @test + * @dataProvider provideFoundDomains + */ + public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void + { + $authority = 'example.com'; + $repo = $this->prophesize(DomainRepositoryInterface::class); + $repo->findOneByAuthority($authority, $apiKey)->willReturn($foundDomain); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); + $flush = $this->em->flush(); + + $result = $this->domainService->configureNotFoundRedirects($authority, NotFoundRedirects::withRedirects( + 'foo.com', + 'bar.com', + 'baz.com', + ), $apiKey); + + if ($foundDomain !== null) { + self::assertSame($result, $foundDomain); + } + self::assertEquals('foo.com', $result->baseUrlRedirect()); + self::assertEquals('bar.com', $result->regular404Redirect()); + self::assertEquals('baz.com', $result->invalidShortUrlRedirect()); + $getRepo->shouldHaveBeenCalledOnce(); + $persist->shouldHaveBeenCalledOnce(); + $flush->shouldHaveBeenCalledOnce(); + } + public function provideFoundDomains(): iterable { - yield 'domain not found' => [null]; - yield 'domain found' => [new Domain('')]; + $domain = Domain::withAuthority(''); + $adminApiKey = ApiKey::create(); + $authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); + + yield 'domain not found and no API key' => [null, null]; + yield 'domain found and no API key' => [$domain, null]; + yield 'domain not found and admin API key' => [null, $adminApiKey]; + yield 'domain found and admin API key' => [$domain, $adminApiKey]; + yield 'domain not found and author API key' => [null, $authorApiKey]; + yield 'domain found and author API key' => [$domain, $authorApiKey]; + } + + /** @test */ + public function anExceptionIsThrowsWhenTryingToEditRedirectsForDefaultDomain(): void + { + $this->expectException(InvalidDomainException::class); + $this->expectExceptionMessage( + 'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.', + ); + + $this->domainService->configureNotFoundRedirects('default.com', NotFoundRedirects::withoutRedirects()); } } diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index fceba3e2..89ccc805 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -26,7 +26,7 @@ class ShortUrlTest extends TestCase */ public function regenerateShortCodeThrowsExceptionIfStateIsInvalid( ShortUrl $shortUrl, - string $expectedMessage + string $expectedMessage, ): void { $this->expectException(ShortCodeCannotBeRegeneratedException::class); $this->expectExceptionMessage($expectedMessage); diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index 9df49879..0d257d8e 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -6,21 +6,18 @@ namespace ShlinkioTest\Shlink\Core\ErrorHandler; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; -use Laminas\Diactoros\Uri; -use Mezzio\Router\Route; -use Mezzio\Router\RouteResult; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Core\Action\RedirectAction; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface; +use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; -use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; class NotFoundRedirectHandlerTest extends TestCase { @@ -28,93 +25,103 @@ class NotFoundRedirectHandlerTest extends TestCase private NotFoundRedirectHandler $middleware; private NotFoundRedirectOptions $redirectOptions; - private ObjectProphecy $helper; + private ObjectProphecy $resolver; + private ObjectProphecy $domainService; + private ObjectProphecy $next; + private ServerRequestInterface $req; public function setUp(): void { $this->redirectOptions = new NotFoundRedirectOptions(); - $this->helper = $this->prophesize(RedirectResponseHelperInterface::class); - $this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal()); + $this->resolver = $this->prophesize(NotFoundRedirectResolverInterface::class); + $this->domainService = $this->prophesize(DomainServiceInterface::class); + + $this->middleware = new NotFoundRedirectHandler( + $this->redirectOptions, + $this->resolver->reveal(), + $this->domainService->reveal(), + ); + + $this->next = $this->prophesize(RequestHandlerInterface::class); + $this->req = ServerRequestFactory::fromGlobals()->withAttribute( + NotFoundType::class, + $this->prophesize(NotFoundType::class)->reveal(), + ); } /** * @test - * @dataProvider provideRedirects + * @dataProvider provideNonRedirectScenarios */ - public function expectedRedirectionIsReturnedDependingOnTheCase( - ServerRequestInterface $request, - string $expectedRedirectTo - ): void { - $this->redirectOptions->invalidShortUrl = 'invalidShortUrl'; - $this->redirectOptions->regular404 = 'regular404'; - $this->redirectOptions->baseUrl = 'baseUrl'; - + public function nextIsCalledWhenNoRedirectIsResolved(callable $setUp): void + { $expectedResp = new Response(); - $buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp); - $next = $this->prophesize(RequestHandlerInterface::class); - $handle = $next->handle($request)->willReturn(new Response()); + $setUp($this->domainService, $this->resolver); + $handle = $this->next->handle($this->req)->willReturn($expectedResp); - $resp = $this->middleware->process($request, $next->reveal()); + $result = $this->middleware->process($this->req, $this->next->reveal()); - self::assertSame($expectedResp, $resp); - $buildResp->shouldHaveBeenCalledOnce(); - $handle->shouldNotHaveBeenCalled(); - } - - public function provideRedirects(): iterable - { - yield 'base URL with trailing slash' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))), - 'baseUrl', - ]; - yield 'base URL without trailing slash' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))), - 'baseUrl', - ]; - yield 'regular 404' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))), - 'regular404', - ]; - yield 'invalid short URL' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals() - ->withAttribute( - RouteResult::class, - RouteResult::fromRoute( - new Route( - '', - $this->prophesize(MiddlewareInterface::class)->reveal(), - ['GET'], - RedirectAction::class, - ), - ), - ) - ->withUri(new Uri('/abc123'))), - 'invalidShortUrl', - ]; - } - - /** @test */ - public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void - { - $req = $this->withNotFoundType(ServerRequestFactory::fromGlobals()); - $resp = new Response(); - - $buildResp = $this->helper->buildRedirectResponse(Argument::cetera()); - - $next = $this->prophesize(RequestHandlerInterface::class); - $handle = $next->handle($req)->willReturn($resp); - - $result = $this->middleware->process($req, $next->reveal()); - - self::assertSame($resp, $result); - $buildResp->shouldNotHaveBeenCalled(); + self::assertSame($expectedResp, $result); $handle->shouldHaveBeenCalledOnce(); } - private function withNotFoundType(ServerRequestInterface $req): ServerRequestInterface + public function provideNonRedirectScenarios(): iterable { - $type = NotFoundType::fromRequest($req, ''); - return $req->withAttribute(NotFoundType::class, $type); + yield 'no domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { + $domainService->findByAuthority(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledOnce(); + $resolver->resolveRedirectResponse(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledOnce(); + }]; + yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { + $domainService->findByAuthority(Argument::cetera()) + ->willReturn(Domain::withAuthority('')) + ->shouldBeCalledOnce(); + $resolver->resolveRedirectResponse(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledTimes(2); + }]; + } + + /** @test */ + public function globalRedirectIsUsedIfDomainRedirectIsNotFound(): void + { + $expectedResp = new Response(); + + $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn(null); + $resolveRedirect = $this->resolver->resolveRedirectResponse( + Argument::type(NotFoundType::class), + $this->redirectOptions, + )->willReturn($expectedResp); + + $result = $this->middleware->process($this->req, $this->next->reveal()); + + self::assertSame($expectedResp, $result); + $findDomain->shouldHaveBeenCalledOnce(); + $resolveRedirect->shouldHaveBeenCalledOnce(); + $this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function domainRedirectIsUsedIfFound(): void + { + $expectedResp = new Response(); + $domain = Domain::withAuthority(''); + + $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn($domain); + $resolveRedirect = $this->resolver->resolveRedirectResponse( + Argument::type(NotFoundType::class), + $domain, + )->willReturn($expectedResp); + + $result = $this->middleware->process($this->req, $this->next->reveal()); + + self::assertSame($expectedResp, $result); + $findDomain->shouldHaveBeenCalledOnce(); + $resolveRedirect->shouldHaveBeenCalledOnce(); + $this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled(); } } diff --git a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php index 560a2468..81fef1a6 100644 --- a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -14,8 +14,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTrackerMiddleware; -use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class NotFoundTrackerMiddlewareTest extends TestCase { @@ -23,7 +22,7 @@ class NotFoundTrackerMiddlewareTest extends TestCase private NotFoundTrackerMiddleware $middleware; private ServerRequestInterface $request; - private ObjectProphecy $visitsTracker; + private ObjectProphecy $requestTracker; private ObjectProphecy $notFoundType; private ObjectProphecy $handler; @@ -33,8 +32,8 @@ class NotFoundTrackerMiddlewareTest extends TestCase $this->handler = $this->prophesize(RequestHandlerInterface::class); $this->handler->handle(Argument::cetera())->willReturn(new Response()); - $this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class); - $this->middleware = new NotFoundTrackerMiddleware($this->visitsTracker->reveal()); + $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); + $this->middleware = new NotFoundTrackerMiddleware($this->requestTracker->reveal()); $this->request = ServerRequestFactory::fromGlobals()->withAttribute( NotFoundType::class, @@ -43,53 +42,11 @@ class NotFoundTrackerMiddlewareTest extends TestCase } /** @test */ - public function baseUrlErrorIsTracked(): void + public function delegatesIntoRequestTracker(): void { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); - $this->middleware->process($this->request, $this->handler->reveal()); - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldNotHaveBeenCalled(); - $isInvalidShortUrl->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - } - - /** @test */ - public function regularNotFoundErrorIsTracked(): void - { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); - - $this->middleware->process($this->request, $this->handler->reveal()); - - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldHaveBeenCalledOnce(); - $isInvalidShortUrl->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - } - - /** @test */ - public function invalidShortUrlErrorIsTracked(): void - { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true); - - $this->middleware->process($this->request, $this->handler->reveal()); - - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldHaveBeenCalledOnce(); - $isInvalidShortUrl->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + $this->requestTracker->trackNotFoundIfApplicable($this->request)->shouldHaveBeenCalledOnce(); + $this->handler->handle($this->request)->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php index 5f08e5fe..3d830cf3 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php @@ -44,7 +44,7 @@ class CloseDbConnectionEventListenerTest extends TestCase try { ($eventListener)(new stdClass()); - } catch (Throwable $e) { + } catch (Throwable) { // Ignore exceptions } diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php index a492f9dd..178a142f 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -79,7 +79,7 @@ class UpdateGeoLiteDbTest extends TestCase int $total, int $downloaded, bool $oldDbExists, - ?string $expectedMessage + ?string $expectedMessage, ): void { $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( function (array $args) use ($total, $downloaded, $oldDbExists): void { diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index a7028a02..43dcc2e5 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -21,7 +21,7 @@ class DeleteShortUrlExceptionTest extends TestCase public function fromVisitsThresholdGeneratesMessageProperly( int $threshold, string $shortCode, - string $expectedMessage + string $expectedMessage, ): void { $e = DeleteShortUrlException::fromVisitsThreshold($threshold, $shortCode); diff --git a/module/Core/test/Exception/DomainNotFoundExceptionTest.php b/module/Core/test/Exception/DomainNotFoundExceptionTest.php index 6ac26efd..5f2b9889 100644 --- a/module/Core/test/Exception/DomainNotFoundExceptionTest.php +++ b/module/Core/test/Exception/DomainNotFoundExceptionTest.php @@ -12,7 +12,7 @@ use function sprintf; class DomainNotFoundExceptionTest extends TestCase { /** @test */ - public function properlyCreatesExceptionFromNotFoundTag(): void + public function properlyCreatesExceptionFromId(): void { $id = '123'; $expectedMessage = sprintf('Domain with id "%s" could not be found', $id); @@ -25,4 +25,19 @@ class DomainNotFoundExceptionTest extends TestCase self::assertEquals(['id' => $id], $e->getAdditionalData()); self::assertEquals(404, $e->getStatus()); } + + /** @test */ + public function properlyCreatesExceptionFromAuthority(): void + { + $authority = 'example.com'; + $expectedMessage = sprintf('Domain with authority "%s" could not be found', $authority); + $e = DomainNotFoundException::fromAuthority($authority); + + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Domain not found', $e->getTitle()); + self::assertEquals('DOMAIN_NOT_FOUND', $e->getType()); + self::assertEquals(['authority' => $authority], $e->getAdditionalData()); + self::assertEquals(404, $e->getStatus()); + } } diff --git a/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php index c42f864a..40ccd0ee 100644 --- a/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php +++ b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php @@ -15,7 +15,7 @@ class ForbiddenTagOperationExceptionTest extends TestCase */ public function createsExpectedExceptionForDeletion( ForbiddenTagOperationException $e, - string $expectedMessage + string $expectedMessage, ): void { $this->assertExceptionShape($e, $expectedMessage); } diff --git a/module/Core/test/Exception/InvalidDomainExceptionTest.php b/module/Core/test/Exception/InvalidDomainExceptionTest.php new file mode 100644 index 00000000..06b78ff2 --- /dev/null +++ b/module/Core/test/Exception/InvalidDomainExceptionTest.php @@ -0,0 +1,24 @@ +getMessage()); + self::assertEquals($expected, $e->getDetail()); + self::assertEquals('Invalid domain', $e->getTitle()); + self::assertEquals('INVALID_DOMAIN', $e->getType()); + self::assertEquals(403, $e->getStatus()); + } +} diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index e6a48914..ea4e606d 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -17,7 +17,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase public function properlyCreatesExceptionFromNotFoundShortCode( string $expectedMessage, string $shortCode, - ?string $domain + ?string $domain, ): void { $expectedAdditional = ['shortCode' => $shortCode]; if ($domain !== null) { diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index d17c5720..1a4a4de1 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -160,7 +160,7 @@ class ImportedLinksProcessorTest extends TestCase ImportedShlinkUrl $importedUrl, string $expectedOutput, int $amountOfPersistedVisits, - ?ShortUrl $foundShortUrl + ?ShortUrl $foundShortUrl, ): void { $findExisting = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn($foundShortUrl); $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 5420e4b6..33fdb8f6 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -33,7 +33,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase array $tags = [], ?string $startDate = null, ?string $endDate = null, - ?string $orderBy = null + ?string $orderBy = null, ): void { $params = ShortUrlsParams::fromRawData([ 'searchTerm' => $searchTerm, @@ -58,7 +58,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase ?string $searchTerm = null, array $tags = [], ?string $startDate = null, - ?string $endDate = null + ?string $endDate = null, ): void { $params = ShortUrlsParams::fromRawData([ 'searchTerm' => $searchTerm, diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 2a9e5fc4..97a2c1f0 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -70,7 +70,7 @@ class VisitsPaginatorAdapterTest extends TestCase $this->repo->reveal(), new ShortUrlIdentifier(''), VisitsParams::fromRawData([]), - $apiKey !== null ? $apiKey->spec() : null, + $apiKey?->spec(), ); } } diff --git a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php b/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php index ca3b463f..b30f8cab 100644 --- a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php +++ b/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php @@ -60,7 +60,7 @@ class ShortCodeHelperTest extends TestCase public function provideDomains(): iterable { yield 'no domain' => [null, null]; - yield 'domain' => [new Domain($authority = 'doma.in'), $authority]; + yield 'domain' => [Domain::withAuthority($authority = 'doma.in'), $authority]; } /** @test */ diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 73823729..41f2b492 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -49,7 +49,7 @@ class ShortUrlResolverTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null)->willReturn($shortUrl); + $findOne = $repo->findOne($identifier, $apiKey?->spec())->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $result = $this->urlResolver->resolveShortUrl($identifier, $apiKey); @@ -69,7 +69,7 @@ class ShortUrlResolverTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null)->willReturn(null); + $findOne = $repo->findOne($identifier, $apiKey?->spec())->willReturn(null); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal(), $apiKey); $this->expectException(ShortUrlNotFoundException::class); diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 67420edc..b07d4df9 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -82,7 +82,7 @@ class ShortUrlServiceTest extends TestCase public function updateShortUrlUpdatesProvidedData( int $expectedValidateCalls, ShortUrlEdit $shortUrlEdit, - ?ApiKey $apiKey + ?ApiKey $apiKey, ): void { $originalLongUrl = 'originalLongUrl'; $shortUrl = ShortUrl::withLongUrl($originalLongUrl); diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php new file mode 100644 index 00000000..c64147c1 --- /dev/null +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -0,0 +1,49 @@ +trackingOptions = new TrackingOptions(['disable_track_param' => 'foobar']); + $this->redirectionBuilder = new ShortUrlRedirectionBuilder($this->trackingOptions); + } + + /** + * @test + * @dataProvider provideData + */ + public function buildShortUrlRedirectBuildsExpectedUrl(string $expectedUrl, array $query, ?string $extraPath): void + { + $shortUrl = ShortUrl::withLongUrl('https://domain.com/foo/bar?some=thing'); + $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); + + self::assertEquals($expectedUrl, $result); + } + + public function provideData(): iterable + { + yield ['https://domain.com/foo/bar?some=thing', [], null]; + yield ['https://domain.com/foo/bar?some=thing&else', ['else' => null], null]; + yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null]; + yield ['https://domain.com/foo/bar?some=overwritten&foo=bar', ['foo' => 'bar', 'some' => 'overwritten'], null]; + yield ['https://domain.com/foo/bar?some=overwritten', ['foobar' => 'notrack', 'some' => 'overwritten'], null]; + yield ['https://domain.com/foo/bar/something/else-baz?some=thing', [], '/something/else-baz']; + yield [ + 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', + ['hello' => 'world'], + '/something/else-baz', + ]; + } +} diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index 483fd57d..b4acc417 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -19,7 +19,7 @@ class ShortUrlStringifierTest extends TestCase array $config, string $basePath, ShortUrl $shortUrl, - string $expected + string $expected, ): void { $stringifier = new ShortUrlStringifier($config, $basePath); diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php new file mode 100644 index 00000000..24917366 --- /dev/null +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -0,0 +1,147 @@ +resolver = $this->prophesize(ShortUrlResolverInterface::class); + $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); + $this->redirectionBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); + $this->redirectResponseHelper = $this->prophesize(RedirectResponseHelperInterface::class); + $this->options = new UrlShortenerOptions(['append_extra_path' => true]); + + $this->middleware = new ExtraPathRedirectMiddleware( + $this->resolver->reveal(), + $this->requestTracker->reveal(), + $this->redirectionBuilder->reveal(), + $this->redirectResponseHelper->reveal(), + $this->options, + ); + + $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->handler->handle(Argument::cetera())->willReturn(new RedirectResponse('')); + } + + /** + * @test + * @dataProvider provideNonRedirectingRequests + */ + public function handlerIsCalledWhenConfigPreventsRedirectWithExtraPath( + bool $appendExtraPath, + ServerRequestInterface $request + ): void { + $this->options->appendExtraPath = $appendExtraPath; + + $this->middleware->process($request, $this->handler->reveal()); + + $this->resolver->resolveEnabledShortUrl(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->redirectResponseHelper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideNonRedirectingRequests(): iterable + { + $baseReq = ServerRequestFactory::fromGlobals(); + $buildReq = static fn (?NotFoundType $type): ServerRequestInterface => + $baseReq->withAttribute(NotFoundType::class, $type); + + yield 'disabled option' => [false, $buildReq(NotFoundType::fromRequest($baseReq, '/foo/bar'))]; + yield 'base_url error' => [true, $buildReq(NotFoundType::fromRequest($baseReq, ''))]; + yield 'invalid_short_url error' => [ + true, + $buildReq(NotFoundType::fromRequest($baseReq, ''))->withAttribute( + RouteResult::class, + RouteResult::fromRoute(new Route( + '', + $this->prophesize(MiddlewareInterface::class)->reveal(), + ['GET'], + )), + ), + ]; + yield 'no error type' => [true, $buildReq(null)]; + } + + /** @test */ + public function handlerIsCalledWhenNoShortUrlIsFound(): void + { + $type = $this->prophesize(NotFoundType::class); + $type->isRegularNotFound()->willReturn(true); + $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) + ->withUri(new Uri('/shortCode/bar/baz')); + + $resolve = $this->resolver->resolveEnabledShortUrl(Argument::cetera())->willThrow( + ShortUrlNotFoundException::class, + ); + + $this->middleware->process($request, $this->handler->reveal()); + + $resolve->shouldHaveBeenCalledOnce(); + $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->redirectResponseHelper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFound(): void + { + $type = $this->prophesize(NotFoundType::class); + $type->isRegularNotFound()->willReturn(true); + $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) + ->withUri(new Uri('https://doma.in/shortCode/bar/baz')); + $shortUrl = ShortUrl::withLongUrl(''); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain('shortCode', 'doma.in'); + + $resolve = $this->resolver->resolveEnabledShortUrl($identifier)->willReturn($shortUrl); + $buildLongUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, [], '/bar/baz')->willReturn( + 'the_built_long_url', + ); + $buildResp = $this->redirectResponseHelper->buildRedirectResponse('the_built_long_url')->willReturn( + new RedirectResponse(''), + ); + + $this->middleware->process($request, $this->handler->reveal()); + + $resolve->shouldHaveBeenCalledOnce(); + $buildLongUrl->shouldHaveBeenCalledOnce(); + $buildResp->shouldHaveBeenCalledOnce(); + $this->requestTracker->trackIfApplicable($shortUrl, $request)->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index aeef3f47..9aaf9495 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -68,7 +68,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase $authority = 'doma.in'; yield 'not found domain' => [null, $authority]; - yield 'found domain' => [new Domain($authority), $authority]; + yield 'found domain' => [Domain::withAuthority($authority), $authority]; } /** diff --git a/module/Core/test/Util/DoctrineBatchHelperTest.php b/module/Core/test/Util/DoctrineBatchHelperTest.php index b655c070..f6f9981d 100644 --- a/module/Core/test/Util/DoctrineBatchHelperTest.php +++ b/module/Core/test/Util/DoctrineBatchHelperTest.php @@ -31,7 +31,7 @@ class DoctrineBatchHelperTest extends TestCase public function entityManagerIsFlushedAndClearedTheExpectedAmountOfTimes( array $iterable, int $batchSize, - int $expectedCalls + int $expectedCalls, ): void { $wrappedIterable = $this->helper->wrapIterable($iterable, $batchSize); diff --git a/module/Core/test/Util/RedirectResponseHelperTest.php b/module/Core/test/Util/RedirectResponseHelperTest.php index 0eb8c0fe..eb26768f 100644 --- a/module/Core/test/Util/RedirectResponseHelperTest.php +++ b/module/Core/test/Util/RedirectResponseHelperTest.php @@ -28,7 +28,7 @@ class RedirectResponseHelperTest extends TestCase int $configuredStatus, int $configuredLifetime, int $expectedStatus, - ?string $expectedCacheControl + ?string $expectedCacheControl, ): void { $this->shortenerOpts->redirectStatusCode = $configuredStatus; $this->shortenerOpts->redirectCacheLifetime = $configuredLifetime; diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index 25710172..57a5d3ce 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -90,7 +90,7 @@ class UrlValidatorTest extends TestCase */ public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled( ?bool $doValidate, - bool $validateUrl + bool $validateUrl, ): void { $request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class); $this->options->validateUrl = $validateUrl; diff --git a/module/Core/test/Visit/RequestTrackerTest.php b/module/Core/test/Visit/RequestTrackerTest.php new file mode 100644 index 00000000..46faf9fd --- /dev/null +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -0,0 +1,147 @@ +notFoundType = $this->prophesize(NotFoundType::class); + $this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class); + + $this->requestTracker = new RequestTracker( + $this->visitsTracker->reveal(), + new TrackingOptions(['disable_track_param' => 'foobar']), + ); + + $this->request = ServerRequestFactory::fromGlobals()->withAttribute( + NotFoundType::class, + $this->notFoundType->reveal(), + ); + } + + /** + * @test + * @dataProvider provideNonTrackingRequests + */ + public function trackingIsDisabledWhenRequestDoesNotMeetConditions(ServerRequestInterface $request): void + { + $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); + + $this->requestTracker->trackIfApplicable($shortUrl, $request); + + $this->visitsTracker->track(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideNonTrackingRequests(): iterable + { + yield 'forwarded from head' => [ServerRequestFactory::fromGlobals()->withAttribute( + ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE, + RequestMethodInterface::METHOD_HEAD, + )]; + yield 'disable track param' => [ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => 'foo'])]; + yield 'disable track param as null' => [ + ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => null]), + ]; + } + + /** @test */ + public function trackingHappensOverShortUrlsWhenRequestMeetsConditions(): void + { + $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); + + $this->requestTracker->trackIfApplicable($shortUrl, $this->request); + + $this->visitsTracker->track($shortUrl, Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function baseUrlErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); + + $this->requestTracker->trackNotFoundIfApplicable($this->request); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldNotHaveBeenCalled(); + $isInvalidShortUrl->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function regularNotFoundErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); + + $this->requestTracker->trackNotFoundIfApplicable($this->request); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldHaveBeenCalledOnce(); + $isInvalidShortUrl->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function invalidShortUrlErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true); + + $this->requestTracker->trackNotFoundIfApplicable($this->request); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldHaveBeenCalledOnce(); + $isInvalidShortUrl->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideNonTrackingRequests + */ + public function notFoundIsNotTrackedIfRequestDoesNotMeetConditions(ServerRequestInterface $request): void + { + $this->requestTracker->trackNotFoundIfApplicable($request); + + $this->visitsTracker->trackBaseUrlVisit(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::cetera())->shouldNotHaveBeenCalled(); + } +} diff --git a/module/Core/test/Visit/VisitLocatorTest.php b/module/Core/test/Visit/VisitLocatorTest.php index c99d051b..5c51b848 100644 --- a/module/Core/test/Visit/VisitLocatorTest.php +++ b/module/Core/test/Visit/VisitLocatorTest.php @@ -53,7 +53,7 @@ class VisitLocatorTest extends TestCase */ public function locateVisitsIteratesAndLocatesExpectedVisits( string $serviceMethodName, - string $expectedRepoMethodName + string $expectedRepoMethodName, ): void { $unlocatedVisits = map( range(1, 200), @@ -105,7 +105,7 @@ class VisitLocatorTest extends TestCase public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty( string $serviceMethodName, string $expectedRepoMethodName, - bool $isNonLocatableAddress + bool $isNonLocatableAddress, ): void { $unlocatedVisits = [ Visit::forValidShortUrl(ShortUrl::withLongUrl('foo'), Visitor::emptyInstance()), @@ -122,11 +122,8 @@ class VisitLocatorTest extends TestCase $this->visitService->{$serviceMethodName}( new class ($isNonLocatableAddress) implements VisitGeolocationHelperInterface { - private bool $isNonLocatableAddress; - - public function __construct(bool $isNonLocatableAddress) + public function __construct(private bool $isNonLocatableAddress) { - $this->isNonLocatableAddress = $isNonLocatableAddress; } public function geolocateVisit(Visit $visit): Location diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index cae3fbb1..ab76bbf1 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -80,7 +80,7 @@ class VisitsStatsHelperTest extends TestCase { $shortCode = '123ABC'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $spec = $apiKey === null ? null : $apiKey->spec(); + $spec = $apiKey?->spec(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); $count = $repo->shortCodeIsInUse($identifier, $spec)->willReturn( diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index e1a869df..5e0267d6 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -40,6 +40,7 @@ return [ Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, + Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -81,6 +82,7 @@ return [ Action\Tag\CreateTagsAction::class => [TagService::class], Action\Tag\UpdateTagAction::class => [TagService::class], Action\Domain\ListDomainsAction::class => [DomainService::class], + Action\Domain\DomainRedirectsAction::class => [DomainService::class], Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 9b09a266..991f4bb3 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -44,6 +44,7 @@ return [ // Domains Action\Domain\ListDomainsAction::getRouteDef(), + Action\Domain\DomainRedirectsAction::getRouteDef(), Action\MercureInfoAction::getRouteDef(), ], diff --git a/module/Rest/src/Action/Domain/DomainRedirectsAction.php b/module/Rest/src/Action/Domain/DomainRedirectsAction.php new file mode 100644 index 00000000..e98aa339 --- /dev/null +++ b/module/Rest/src/Action/Domain/DomainRedirectsAction.php @@ -0,0 +1,39 @@ +getParsedBody(); + $requestData = DomainRedirectsRequest::fromRawData($body); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + + $authority = $requestData->authority(); + $domain = $this->domainService->getOrCreate($authority); + $notFoundRedirects = $requestData->toNotFoundRedirects($domain); + + $this->domainService->configureNotFoundRedirects($authority, $notFoundRedirects, $apiKey); + + return new JsonResponse($notFoundRedirects); + } +} diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index 35ce04f3..c8f9a475 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -16,11 +16,8 @@ class ListDomainsAction extends AbstractRestAction protected const ROUTE_PATH = '/domains'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private DomainServiceInterface $domainService; - - public function __construct(DomainServiceInterface $domainService) + public function __construct(private DomainServiceInterface $domainService) { - $this->domainService = $domainService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php new file mode 100644 index 00000000..e2b27e23 --- /dev/null +++ b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php @@ -0,0 +1,79 @@ +validateAndInit($payload); + return $instance; + } + + /** + * @throws ValidationException + */ + private function validateAndInit(array $payload): void + { + $inputFilter = DomainRedirectsInputFilter::withData($payload); + if (! $inputFilter->isValid()) { + throw ValidationException::fromInputFilter($inputFilter); + } + + $this->baseUrlRedirectWasProvided = array_key_exists( + DomainRedirectsInputFilter::BASE_URL_REDIRECT, + $payload, + ); + $this->regular404RedirectWasProvided = array_key_exists( + DomainRedirectsInputFilter::REGULAR_404_REDIRECT, + $payload, + ); + $this->invalidShortUrlRedirectWasProvided = array_key_exists( + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT, + $payload, + ); + + $this->authority = $inputFilter->getValue(DomainRedirectsInputFilter::DOMAIN); + $this->baseUrlRedirect = $inputFilter->getValue(DomainRedirectsInputFilter::BASE_URL_REDIRECT); + $this->regular404Redirect = $inputFilter->getValue(DomainRedirectsInputFilter::REGULAR_404_REDIRECT); + $this->invalidShortUrlRedirect = $inputFilter->getValue(DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT); + } + + public function authority(): string + { + return $this->authority; + } + + public function toNotFoundRedirects(?NotFoundRedirectConfigInterface $defaults = null): NotFoundRedirects + { + return NotFoundRedirects::withRedirects( + $this->baseUrlRedirectWasProvided ? $this->baseUrlRedirect : $defaults?->baseUrlRedirect(), + $this->regular404RedirectWasProvided ? $this->regular404Redirect : $defaults?->regular404Redirect(), + $this->invalidShortUrlRedirectWasProvided + ? $this->invalidShortUrlRedirect + : $defaults?->invalidShortUrlRedirect(), + ); + } +} diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index ef89da64..5f9d052c 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -20,13 +20,8 @@ class HealthAction extends AbstractRestAction protected const ROUTE_PATH = '/health'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private EntityManagerInterface $em; - private AppOptions $options; - - public function __construct(EntityManagerInterface $em, AppOptions $options) + public function __construct(private EntityManagerInterface $em, private AppOptions $options) { - $this->em = $em; - $this->options = $options; } /** @@ -38,7 +33,7 @@ class HealthAction extends AbstractRestAction { try { $connected = $this->em->getConnection()->ping(); - } catch (Throwable $e) { + } catch (Throwable) { $connected = false; } diff --git a/module/Rest/src/Action/MercureInfoAction.php b/module/Rest/src/Action/MercureInfoAction.php index 75893ab9..d6710357 100644 --- a/module/Rest/src/Action/MercureInfoAction.php +++ b/module/Rest/src/Action/MercureInfoAction.php @@ -19,13 +19,8 @@ class MercureInfoAction extends AbstractRestAction protected const ROUTE_PATH = '/mercure-info'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private JwtProviderInterface $jwtProvider; - private array $mercureConfig; - - public function __construct(JwtProviderInterface $jwtProvider, array $mercureConfig) + public function __construct(private JwtProviderInterface $jwtProvider, private array $mercureConfig) { - $this->jwtProvider = $jwtProvider; - $this->mercureConfig = $mercureConfig; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index 587c4bc5..90616dc5 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -15,13 +15,10 @@ use Shlinkio\Shlink\Rest\Action\AbstractRestAction; abstract class AbstractCreateShortUrlAction extends AbstractRestAction { - private UrlShortenerInterface $urlShortener; - private DataTransformerInterface $transformer; - - public function __construct(UrlShortenerInterface $urlShortener, DataTransformerInterface $transformer) - { - $this->urlShortener = $urlShortener; - $this->transformer = $transformer; + public function __construct( + private UrlShortenerInterface $urlShortener, + private DataTransformerInterface $transformer, + ) { } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php index 73eaa6ee..8059e5ab 100644 --- a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php @@ -17,11 +17,8 @@ class DeleteShortUrlAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE]; - private DeleteShortUrlServiceInterface $deleteShortUrlService; - - public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService) + public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService) { - $this->deleteShortUrlService = $deleteShortUrlService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 49187314..87c21aec 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -19,13 +19,10 @@ class EditShortUrlAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH, self::METHOD_PUT]; - private ShortUrlServiceInterface $shortUrlService; - private DataTransformerInterface $transformer; - - public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer) - { - $this->shortUrlService = $shortUrlService; - $this->transformer = $transformer; + public function __construct( + private ShortUrlServiceInterface $shortUrlService, + private DataTransformerInterface $transformer, + ) { } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index d114049c..feda3a62 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -21,15 +21,13 @@ class EditShortUrlTagsAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT]; - private ShortUrlServiceInterface $shortUrlService; - - public function __construct(ShortUrlServiceInterface $shortUrlService) + public function __construct(private ShortUrlServiceInterface $shortUrlService) { - $this->shortUrlService = $shortUrlService; } public function handle(Request $request): Response { + /** @var array $bodyParams */ $bodyParams = $request->getParsedBody(); if (! isset($bodyParams['tags'])) { diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index ee077790..075b56e1 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -21,13 +21,10 @@ class ListShortUrlsAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private ShortUrlServiceInterface $shortUrlService; - private DataTransformerInterface $transformer; - - public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer) - { - $this->shortUrlService = $shortUrlService; - $this->transformer = $transformer; + public function __construct( + private ShortUrlServiceInterface $shortUrlService, + private DataTransformerInterface $transformer + ) { } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index c14423ce..aae1a895 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -18,13 +18,10 @@ class ResolveShortUrlAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private ShortUrlResolverInterface $urlResolver; - private DataTransformerInterface $transformer; - - public function __construct(ShortUrlResolverInterface $urlResolver, DataTransformerInterface $transformer) - { - $this->urlResolver = $urlResolver; - $this->transformer = $transformer; + public function __construct( + private ShortUrlResolverInterface $urlResolver, + private DataTransformerInterface $transformer, + ) { } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php index 8aaf907b..09c860f5 100644 --- a/module/Rest/src/Action/Tag/CreateTagsAction.php +++ b/module/Rest/src/Action/Tag/CreateTagsAction.php @@ -16,22 +16,13 @@ class CreateTagsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_POST]; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { - $this->tagService = $tagService; } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * - * @throws \InvalidArgumentException - */ public function handle(ServerRequestInterface $request): ResponseInterface { + /** @var array $body */ $body = $request->getParsedBody(); $tags = $body['tags'] ?? []; diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php index b1be8af5..48e7acd9 100644 --- a/module/Rest/src/Action/Tag/DeleteTagsAction.php +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -16,11 +16,8 @@ class DeleteTagsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE]; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { - $this->tagService = $tagService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 48cf923b..89371b71 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -19,11 +19,8 @@ class ListTagsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { - $this->tagService = $tagService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index d83d8b9a..016d008b 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -17,15 +17,13 @@ class UpdateTagAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT]; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { - $this->tagService = $tagService; } public function handle(ServerRequestInterface $request): ResponseInterface { + /** @var array $body */ $body = $request->getParsedBody(); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); diff --git a/module/Rest/src/Action/Visit/GlobalVisitsAction.php b/module/Rest/src/Action/Visit/GlobalVisitsAction.php index 4810b100..1f2e1211 100644 --- a/module/Rest/src/Action/Visit/GlobalVisitsAction.php +++ b/module/Rest/src/Action/Visit/GlobalVisitsAction.php @@ -16,11 +16,8 @@ class GlobalVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsStatsHelperInterface $statsHelper; - - public function __construct(VisitsStatsHelperInterface $statsHelper) + public function __construct(private VisitsStatsHelperInterface $statsHelper) { - $this->statsHelper = $statsHelper; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index 7a65b920..b05d7b31 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -20,15 +20,10 @@ class OrphanVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/visits/orphan'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsStatsHelperInterface $visitsHelper; - private DataTransformerInterface $orphanVisitTransformer; - public function __construct( - VisitsStatsHelperInterface $visitsHelper, - DataTransformerInterface $orphanVisitTransformer + private VisitsStatsHelperInterface $visitsHelper, + private DataTransformerInterface $orphanVisitTransformer ) { - $this->visitsHelper = $visitsHelper; - $this->orphanVisitTransformer = $orphanVisitTransformer; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index 8175d1c7..5496ba35 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -21,11 +21,8 @@ class ShortUrlVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsStatsHelperInterface $visitsHelper; - - public function __construct(VisitsStatsHelperInterface $visitsHelper) + public function __construct(private VisitsStatsHelperInterface $visitsHelper) { - $this->visitsHelper = $visitsHelper; } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php index 8d981c82..b577ce06 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -20,11 +20,8 @@ class TagVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags/{tag}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsStatsHelperInterface $visitsHelper; - - public function __construct(VisitsStatsHelperInterface $visitsHelper) + public function __construct(private VisitsStatsHelperInterface $visitsHelper) { - $this->visitsHelper = $visitsHelper; } public function handle(Request $request): Response diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index aa3c117a..39b5dca1 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -8,16 +8,12 @@ use Cake\Chronos\Chronos; final class ApiKeyMeta { - private ?string $name = null; - private ?Chronos $expirationDate = null; - /** @var RoleDefinition[] */ - private array $roleDefinitions; - - private function __construct(?string $name, ?Chronos $expirationDate, array $roleDefinitions) - { - $this->name = $name; - $this->expirationDate = $expirationDate; - $this->roleDefinitions = $roleDefinitions; + private function __construct( + private ?string $name, + private ?Chronos $expirationDate, + /** @var RoleDefinition[] */ + private array $roleDefinitions, + ) { } public static function withName(string $name): self diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index 569044dc..fdd4d5cb 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -9,13 +9,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; final class RoleDefinition { - private string $roleName; - private array $meta; - - private function __construct(string $roleName, array $meta) + private function __construct(private string $roleName, private array $meta) { - $this->roleName = $roleName; - $this->meta = $meta; } public static function forAuthoredShortUrls(): self diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index a1f9b361..ddfabe81 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -11,14 +11,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class WithApiKeySpecsEnsuringJoin extends BaseSpecification { - private ?ApiKey $apiKey; - private string $fieldToJoin; - - public function __construct(?ApiKey $apiKey, string $fieldToJoin = 'shortUrls') + public function __construct(private ?ApiKey $apiKey, private string $fieldToJoin = 'shortUrls') { parent::__construct(); - $this->apiKey = $apiKey; - $this->fieldToJoin = $fieldToJoin; } protected function getSpec(): Specification diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 0317390e..121bea18 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -116,9 +116,14 @@ class ApiKey extends AbstractEntity { /** @var ApiKeyRole|null $role */ $role = $this->roles->get($roleName); - return $role === null ? [] : $role->meta(); + return $role?->meta() ?? []; } + /** + * @template T + * @param callable(string $roleName, array $meta): T $fun + * @return T[] + */ public function mapRoles(callable $fun): array { return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->name(), $role->meta()))->getValues(); diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 99dbb627..1155c37b 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -8,15 +8,8 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; class ApiKeyRole extends AbstractEntity { - private string $roleName; - private array $meta; - private ApiKey $apiKey; - - public function __construct(string $roleName, array $meta, ApiKey $apiKey) + public function __construct(private string $roleName, private array $meta, private ApiKey $apiKey) { - $this->roleName = $roleName; - $this->meta = $meta; - $this->apiKey = $apiKey; } public function name(): string diff --git a/module/Rest/src/Middleware/AuthenticationMiddleware.php b/module/Rest/src/Middleware/AuthenticationMiddleware.php index cb8f8b7a..705bc9c5 100644 --- a/module/Rest/src/Middleware/AuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/AuthenticationMiddleware.php @@ -23,18 +23,11 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa { public const API_KEY_HEADER = 'X-Api-Key'; - private ApiKeyServiceInterface $apiKeyService; - private array $routesWithoutApiKey; - private array $routesWithQueryApiKey; - public function __construct( - ApiKeyServiceInterface $apiKeyService, - array $routesWithoutApiKey, - array $routesWithQueryApiKey + private ApiKeyServiceInterface $apiKeyService, + private array $routesWithoutApiKey, + private array $routesWithQueryApiKey ) { - $this->apiKeyService = $apiKeyService; - $this->routesWithoutApiKey = $routesWithoutApiKey; - $this->routesWithQueryApiKey = $routesWithQueryApiKey; } public function process(Request $request, RequestHandlerInterface $handler): Response diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index b265fe13..b0d63dc7 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -16,11 +16,8 @@ use function implode; class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface { - private array $config; - - public function __construct(array $config) + public function __construct(private array $config) { - $this->config = $config; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php b/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php index a0896976..08503757 100644 --- a/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php @@ -13,7 +13,7 @@ use Psr\Http\Server\RequestHandlerInterface; use function array_shift; use function explode; -use function strpos; +use function str_contains; use function strtolower; class CreateShortUrlContentNegotiationMiddleware implements MiddlewareInterface @@ -62,7 +62,7 @@ class CreateShortUrlContentNegotiationMiddleware implements MiddlewareInterface { $accepts = explode(',', $acceptValue); $accept = strtolower(array_shift($accepts)); - return strpos($accept, 'text/plain') !== false ? self::PLAIN_TEXT : self::JSON; + return str_contains($accept, 'text/plain') ? self::PLAIN_TEXT : self::JSON; } private function determineBody(JsonResponse $resp): string diff --git a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php index c1991de2..5b1bfd40 100644 --- a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php @@ -12,15 +12,13 @@ use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; class DefaultShortCodesLengthMiddleware implements MiddlewareInterface { - private int $defaultShortCodesLength; - - public function __construct(int $defaultShortCodesLength) + public function __construct(private int $defaultShortCodesLength) { - $this->defaultShortCodesLength = $defaultShortCodesLength; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + /** @var array $body */ $body = $request->getParsedBody(); if (! isset($body[ShortUrlInputFilter::SHORT_CODE_LENGTH])) { $body[ShortUrlInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength; diff --git a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php index 3d76a975..8eb98153 100644 --- a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php @@ -11,17 +11,16 @@ use Psr\Http\Server\RequestHandlerInterface; class DropDefaultDomainFromRequestMiddleware implements MiddlewareInterface { - private string $defaultDomain; - - public function __construct(string $defaultDomain) + public function __construct(private string $defaultDomain) { - $this->defaultDomain = $defaultDomain; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + /** @var array $body */ + $body = $request->getParsedBody(); $request = $request->withQueryParams($this->sanitizeDomainFromPayload($request->getQueryParams())) - ->withParsedBody($this->sanitizeDomainFromPayload($request->getParsedBody())); + ->withParsedBody($this->sanitizeDomainFromPayload($body)); return $handler->handle($request); } diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php index c875a9ab..0f4fd75e 100644 --- a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -16,11 +16,8 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class OverrideDomainMiddleware implements MiddlewareInterface { - private DomainServiceInterface $domainService; - - public function __construct(DomainServiceInterface $domainService) + public function __construct(private DomainServiceInterface $domainService) { - $this->domainService = $domainService; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -35,6 +32,7 @@ class OverrideDomainMiddleware implements MiddlewareInterface $domain = $this->domainService->getDomain($domainId); if ($requestMethod === RequestMethodInterface::METHOD_POST) { + /** @var array $payload */ $payload = $request->getParsedBody(); $payload[ShortUrlInputFilter::DOMAIN] = $domain->getAuthority(); diff --git a/module/Rest/src/Service/ApiKeyCheckResult.php b/module/Rest/src/Service/ApiKeyCheckResult.php index 8ec3f65e..2caee4e1 100644 --- a/module/Rest/src/Service/ApiKeyCheckResult.php +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -8,11 +8,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class ApiKeyCheckResult { - private ?ApiKey $apiKey; - - public function __construct(?ApiKey $apiKey = null) + public function __construct(private ?ApiKey $apiKey = null) { - $this->apiKey = $apiKey; } public function isValid(): bool diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index e81c446f..d66e70e2 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -15,17 +15,14 @@ use function sprintf; class ApiKeyService implements ApiKeyServiceInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } public function create( ?Chronos $expirationDate = null, ?string $name = null, - RoleDefinition ...$roleDefinitions + RoleDefinition ...$roleDefinitions, ): ApiKey { $key = $this->buildApiKeyWithParams($expirationDate, $name); foreach ($roleDefinitions as $definition) { @@ -40,20 +37,14 @@ class ApiKeyService implements ApiKeyServiceInterface private function buildApiKeyWithParams(?Chronos $expirationDate, ?string $name): ApiKey { - // TODO Use match expression when migrating to PHP8 - if ($expirationDate === null && $name === null) { - return ApiKey::create(); - } - - if ($expirationDate !== null && $name !== null) { - return ApiKey::fromMeta(ApiKeyMeta::withNameAndExpirationDate($name, $expirationDate)); - } - - if ($name === null) { - return ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)); - } - - return ApiKey::fromMeta(ApiKeyMeta::withName($name)); + return match (true) { + $expirationDate !== null && $name !== null => ApiKey::fromMeta( + ApiKeyMeta::withNameAndExpirationDate($name, $expirationDate), + ), + $expirationDate !== null => ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)), + $name !== null => ApiKey::fromMeta(ApiKeyMeta::withName($name)), + default => ApiKey::create(), + }; } public function check(string $key): ApiKeyCheckResult diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 982bdf4f..85b726df 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -14,7 +14,7 @@ interface ApiKeyServiceInterface public function create( ?Chronos $expirationDate = null, ?string $name = null, - RoleDefinition ...$roleDefinitions + RoleDefinition ...$roleDefinitions, ): ApiKey; public function check(string $key): ApiKeyCheckResult; diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 360287ec..479527c1 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -19,7 +19,7 @@ class DeleteShortUrlTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/DomainRedirectsTest.php b/module/Rest/test-api/Action/DomainRedirectsTest.php new file mode 100644 index 00000000..987c09d6 --- /dev/null +++ b/module/Rest/test-api/Action/DomainRedirectsTest.php @@ -0,0 +1,100 @@ +callApiWithKey(self::METHOD_PATCH, '/domains/redirects', [ + RequestOptions::JSON => ['domain' => 'doma.in'], + ]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); + self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); + self::assertEquals('INVALID_DOMAIN', $payload['type']); + self::assertEquals( + 'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.', + $payload['detail'], + ); + self::assertEquals('Invalid domain', $payload['title']); + } + + /** + * @test + * @dataProvider provideInvalidDomains + */ + public function anErrorIsReturnedWhenTryingToEditAnInvalidDomain(array $request): void + { + $resp = $this->callApiWithKey(self::METHOD_PATCH, '/domains/redirects', [ + RequestOptions::JSON => $request, + ]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('Provided data is not valid', $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); + } + + public function provideInvalidDomains(): iterable + { + yield 'no domain' => [[]]; + yield 'empty domain' => [['domain' => '']]; + yield 'null domain' => [['domain' => null]]; + yield 'invalid domain' => [['domain' => '192.168.1.1']]; + } + + /** + * @test + * @dataProvider provideRequests + */ + public function allowsToEditDomainRedirects(array $request, array $expectedResponse): void + { + $resp = $this->callApiWithKey(self::METHOD_PATCH, '/domains/redirects', [ + RequestOptions::JSON => $request, + ]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); + self::assertEquals($expectedResponse, $payload); + } + + public function provideRequests(): iterable + { + yield 'new domain' => [[ + 'domain' => 'my-new-domain.com', + 'regular404Redirect' => 'foo.com', + ], [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => 'foo.com', + 'invalidShortUrlRedirect' => null, + ]]; + yield 'existing domain with redirects' => [[ + 'domain' => 'detached-with-redirects.com', + 'baseUrlRedirect' => null, + 'invalidShortUrlRedirect' => 'foo.com', + ], [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => 'bar.com', + 'invalidShortUrlRedirect' => 'foo.com', + ]]; + yield 'existing domain with no redirects' => [[ + 'domain' => 'example.com', + 'baseUrlRedirect' => null, + 'invalidShortUrlRedirect' => 'foo.com', + ], [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => 'foo.com', + ]]; + } +} diff --git a/module/Rest/test-api/Action/EditShortUrlTagsTest.php b/module/Rest/test-api/Action/EditShortUrlTagsTest.php index 18f6f3b0..f940a52d 100644 --- a/module/Rest/test-api/Action/EditShortUrlTagsTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTagsTest.php @@ -35,7 +35,7 @@ class EditShortUrlTagsTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $url = $this->buildShortUrlPath($shortCode, $domain, '/tags'); $resp = $this->callApiWithKey(self::METHOD_PUT, $url, [RequestOptions::JSON => [ diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 6652c1a4..a25ccddd 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -6,12 +6,12 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; +use GuzzleHttp\Psr7\Query; use GuzzleHttp\RequestOptions; use Laminas\Diactoros\Uri; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; -use function GuzzleHttp\Psr7\build_query; use function sprintf; class EditShortUrlTest extends ApiTestCase @@ -105,7 +105,7 @@ class EditShortUrlTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $url = $this->buildShortUrlPath($shortCode, $domain); $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []], $apiKey); @@ -147,7 +147,7 @@ class EditShortUrlTest extends ApiTestCase $url = new Uri(sprintf('/short-urls/%s', $shortCode)); if ($domain !== null) { - $url = $url->withQuery(build_query(['domain' => $domain])); + $url = $url->withQuery(Query::build(['domain' => $domain])); } $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php index cf3167f8..5f33c20b 100644 --- a/module/Rest/test-api/Action/ListDomainsTest.php +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -31,26 +31,60 @@ class ListDomainsTest extends ApiTestCase [ 'domain' => 'doma.in', 'isDefault' => true, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], + ], + [ + 'domain' => 'detached-with-redirects.com', + 'isDefault' => false, + 'redirects' => [ + 'baseUrlRedirect' => 'foo.com', + 'regular404Redirect' => 'bar.com', + 'invalidShortUrlRedirect' => null, + ], ], [ 'domain' => 'example.com', 'isDefault' => false, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], [ 'domain' => 'some-domain.com', 'isDefault' => false, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], ]]; yield 'author API key' => ['author_api_key', [ [ 'domain' => 'doma.in', 'isDefault' => true, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], ]]; yield 'domain API key' => ['domain_api_key', [ [ 'domain' => 'example.com', 'isDefault' => false, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], ]]; } diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index 067cf9a4..21f4cae1 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -45,7 +45,7 @@ class OrphanVisitsTest extends ApiTestCase array $query, int $totalItems, int $expectedAmount, - array $expectedVisits + array $expectedVisits, ): void { $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', [RequestOptions::QUERY => $query]); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index ca99f058..216e35e9 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -51,7 +51,7 @@ class ResolveShortUrlTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 1d572004..327c7c05 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -23,7 +23,7 @@ class ShortUrlVisitsTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $resp = $this->callApiWithKey( self::METHOD_GET, diff --git a/module/Rest/test-api/Action/TagVisitsTest.php b/module/Rest/test-api/Action/TagVisitsTest.php index 07b0576d..544fcccf 100644 --- a/module/Rest/test-api/Action/TagVisitsTest.php +++ b/module/Rest/test-api/Action/TagVisitsTest.php @@ -19,7 +19,7 @@ class TagVisitsTest extends ApiTestCase string $apiKey, string $tag, bool $excludeBots, - int $expectedVisitsAmount + int $expectedVisitsAmount, ): void { $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [ RequestOptions::QUERY => $excludeBots ? ['excludeBots' => true] : [], diff --git a/module/Rest/test-api/Fixtures/DomainFixture.php b/module/Rest/test-api/Fixtures/DomainFixture.php index 576586a6..619dfdc4 100644 --- a/module/Rest/test-api/Fixtures/DomainFixture.php +++ b/module/Rest/test-api/Fixtures/DomainFixture.php @@ -6,17 +6,23 @@ namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Persistence\ObjectManager; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Entity\Domain; class DomainFixture extends AbstractFixture { public function load(ObjectManager $manager): void { - $domain = new Domain('example.com'); + $domain = Domain::withAuthority('example.com'); $manager->persist($domain); $this->addReference('example_domain', $domain); - $manager->persist(new Domain('this_domain_is_detached.com')); + $manager->persist(Domain::withAuthority('this_domain_is_detached.com')); + + $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); + $detachedWithRedirects->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com')); + $manager->persist($detachedWithRedirects); + $manager->flush(); } } diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php index 093a200c..a51d6a7b 100644 --- a/module/Rest/test-api/Middleware/CorsTest.php +++ b/module/Rest/test-api/Middleware/CorsTest.php @@ -28,7 +28,7 @@ class CorsTest extends ApiTestCase public function responseIncludesCorsHeadersIfOriginIsSent( string $origin, string $endpoint, - int $expectedStatusCode + int $expectedStatusCode, ): void { $resp = $this->callApiWithKey(self::METHOD_GET, $endpoint, [ RequestOptions::HEADERS => ['Origin' => $origin], diff --git a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php new file mode 100644 index 00000000..5d09f3f7 --- /dev/null +++ b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php @@ -0,0 +1,160 @@ +domainService = $this->prophesize(DomainServiceInterface::class); + $this->action = new DomainRedirectsAction($this->domainService->reveal()); + } + + /** + * @test + * @dataProvider provideInvalidBodies + */ + public function invalidDataThrowsException(array $body): void + { + $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); + + $this->expectException(ValidationException::class); + $this->domainService->getOrCreate(Argument::cetera())->shouldNotBeCalled(); + $this->domainService->configureNotFoundRedirects(Argument::cetera())->shouldNotBeCalled(); + + $this->action->handle($request); + } + + public function provideInvalidBodies(): iterable + { + yield 'no domain' => [[]]; + yield 'empty domain' => [['domain' => '']]; + yield 'invalid domain' => [['domain' => '192.168.1.20']]; + } + + /** + * @test + * @dataProvider provideDomainsAndRedirects + */ + public function domainIsFetchedAndUsedToGetItConfigured( + Domain $domain, + array $redirects, + array $expectedResult, + ): void { + $authority = 'doma.in'; + $redirects['domain'] = $authority; + $apiKey = ApiKey::create(); + $request = ServerRequestFactory::fromGlobals()->withParsedBody($redirects) + ->withAttribute(ApiKey::class, $apiKey); + + $getOrCreate = $this->domainService->getOrCreate($authority)->willReturn($domain); + $configureNotFoundRedirects = $this->domainService->configureNotFoundRedirects( + $authority, + NotFoundRedirects::withRedirects( + array_key_exists(DomainRedirectsInputFilter::BASE_URL_REDIRECT, $redirects) + ? $redirects[DomainRedirectsInputFilter::BASE_URL_REDIRECT] + : $domain?->baseUrlRedirect(), + array_key_exists(DomainRedirectsInputFilter::REGULAR_404_REDIRECT, $redirects) + ? $redirects[DomainRedirectsInputFilter::REGULAR_404_REDIRECT] + : $domain?->regular404Redirect(), + array_key_exists(DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT, $redirects) + ? $redirects[DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT] + : $domain?->invalidShortUrlRedirect(), + ), + $apiKey, + ); + + /** @var JsonResponse $response */ + $response = $this->action->handle($request); + /** @var NotFoundRedirects $payload */ + $payload = $response->getPayload(); + + self::assertEquals($expectedResult, $payload->jsonSerialize()); + $getOrCreate->shouldHaveBeenCalledOnce(); + $configureNotFoundRedirects->shouldHaveBeenCalledOnce(); + } + + public function provideDomainsAndRedirects(): iterable + { + yield 'full overwrite' => [Domain::withAuthority(''), [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'foo', + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => 'bar', + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'baz', + ], [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'foo', + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => 'bar', + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'baz', + ]]; + yield 'partial overwrite' => [Domain::withAuthority(''), [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'foo', + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'baz', + ], [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'foo', + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => null, + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'baz', + ]]; + yield 'no override' => [ + (static function (): Domain { + $domain = Domain::withAuthority(''); + $domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects( + 'baz', + 'bar', + 'foo', + )); + + return $domain; + })(), + [], + [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'baz', + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => 'bar', + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'foo', + ], + ]; + yield 'reset' => [ + (static function (): Domain { + $domain = Domain::withAuthority(''); + $domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects( + 'foo', + 'bar', + 'baz', + )); + + return $domain; + })(), + [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => null, + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => null, + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => null, + ], + [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => null, + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => null, + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => null, + ], + ]; + } +} diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index cbe43895..45575cc6 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -11,6 +11,8 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; 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 Shlinkio\Shlink\Rest\Action\Domain\ListDomainsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -32,8 +34,8 @@ class ListDomainsActionTest extends TestCase { $apiKey = ApiKey::create(); $domains = [ - new DomainItem('bar.com', true), - new DomainItem('baz.com', false), + DomainItem::forDefaultDomain('bar.com', new NotFoundRedirectOptions()), + DomainItem::forExistingDomain(Domain::withAuthority('baz.com')), ]; $listDomains = $this->domainService->listDomains($apiKey)->willReturn($domains); diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 712d605d..170ccc09 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -49,7 +49,7 @@ class ListShortUrlsActionTest extends TestCase array $expectedTags, ?string $expectedOrderBy, ?string $startDate = null, - ?string $endDate = null + ?string $endDate = null, ): void { $apiKey = ApiKey::create(); $request = (new ServerRequest())->withQueryParams($query)->withAttribute(ApiKey::class, $apiKey); diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index 6f8ddbb9..04ffb107 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -16,8 +16,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function strpos; - class ResolveShortUrlActionTest extends TestCase { use ProphecyTrait; @@ -46,6 +44,6 @@ class ResolveShortUrlActionTest extends TestCase $response = $this->action->handle($request); self::assertEquals(200, $response->getStatusCode()); - self::assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0); + self::assertStringContainsString('http://domain.com/foo/bar', $response->getBody()->getContents()); } } diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index 68503b58..c915098a 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -88,7 +88,7 @@ class AuthenticationMiddlewareTest extends TestCase */ public function throwsExceptionWhenNoApiKeyIsProvided( ServerRequestInterface $request, - string $expectedMessage + string $expectedMessage, ): void { $this->apiKeyService->check(Argument::any())->shouldNotBeCalled(); $this->handler->handle($request)->shouldNotBeCalled(); diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index a274e1f8..acdc9600 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -91,7 +91,7 @@ class CrossDomainMiddlewareTest extends TestCase */ public function optionsRequestParsesRouteMatchToDetermineAllowedMethods( ?string $allowHeader, - string $expectedAllowedMethods + string $expectedAllowedMethods, ): void { $originalResponse = new Response(); if ($allowHeader !== null) { @@ -121,7 +121,7 @@ class CrossDomainMiddlewareTest extends TestCase public function expectedStatusCodeIsReturnDependingOnRequestMethod( string $method, int $status, - int $expectedStatus + int $expectedStatus, ): void { $originalResponse = (new Response())->withStatus($status); $request = (new ServerRequest())->withMethod($method) diff --git a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php index 9614f8c7..ed1e62d3 100644 --- a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php @@ -82,19 +82,23 @@ class OverrideDomainMiddlewareTest extends TestCase public function provideBodies(): iterable { - yield 'no domain provided' => [new Domain('foo.com'), [], [ShortUrlInputFilter::DOMAIN => 'foo.com']]; + yield 'no domain provided' => [ + Domain::withAuthority('foo.com'), + [], + [ShortUrlInputFilter::DOMAIN => 'foo.com'], + ]; yield 'other domain provided' => [ - new Domain('bar.com'), + Domain::withAuthority('bar.com'), [ShortUrlInputFilter::DOMAIN => 'foo.com'], [ShortUrlInputFilter::DOMAIN => 'bar.com'], ]; yield 'same domain provided' => [ - new Domain('baz.com'), + Domain::withAuthority('baz.com'), [ShortUrlInputFilter::DOMAIN => 'baz.com'], [ShortUrlInputFilter::DOMAIN => 'baz.com'], ]; yield 'more body params' => [ - new Domain('doma.in'), + Domain::withAuthority('doma.in'), [ShortUrlInputFilter::DOMAIN => 'baz.com', 'something' => 'else', 'foo' => 123], [ShortUrlInputFilter::DOMAIN => 'doma.in', 'something' => 'else', 'foo' => 123], ]; @@ -106,7 +110,7 @@ class OverrideDomainMiddlewareTest extends TestCase */ public function setsRequestAttributeWhenMethodIsNotPost(string $method): void { - $domain = new Domain('something.com'); + $domain = Domain::withAuthority('something.com'); $request = $this->requestWithApiKey()->withMethod($method); $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(true); $getRoleMeta = $this->apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)->willReturn(['domain_id' => '123']); diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index addebbcd..de17d8bd 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -55,7 +55,7 @@ class ApiKeyServiceTest extends TestCase yield 'no expiration date or name' => [null, null, []]; yield 'expiration date' => [Chronos::parse('2030-01-01'), null, []]; yield 'roles' => [null, null, [ - RoleDefinition::forDomain((new Domain(''))->setId('123')), + RoleDefinition::forDomain(Domain::withAuthority('')->setId('123')), RoleDefinition::forAuthoredShortUrls(), ]]; yield 'single name' => [null, 'Alice', []]; diff --git a/phpstan.neon b/phpstan.neon index 80f1b083..bf3afc8e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,11 @@ +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-symfony/extension.neon parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false - ignoreErrors: - - '#If condition is always false#' + symfony: + console_application_loader: 'config/cli-app.php' + doctrine: + repositoryClass: Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository + objectManagerLoader: 'config/entity-manager.php' diff --git a/public/index.php b/public/index.php index 78bb412a..99018890 100644 --- a/public/index.php +++ b/public/index.php @@ -2,5 +2,4 @@ declare(strict_types=1); -$run = require __DIR__ . '/../config/run.php'; -$run(); +(require __DIR__ . '/../config/run.php')();