mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
commit
ff6747dab5
257 changed files with 3362 additions and 1445 deletions
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
4
.github/workflows/publish-release.yml
vendored
4
.github/workflows/publish-release.yml
vendored
|
@ -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
|
||||
|
|
32
CHANGELOG.md
32
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*
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
14
Dockerfile
14
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"]
|
||||
|
|
|
@ -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.
|
||||
|
|
7
bin/cli
7
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();
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
$isSwoole = extension_loaded('swoole');
|
||||
|
||||
return [
|
||||
|
||||
'url_shortener' => [
|
||||
'domain' => [
|
||||
'schema' => 'http',
|
||||
'hostname' => 'localhost:8080',
|
||||
'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'),
|
||||
],
|
||||
'auto_resolve_titles' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
12
config/cli-app.php
Normal file
12
config/cli-app.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as CliApp;
|
||||
|
||||
return (static function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
return $container->get(CliApp::class);
|
||||
})();
|
|
@ -4,12 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Tools\Console\ConsoleRunner;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return (function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
$em = $container->get(EntityManager::class);
|
||||
|
||||
return (static function () {
|
||||
/** @var EntityManager $em */
|
||||
$em = include __DIR__ . '/entity-manager.php';
|
||||
return ConsoleRunner::createHelperSet($em);
|
||||
})();
|
||||
|
|
12
config/entity-manager.php
Normal file
12
config/entity-manager.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return (static function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
return $container->get(EntityManager::class);
|
||||
})();
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
FROM php:8.0.6-fpm-alpine3.13
|
||||
FROM php:8.0.9-fpm-alpine3.14
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.19
|
||||
ENV APCU_VERSION 5.1.20
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
FROM php:8.0.6-alpine3.13
|
||||
FROM php:8.0.9-alpine3.14
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.19
|
||||
ENV APCU_VERSION 5.1.20
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV INOTIFY_VERSION 3.0.0
|
||||
ENV SWOOLE_VERSION 4.6.7
|
||||
ENV SWOOLE_VERSION 4.7.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
|
||||
RUN apk update
|
||||
|
|
|
@ -57,7 +57,7 @@ final class Version20180913205455 extends AbstractMigration
|
|||
|
||||
try {
|
||||
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
41
data/migrations/Version20210720143824.php
Normal file
41
data/migrations/Version20210720143824.php
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Schema\Table;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210720143824 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$domainsTable = $schema->getTable('domains');
|
||||
$this->skipIf($domainsTable->hasColumn('base_url_redirect'));
|
||||
|
||||
$this->createRedirectColumn($domainsTable, 'base_url_redirect');
|
||||
$this->createRedirectColumn($domainsTable, 'regular_not_found_redirect');
|
||||
$this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect');
|
||||
}
|
||||
|
||||
private function createRedirectColumn(Table $table, string $columnName): void
|
||||
{
|
||||
$table->addColumn($columnName, Types::STRING, [
|
||||
'notnull' => false,
|
||||
'default' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$domainsTable = $schema->getTable('domains');
|
||||
$this->skipIf(! $domainsTable->hasColumn('base_url_redirect'));
|
||||
|
||||
$domainsTable->dropColumn('base_url_redirect');
|
||||
$domainsTable->dropColumn('regular_not_found_redirect');
|
||||
$domainsTable->dropColumn('invalid_short_url_redirect');
|
||||
}
|
||||
}
|
|
@ -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' => [
|
||||
|
|
|
@ -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
|
||||
|
|
20
docs/swagger/definitions/NotFoundRedirects.json
Normal file
20
docs/swagger/definitions/NotFoundRedirects.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrlRedirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits the domain's base URL"
|
||||
},
|
||||
"regular404Redirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits a not found URL other than an invalid short URL"
|
||||
},
|
||||
"invalidShortUrlRedirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits an invalid short URL"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
124
docs/swagger/paths/v2_domains_redirects.json
Normal file
124
docs/swagger/paths/v2_domains_redirects.json
Normal file
|
@ -0,0 +1,124 @@
|
|||
{
|
||||
"patch": {
|
||||
"operationId": "setDomainRedirects",
|
||||
"tags": [
|
||||
"Domains"
|
||||
],
|
||||
"summary": "Sets domain \"not found\" redirects",
|
||||
"description": "Sets the URLs that you want a visitor to get redirected to for \not found\" URLs for a specific domain",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Request body.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"required": ["domain"],
|
||||
"properties": {
|
||||
"domain": {
|
||||
"description": "The domain's authority for which you want to set redirects",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../definitions/NotFoundRedirects.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The domain's redirects after the update, when existing redirects have been merged with provided ones.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"required": ["baseUrlRedirect", "regular404Redirect", "invalidShortUrlRedirect"]
|
||||
},
|
||||
{
|
||||
"$ref": "../definitions/NotFoundRedirects.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"baseUrlRedirect": "https://example.com/my-landing-page",
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Provided data is invalid.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["invalidElements"],
|
||||
"properties": {
|
||||
"invalidElements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"domain",
|
||||
"baseUrlRedirect",
|
||||
"regular404Redirect",
|
||||
"invalidShortUrlRedirect"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Default domain was provided, and it cannot be edited this way.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
"URL Shortener"
|
||||
],
|
||||
"summary": "Short URL QR code",
|
||||
"description": "Generates a QR code image pointing to a short URL",
|
||||
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
|
@ -35,10 +35,8 @@
|
|||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"png",
|
||||
"svg"
|
||||
]
|
||||
"enum": ["png", "svg"],
|
||||
"default": "png"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -51,6 +49,17 @@
|
|||
"minimum": 0,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "errorCorrection",
|
||||
"in": "query",
|
||||
"description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["L", "M", "Q", "H"],
|
||||
"default": "L"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
114
module/CLI/src/Command/Domain/DomainRedirectsCommand.php
Normal file
114
module/CLI/src/Command/Domain/DomainRedirectsCommand.php
Normal file
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Functional\filter;
|
||||
use function Functional\invoke;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
|
||||
class DomainRedirectsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:redirects';
|
||||
|
||||
public function __construct(private DomainServiceInterface $domainService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Set specific "not found" redirects for individual domains.')
|
||||
->addArgument(
|
||||
'domain',
|
||||
InputArgument::REQUIRED,
|
||||
'The domain authority to which you want to set the specific redirects',
|
||||
);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
/** @var string|null $domain */
|
||||
$domain = $input->getArgument('domain');
|
||||
if ($domain !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
|
||||
|
||||
/** @var string[] $availableDomains */
|
||||
$availableDomains = invoke(
|
||||
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()),
|
||||
'toString',
|
||||
);
|
||||
if (empty($availableDomains)) {
|
||||
$input->setArgument('domain', $askNewDomain());
|
||||
return;
|
||||
}
|
||||
|
||||
$selectedOption = $io->choice(
|
||||
'Select the domain to configure',
|
||||
[...$availableDomains, '<options=bold>New domain</>'],
|
||||
);
|
||||
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$domainAuthority = $input->getArgument('domain');
|
||||
$domain = $this->domainService->findByAuthority($domainAuthority);
|
||||
|
||||
$ask = static function (string $message, ?string $current) use ($io): ?string {
|
||||
if ($current === null) {
|
||||
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
|
||||
}
|
||||
|
||||
$choice = $io->choice($message, [
|
||||
sprintf('Keep current one: [%s]', $current),
|
||||
'Set new redirect URL',
|
||||
'Remove redirect',
|
||||
]);
|
||||
|
||||
return match ($choice) {
|
||||
'Set new redirect URL' => $io->ask('New redirect URL'),
|
||||
'Remove redirect' => null,
|
||||
default => $current,
|
||||
};
|
||||
};
|
||||
|
||||
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
|
||||
$ask(
|
||||
'URL to redirect to when a user hits this domain\'s base URL',
|
||||
$domain?->baseUrlRedirect(),
|
||||
),
|
||||
$ask(
|
||||
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
|
||||
$domain?->regular404Redirect(),
|
||||
),
|
||||
$ask(
|
||||
'URL to redirect to when a user hits an invalid short URL',
|
||||
$domain?->invalidShortUrlRedirect(),
|
||||
),
|
||||
));
|
||||
|
||||
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
|
@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
|
|||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function Functional\map;
|
||||
|
@ -18,30 +20,58 @@ class ListDomainsCommand extends Command
|
|||
{
|
||||
public const NAME = 'domain:list';
|
||||
|
||||
private DomainServiceInterface $domainService;
|
||||
|
||||
public function __construct(DomainServiceInterface $domainService)
|
||||
public function __construct(private DomainServiceInterface $domainService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->domainService = $domainService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('List all domains that have been ever used for some short URL');
|
||||
->setDescription('List all domains that have been ever used for some short URL')
|
||||
->addOption(
|
||||
'show-redirects',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Will display an extra column with the information of the "not found" redirects for every domain.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$domains = $this->domainService->listDomains();
|
||||
$showRedirects = $input->getOption('show-redirects');
|
||||
$commonFields = ['Domain', 'Is default'];
|
||||
$table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(
|
||||
['Domain', 'Is default'],
|
||||
map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
|
||||
$table->render(
|
||||
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
|
||||
map($domains, function (DomainItem $domain) use ($showRedirects) {
|
||||
$commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No'];
|
||||
|
||||
return $showRedirects
|
||||
? [
|
||||
...$commonValues,
|
||||
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig()),
|
||||
]
|
||||
: $commonValues;
|
||||
}),
|
||||
);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
|
||||
{
|
||||
$baseUrl = $config->baseUrlRedirect() ?? 'N/A';
|
||||
$regular404 = $config->regular404Redirect() ?? 'N/A';
|
||||
$invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
|
||||
|
||||
return <<<EOL
|
||||
* Base URL: {$baseUrl}
|
||||
* Regular 404: {$regular404}
|
||||
* Invalid short URL: {$invalidShortUrl}
|
||||
EOL;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,12 +21,9 @@ class DeleteShortUrlCommand extends Command
|
|||
{
|
||||
public const NAME = 'short-url:delete';
|
||||
|
||||
private DeleteShortUrlServiceInterface $deleteShortUrlService;
|
||||
|
||||
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->deleteShortUrlService = $deleteShortUrlService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -19,13 +19,11 @@ class DownloadGeoLiteDbCommand extends Command
|
|||
{
|
||||
public const NAME = 'visit:download-db';
|
||||
|
||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
||||
private ?ProgressBar $progressBar = null;
|
||||
|
||||
public function __construct(GeolocationDbUpdaterInterface $dbUpdater)
|
||||
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
@ -47,8 +45,8 @@ class DownloadGeoLiteDbCommand extends Command
|
|||
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
$this->progressBar = new ProgressBar($io);
|
||||
}, function (int $total, int $downloaded): void {
|
||||
$this->progressBar->setMaxSteps($total);
|
||||
$this->progressBar->setProgress($downloaded);
|
||||
$this->progressBar?->setMaxSteps($total);
|
||||
$this->progressBar?->setProgress($downloaded);
|
||||
});
|
||||
|
||||
if ($this->progressBar === null) {
|
||||
|
@ -71,7 +69,7 @@ class DownloadGeoLiteDbCommand extends Command
|
|||
}
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $io);
|
||||
$this->getApplication()?->renderThrowable($e, $io);
|
||||
}
|
||||
|
||||
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
|
||||
|
|
|
@ -30,19 +30,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||
{
|
||||
public const NAME = 'visit:locate';
|
||||
|
||||
private VisitLocatorInterface $visitLocator;
|
||||
private IpLocationResolverInterface $ipLocationResolver;
|
||||
|
||||
private SymfonyStyle $io;
|
||||
|
||||
public function __construct(
|
||||
VisitLocatorInterface $visitLocator,
|
||||
IpLocationResolverInterface $ipLocationResolver,
|
||||
private VisitLocatorInterface $visitLocator,
|
||||
private IpLocationResolverInterface $ipLocationResolver,
|
||||
LockFactory $locker
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
$this->visitLocator = $visitLocator;
|
||||
$this->ipLocationResolver = $ipLocationResolver;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
@ -124,7 +119,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||
} catch (Throwable $e) {
|
||||
$this->io->error($e->getMessage());
|
||||
if ($this->io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $this->io);
|
||||
$this->getApplication()?->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
|
@ -144,7 +139,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||
throw IpCannotBeLocatedException::forEmptyAddress();
|
||||
}
|
||||
|
||||
$ipAddr = $visit->getRemoteAddr();
|
||||
$ipAddr = $visit->getRemoteAddr() ?? '';
|
||||
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
|
||||
if ($ipAddr === IpAddress::LOCALHOST) {
|
||||
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
|
||||
|
@ -156,7 +151,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||
} catch (WrongIpException $e) {
|
||||
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
|
||||
if ($this->io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $this->io);
|
||||
$this->getApplication()?->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
throw IpCannotBeLocatedException::forError($e);
|
||||
|
@ -173,7 +168,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||
|
||||
private function checkDbUpdate(InputInterface $input): void
|
||||
{
|
||||
$downloadDbCommand = $this->getApplication()->find(DownloadGeoLiteDbCommand::NAME);
|
||||
$cliApp = $this->getApplication();
|
||||
if ($cliApp === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
|
||||
$exitCode = $downloadDbCommand->run($input, $this->io);
|
||||
|
||||
if ($exitCode === ExitCodes::EXIT_FAILURE) {
|
||||
|
@ -183,6 +183,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||
|
||||
protected function getLockConfig(): LockedCommandConfig
|
||||
{
|
||||
return LockedCommandConfig::nonBlocking($this->getName());
|
||||
return LockedCommandConfig::nonBlocking(self::NAME);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -5,23 +5,33 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\CLI\Util;
|
||||
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Helper\TableSeparator;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function Functional\intersperse;
|
||||
|
||||
final class ShlinkTable
|
||||
{
|
||||
private const DEFAULT_STYLE_NAME = 'default';
|
||||
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
|
||||
|
||||
private ?Table $baseTable;
|
||||
|
||||
public function __construct(Table $baseTable)
|
||||
private function __construct(private Table $baseTable, private bool $withRowSeparators)
|
||||
{
|
||||
$this->baseTable = $baseTable;
|
||||
}
|
||||
|
||||
public static function fromOutput(OutputInterface $output): self
|
||||
public static function default(OutputInterface $output): self
|
||||
{
|
||||
return new self(new Table($output));
|
||||
return new self(new Table($output), false);
|
||||
}
|
||||
|
||||
public static function withRowSeparators(OutputInterface $output): self
|
||||
{
|
||||
return new self(new Table($output), true);
|
||||
}
|
||||
|
||||
public static function fromBaseTable(Table $baseTable): self
|
||||
{
|
||||
return new self($baseTable, false);
|
||||
}
|
||||
|
||||
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
|
||||
|
@ -29,11 +39,12 @@ final class ShlinkTable
|
|||
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
|
||||
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
|
||||
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
|
||||
$tableRows = $this->withRowSeparators ? intersperse($rows, new TableSeparator()) : $rows;
|
||||
|
||||
$table = clone $this->baseTable;
|
||||
$table->setStyle($style)
|
||||
->setHeaders($headers)
|
||||
->setRows($rows)
|
||||
->setRows($tableRows)
|
||||
->setFooterTitle($footerTitle)
|
||||
->setHeaderTitle($headerTitle)
|
||||
->render();
|
||||
|
|
|
@ -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()],
|
||||
|
|
|
@ -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 |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
|
||||
|
|
180
module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php
Normal file
180
module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php
Normal file
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function substr_count;
|
||||
|
||||
class DomainRedirectsCommandTest extends TestCase
|
||||
{
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $domainService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDomains
|
||||
*/
|
||||
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
|
||||
{
|
||||
$domainAuthority = 'my-domain.com';
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withRedirects('foo.com', null, 'baz.com'),
|
||||
)->willReturn(Domain::withAuthority(''));
|
||||
|
||||
$this->commandTester->setInputs(['foo.com', '', 'baz.com']);
|
||||
$this->commandTester->execute(['domain' => $domainAuthority]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('[OK] "Not found" redirects properly set for "my-domain.com"', $output);
|
||||
self::assertStringContainsString('URL to redirect to when a user hits this domain\'s base URL', $output);
|
||||
self::assertStringContainsString(
|
||||
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
|
||||
$output,
|
||||
);
|
||||
self::assertStringContainsString('URL to redirect to when a user hits an invalid short URL', $output);
|
||||
self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)'));
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideDomains(): iterable
|
||||
{
|
||||
yield 'no domain' => [null];
|
||||
yield 'domain without redirects' => [Domain::withAuthority('')];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function offersNewOptionsForDomainsWithExistingRedirects(): void
|
||||
{
|
||||
$domainAuthority = 'example.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
$domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com', 'baz.com'));
|
||||
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withRedirects(null, 'edited.com', 'baz.com'),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['2', '1', 'edited.com', '0']);
|
||||
$this->commandTester->execute(['domain' => $domainAuthority]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('[OK] "Not found" redirects properly set for "example.com"', $output);
|
||||
self::assertStringContainsString('Keep current one: [bar.com]', $output);
|
||||
self::assertStringContainsString('Keep current one: [baz.com]', $output);
|
||||
self::assertStringContainsString('Keep current one: [baz.com]', $output);
|
||||
self::assertStringNotContainsStringIgnoringCase('(Leave empty for no redirect)', $output);
|
||||
self::assertEquals(3, substr_count($output, 'Set new redirect URL'));
|
||||
self::assertEquals(3, substr_count($output, 'Remove redirect'));
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function authorityIsRequestedWhenNotProvidedAndNoOtherDomainsExist(): void
|
||||
{
|
||||
$domainAuthority = 'example.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs([$domainAuthority, '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function oneOfTheExistingDomainsCanBeSelected(): void
|
||||
{
|
||||
$domainAuthority = 'existing-two.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority($domainAuthority)),
|
||||
]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['1', '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringNotContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
self::assertStringNotContainsString('default-domain.com', $output);
|
||||
self::assertStringContainsString('existing-one.com', $output);
|
||||
self::assertStringContainsString($domainAuthority, $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function aNewDomainCanBeCreatedEvenIfOthersAlreadyExist(): void
|
||||
{
|
||||
$domainAuthority = 'new-domain.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-two.com')),
|
||||
]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['2', $domainAuthority, '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
self::assertStringNotContainsString('default-domain.com', $output);
|
||||
self::assertStringContainsString('existing-one.com', $output);
|
||||
self::assertStringContainsString('existing-two.com', $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -8,8 +8,11 @@ use PHPUnit\Framework\TestCase;
|
|||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
|
@ -26,10 +29,38 @@ class ListDomainsCommandTest extends TestCase
|
|||
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function allDomainsAreProperlyPrinted(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInputsAndOutputs
|
||||
*/
|
||||
public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void
|
||||
{
|
||||
$expectedOutput = <<<OUTPUT
|
||||
$bazDomain = Domain::withAuthority('baz.com');
|
||||
$bazDomain->configureNotFoundRedirects(NotFoundRedirects::withRedirects(
|
||||
null,
|
||||
'https://foo.com/baz-domain/regular',
|
||||
'https://foo.com/baz-domain/invalid',
|
||||
));
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions([
|
||||
'base_url' => 'https://foo.com/default/base',
|
||||
'invalid_short_url' => 'https://foo.com/default/invalid',
|
||||
])),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
|
||||
DomainItem::forExistingDomain($bazDomain),
|
||||
]);
|
||||
|
||||
$this->commandTester->execute($input);
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideInputsAndOutputs(): iterable
|
||||
{
|
||||
$withoutRedirectsOutput = <<<OUTPUT
|
||||
+---------+------------+
|
||||
| Domain | Is default |
|
||||
+---------+------------+
|
||||
|
@ -39,16 +70,27 @@ class ListDomainsCommandTest extends TestCase
|
|||
+---------+------------+
|
||||
|
||||
OUTPUT;
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
new DomainItem('foo.com', true),
|
||||
new DomainItem('bar.com', false),
|
||||
new DomainItem('baz.com', false),
|
||||
]);
|
||||
$withRedirectsOutput = <<<OUTPUT
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| Domain | Is default | "Not found" redirects |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| foo.com | Yes | * Base URL: https://foo.com/default/base |
|
||||
| | | * Regular 404: N/A |
|
||||
| | | * Invalid short URL: https://foo.com/default/invalid |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| bar.com | No | * Base URL: N/A |
|
||||
| | | * Regular 404: N/A |
|
||||
| | | * Invalid short URL: N/A |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| baz.com | No | * Base URL: N/A |
|
||||
| | | * Regular 404: https://foo.com/baz-domain/regular |
|
||||
| | | * Invalid short URL: https://foo.com/baz-domain/invalid |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
OUTPUT;
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
yield 'no args' => [[], $withoutRedirectsOutput];
|
||||
yield 'no show redirects' => [['--show-redirects' => false], $withoutRedirectsOutput];
|
||||
yield 'show redirects' => [['--show-redirects' => true], $withRedirectsOutput];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
112
module/Core/src/Action/Model/QrCodeParams.php
Normal file
112
module/Core/src/Action/Model/QrCodeParams.php
Normal file
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action\Model;
|
||||
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Endroid\QrCode\Writer\SvgWriter;
|
||||
use Endroid\QrCode\Writer\WriterInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
final class QrCodeParams
|
||||
{
|
||||
private const DEFAULT_SIZE = 300;
|
||||
private const MIN_SIZE = 50;
|
||||
private const MAX_SIZE = 1000;
|
||||
|
||||
private function __construct(
|
||||
private int $size,
|
||||
private int $margin,
|
||||
private WriterInterface $writer,
|
||||
private ErrorCorrectionLevelInterface $errorCorrectionLevel
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromRequest(ServerRequestInterface $request): self
|
||||
{
|
||||
$query = $request->getQueryParams();
|
||||
|
||||
return new self(
|
||||
self::resolveSize($request, $query),
|
||||
self::resolveMargin($query),
|
||||
self::resolveWriter($query),
|
||||
self::resolveErrorCorrection($query),
|
||||
);
|
||||
}
|
||||
|
||||
private static function resolveSize(Request $request, array $query): int
|
||||
{
|
||||
// FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
|
||||
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
|
||||
if ($size < self::MIN_SIZE) {
|
||||
return self::MIN_SIZE;
|
||||
}
|
||||
|
||||
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
|
||||
}
|
||||
|
||||
private static function resolveMargin(array $query): int
|
||||
{
|
||||
$margin = $query['margin'] ?? null;
|
||||
if ($margin === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$intMargin = (int) $margin;
|
||||
if ($margin !== (string) $intMargin) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $intMargin < 0 ? 0 : $intMargin;
|
||||
}
|
||||
|
||||
private static function resolveWriter(array $query): WriterInterface
|
||||
{
|
||||
$format = strtolower(trim($query['format'] ?? 'png'));
|
||||
return match ($format) {
|
||||
'svg' => new SvgWriter(),
|
||||
default => new PngWriter(),
|
||||
};
|
||||
}
|
||||
|
||||
private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface
|
||||
{
|
||||
$errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l'));
|
||||
return match ($errorCorrectionLevel) {
|
||||
'h' => new ErrorCorrectionLevelHigh(),
|
||||
'q' => new ErrorCorrectionLevelQuartile(),
|
||||
'm' => new ErrorCorrectionLevelMedium(),
|
||||
default => new ErrorCorrectionLevelLow(), // 'l'
|
||||
};
|
||||
}
|
||||
|
||||
public function size(): int
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function margin(): int
|
||||
{
|
||||
return $this->margin;
|
||||
}
|
||||
|
||||
public function writer(): WriterInterface
|
||||
{
|
||||
return $this->writer;
|
||||
}
|
||||
|
||||
public function errorCorrectionLevel(): ErrorCorrectionLevelInterface
|
||||
{
|
||||
return $this->errorCorrectionLevel;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
20
module/Core/src/Config/NotFoundRedirectConfigInterface.php
Normal file
20
module/Core/src/Config/NotFoundRedirectConfigInterface.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
interface NotFoundRedirectConfigInterface
|
||||
{
|
||||
public function invalidShortUrlRedirect(): ?string;
|
||||
|
||||
public function hasInvalidShortUrlRedirect(): bool;
|
||||
|
||||
public function regular404Redirect(): ?string;
|
||||
|
||||
public function hasRegular404Redirect(): bool;
|
||||
|
||||
public function baseUrlRedirect(): ?string;
|
||||
|
||||
public function hasBaseUrlRedirect(): bool;
|
||||
}
|
34
module/Core/src/Config/NotFoundRedirectResolver.php
Normal file
34
module/Core/src/Config/NotFoundRedirectResolver.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||
|
||||
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
|
||||
{
|
||||
public function __construct(private RedirectResponseHelperInterface $redirectResponseHelper)
|
||||
{
|
||||
}
|
||||
|
||||
public function resolveRedirectResponse(
|
||||
NotFoundType $notFoundType,
|
||||
NotFoundRedirectConfigInterface $config
|
||||
): ?ResponseInterface {
|
||||
return match (true) {
|
||||
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() =>
|
||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||
$this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()),
|
||||
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() =>
|
||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||
$this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()),
|
||||
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
|
||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||
$this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
16
module/Core/src/Config/NotFoundRedirectResolverInterface.php
Normal file
16
module/Core/src/Config/NotFoundRedirectResolverInterface.php
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
|
||||
interface NotFoundRedirectResolverInterface
|
||||
{
|
||||
public function resolveRedirectResponse(
|
||||
NotFoundType $notFoundType,
|
||||
NotFoundRedirectConfigInterface $config
|
||||
): ?ResponseInterface;
|
||||
}
|
59
module/Core/src/Config/NotFoundRedirects.php
Normal file
59
module/Core/src/Config/NotFoundRedirects.php
Normal file
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
final class NotFoundRedirects implements JsonSerializable
|
||||
{
|
||||
private function __construct(
|
||||
private ?string $baseUrlRedirect,
|
||||
private ?string $regular404Redirect,
|
||||
private ?string $invalidShortUrlRedirect,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function withRedirects(
|
||||
?string $baseUrlRedirect,
|
||||
?string $regular404Redirect = null,
|
||||
?string $invalidShortUrlRedirect = null,
|
||||
): self {
|
||||
return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect);
|
||||
}
|
||||
|
||||
public static function withoutRedirects(): self
|
||||
{
|
||||
return new self(null, null, null);
|
||||
}
|
||||
|
||||
public static function fromConfig(NotFoundRedirectConfigInterface $config): self
|
||||
{
|
||||
return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect());
|
||||
}
|
||||
|
||||
public function baseUrlRedirect(): ?string
|
||||
{
|
||||
return $this->baseUrlRedirect;
|
||||
}
|
||||
|
||||
public function regular404Redirect(): ?string
|
||||
{
|
||||
return $this->regular404Redirect;
|
||||
}
|
||||
|
||||
public function invalidShortUrlRedirect(): ?string
|
||||
{
|
||||
return $this->invalidShortUrlRedirect;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'baseUrlRedirect' => $this->baseUrlRedirect,
|
||||
'regular404Redirect' => $this->regular404Redirect,
|
||||
'invalidShortUrlRedirect' => $this->invalidShortUrlRedirect,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()],
|
||||
}) ?? [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
22
module/Core/src/Domain/Spec/IsDomain.php
Normal file
22
module/Core/src/Domain/Spec/IsDomain.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\Filter\Filter;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||
|
||||
class IsDomain extends BaseSpecification
|
||||
{
|
||||
public function __construct(private string $domainId, ?string $context = null)
|
||||
{
|
||||
parent::__construct($context);
|
||||
}
|
||||
|
||||
protected function getSpec(): Filter
|
||||
{
|
||||
return Spec::eq('id', $this->domainId);
|
||||
}
|
||||
}
|
22
module/Core/src/Domain/Spec/IsNotAuthority.php
Normal file
22
module/Core/src/Domain/Spec/IsNotAuthority.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\Filter\Filter;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||
|
||||
class IsNotAuthority extends BaseSpecification
|
||||
{
|
||||
public function __construct(private string $authority, ?string $context = null)
|
||||
{
|
||||
parent::__construct($context);
|
||||
}
|
||||
|
||||
protected function getSpec(): Filter
|
||||
{
|
||||
return Spec::not(Spec::eq('authority', $this->authority));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Validation;
|
||||
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
|
||||
class DomainRedirectsInputFilter extends InputFilter
|
||||
{
|
||||
use Validation\InputFactoryTrait;
|
||||
|
||||
public const DOMAIN = 'domain';
|
||||
public const BASE_URL_REDIRECT = 'baseUrlRedirect';
|
||||
public const REGULAR_404_REDIRECT = 'regular404Redirect';
|
||||
public const INVALID_SHORT_URL_REDIRECT = 'invalidShortUrlRedirect';
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function withData(array $data): self
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$instance->initializeInputs();
|
||||
$instance->setData($data);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
private function initializeInputs(): void
|
||||
{
|
||||
$domain = $this->createInput(self::DOMAIN);
|
||||
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
||||
$this->add($domain);
|
||||
|
||||
$this->add($this->createInput(self::BASE_URL_REDIRECT, false));
|
||||
$this->add($this->createInput(self::REGULAR_404_REDIRECT, false));
|
||||
$this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false));
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -104,7 +104,7 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||
|
||||
try {
|
||||
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ class CloseDbConnectionEventListenerDelegator
|
|||
public function __invoke(
|
||||
ContainerInterface $container,
|
||||
string $name,
|
||||
callable $callback
|
||||
callable $callback,
|
||||
): CloseDbConnectionEventListener {
|
||||
/** @var callable $wrapped */
|
||||
$wrapped = $callback();
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue