Compare commits

...

68 Commits

Author SHA1 Message Date
Alejandro Celaya
60d6314262 Merge pull request #1145 from acelaya-forks/feature/update-cache
Feature/update cache
2021-08-05 17:07:41 +02:00
Alejandro Celaya
eff7445804 Updated changelog 2021-08-05 16:50:50 +02:00
Alejandro Celaya
2bfe21aef4 Documented architectural decission on what component to pick for caching 2021-08-05 16:47:30 +02:00
Alejandro Celaya
6ae0c7dcfc Updated to latest common with symfony/cache support 2021-08-05 14:05:44 +02:00
Alejandro Celaya
883ac1007a Updated to provisional hero-common v4.0 2021-08-04 18:46:19 +02:00
Alejandro Celaya
555e6f804c Merge pull request #1141 from acelaya-forks/feature/update-deps
Feature/update deps
2021-08-04 15:36:28 +02:00
Alejandro Celaya
98c5c7990f Updated changelog 2021-08-04 13:29:33 +02:00
Alejandro Celaya
27dcdb517d Updated dockerfile dependencies 2021-08-04 13:28:14 +02:00
Alejandro Celaya
916d75d161 Updated project dependencies 2021-08-04 13:22:16 +02:00
Alejandro Celaya
57bd16f4f5 Updated test utils lib to v2.2 2021-08-04 11:11:00 +02:00
Alejandro Celaya
444a1756a2 Merge pull request #1140 from acelaya-forks/feature/domain-redirects-endpoint
Feature/domain redirects endpoint
2021-08-03 19:59:54 +02:00
Alejandro Celaya
0c97c8f04f Updated changelog 2021-08-03 19:47:44 +02:00
Alejandro Celaya
de81e81ecb Created API test for Domain redirects 2021-08-03 19:43:30 +02:00
Alejandro Celaya
40a7d5a112 Documented error when trying to edit default domain redirects through endpoint 2021-08-03 18:33:50 +02:00
Alejandro Celaya
7c06633a67 Ensured default domain redirects cannot be edited through regular approach 2021-08-03 18:28:09 +02:00
Alejandro Celaya
9abf611d63 Created DomainResirectsAction unit test 2021-08-03 18:09:39 +02:00
Alejandro Celaya
565fe4c348 Added redirects to the list of domains 2021-08-03 17:00:26 +02:00
Alejandro Celaya
7b43403b1c Fixed error when editing domain redirects for a new domain 2021-08-03 16:48:17 +02:00
Alejandro Celaya
9f25979b4c Added validation to not found redirects for domain 2021-08-03 14:08:36 +02:00
Alejandro Celaya
20f70b8b07 Created new table with row separators for CLI, to use with multi-line rows 2021-08-03 10:21:42 +02:00
Alejandro Celaya
8fbf05acd4 Added deprecated keyword to ensure something is changed for v3.0.0 2021-08-03 10:02:44 +02:00
Alejandro Celaya
6860855c71 Prevent double flush when editing domain redirects 2021-08-03 09:55:21 +02:00
Alejandro Celaya
b78660c685 Updated installer 2021-08-02 20:50:35 +02:00
Alejandro Celaya
6a40bbdcb5 Created new action to set redirects for a domain 2021-08-02 20:49:39 +02:00
Alejandro Celaya
5a1a4f5594 Added support to configure domain redirects but taking into consideration the permissions on an API key 2021-08-02 20:49:39 +02:00
Alejandro Celaya
2ac7be4363 Extended DomainNotFoundException to allow creating from an authority 2021-08-02 20:49:39 +02:00
Alejandro Celaya
4ef5ab7a90 Fixed wrong domains getting resolved for an API key roles 2021-08-02 20:49:39 +02:00
Alejandro Celaya
192308a6a3 Added swagger docs for endpoint do edit domain redirects 2021-08-02 20:49:39 +02:00
Alejandro Celaya
c9ce111643 Fixed merge conflicts 2021-08-02 20:39:33 +02:00
Alejandro Celaya
32fda231ad Merge pull request #1138 from acelaya-forks/feature/fix-import-with-no-visits
Feature/fix import with no visits
2021-08-02 20:34:06 +02:00
Alejandro Celaya
e4d4686717 Ensure visits lists where the page is lower than 1, fall back to page 1 to avoid errors 2021-08-02 20:22:07 +02:00
Alejandro Celaya
ca6c6a1b6e Updated importer to v2.3.1 2021-08-02 18:29:16 +02:00
Alejandro Celaya
806c4ce168 Merge pull request #1134 from acelaya-forks/feature/infection24
Feature/infection24
2021-08-01 10:11:53 +02:00
Alejandro Celaya
9d14597be0 Added --only-covering-test-cases flag when running infection commands 2021-08-01 10:00:24 +02:00
Alejandro Celaya
dc68bb907c Updated infection to v0.24 2021-08-01 09:57:34 +02:00
Alejandro Celaya
e4598c058a Merge pull request #1133 from acelaya-forks/feature/docker-cron-permissions
Disabled user change on Dockerfile, as it produces some issues
2021-08-01 09:11:06 +02:00
Alejandro Celaya
377562cdff Disabled user change on Dockerfile, as it produces some issues 2021-08-01 08:55:39 +02:00
Alejandro Celaya
969fcccc1f Merge pull request #1131 from acelaya-forks/feature/clean-workarounds-from-fix
Removed hardcoded dependency
2021-07-30 18:54:45 +02:00
Alejandro Celaya
4c00764146 Removed hardcoded dependency 2021-07-30 18:40:26 +02:00
Alejandro Celaya
e98ee64695 Merge branch 'main' into develop 2021-07-30 18:25:48 +02:00
Alejandro Celaya
51c7d0ed3e Removed deprecated env var for publish release 2021-07-30 18:25:00 +02:00
Alejandro Celaya
db93498ee6 Fixed merge conflicts 2021-07-30 18:19:32 +02:00
Alejandro Celaya
b3af493758 Merge pull request #1130 from acelaya-forks/feature/docker-memory-limit
Fixed memory too low limit on docker image
2021-07-30 18:16:40 +02:00
Alejandro Celaya
7b9ebbbb5f Fixed use of ImplicitOptionsMiddleware with its new signature 2021-07-30 18:05:03 +02:00
Alejandro Celaya
ea735fc0a0 Ensured guzzle/psr7 1.7 is used as the project still has deprecated calls 2021-07-30 17:48:43 +02:00
Alejandro Celaya
06227e97d0 Fixed memory too low limit on docker image 2021-07-30 17:39:45 +02:00
Alejandro Celaya
dbc50b6d4f Merge pull request #1124 from acelaya-forks/feature/domain-specific-redirects
Feature/domain specific redirects
2021-07-23 18:59:24 +02:00
Alejandro Celaya
8b75ad1e7f Covered detached domains with redirects in domains list API test 2021-07-23 13:11:09 +02:00
Alejandro Celaya
8f3c740b57 Ensured domains not used in short URLs but with redirects configured are returned in domains list 2021-07-23 13:06:03 +02:00
Alejandro Celaya
24a6a0c23f Added test for DomainRedirectCommand 2021-07-22 20:48:58 +02:00
Alejandro Celaya
267d72a76c Improved unit tests covering new not found redirects for domains capability 2021-07-22 17:49:37 +02:00
Alejandro Celaya
021cecc216 Created command that allows configuring not found redirects for every domain 2021-07-21 21:09:33 +02:00
Alejandro Celaya
4642480bbb Updated changelog 2021-07-21 09:41:58 +02:00
Alejandro Celaya
4d48482d1e Added support to define differnet not-found redirects per domain 2021-07-21 09:28:21 +02:00
Alejandro Celaya
2054784a4a Merge pull request #1123 from acelaya-forks/feature/match-in-db-tests
Replaced map with match
2021-07-20 14:04:19 +02:00
Alejandro Celaya
57d816b862 Replaced map with match 2021-07-20 14:03:19 +02:00
Alejandro Celaya
32bb66c42b Merge pull request #1122 from acelaya-forks/feature/phpstan-level
Feature/phpstan level
2021-07-20 14:01:45 +02:00
Alejandro Celaya
e4d15e64b6 Ensured static analysis is run with APP_ENV=test 2021-07-20 13:50:14 +02:00
Alejandro Celaya
b11daeae7d Fixed version constraint in composer.json 2021-07-20 13:41:55 +02:00
Alejandro Celaya
8e78f8527e Updated changelog 2021-07-20 13:37:00 +02:00
Alejandro Celaya
bc385744db Temporarely ignored some phpstan errors until a custom rule is defined 2021-07-20 13:36:09 +02:00
Alejandro Celaya
02fd28edec Installed phpstan-dcotrine and fixed more static analysis errors 2021-07-20 13:29:50 +02:00
Alejandro Celaya
95770ac104 Increased phpstan level to 8 2021-07-20 12:51:07 +02:00
Alejandro Celaya
2eeb762cd9 Moved specific phpstan ignore to their own lines 2021-07-19 22:50:32 +02:00
Alejandro Celaya
de5666d262 Resolved all phpstan errors 2021-07-19 22:47:12 +02:00
Alejandro Celaya
934d266880 Added phpstan-symfony plugin to improve inspections on getArgument and getOption 2021-07-19 20:00:53 +02:00
Alejandro Celaya
b8fa234dbb Fixed some phpstan errors 2021-07-19 18:35:42 +02:00
Alejandro Celaya
bceea090ed Increaed phpstan level to 7 2021-07-17 20:58:24 +02:00
121 changed files with 2392 additions and 413 deletions

View File

@@ -6,19 +6,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased]
### Added
* *Nothing*
### Changed
* [#1142](https://github.com/shlinkio/shlink/issues/1142) Replaced `doctrine/cache` package with `symfony/cache`.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1098](https://github.com/shlinkio/shlink/issues/1098) Fixed errors when using a Redis Cluster for caching, caused by `doctrine/cache` not fully supporting clusters.
## [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.
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`.
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.
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
* *Nothing*
* [#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*
@@ -30,6 +53,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* *Nothing*
## [2.7.3] - 2021-08-02
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1135](https://github.com/shlinkio/shlink/issues/1135) Fixed error when importing short URLs with no visits from another Shlink instance.
* [#1136](https://github.com/shlinkio/shlink/issues/1136) Fixed error when fetching tag/short-url/orphan visits for a page lower than 1.
## [2.7.2] - 2021-07-30
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1128](https://github.com/shlinkio/shlink/issues/1128) Increased memory limit reserved for the docker image, preventing it from crashing on GeoLite db download.
## [2.7.1] - 2021-05-30
### Added
* *Nothing*

View File

@@ -1,8 +1,8 @@
FROM php:8.0.6-alpine3.13 as base
FROM php:8.0.9-alpine3.14 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.6.7
ENV SWOOLE_VERSION 4.7.0
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.1
ENV LC_ALL "C"
@@ -79,12 +79,13 @@ COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.l
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
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
# FIXME Disabled for now, as it conflicts with ENABLE_PERIODIC_VISIT_LOCATE, which is used to configure a cron as root.
# Ref: https://github.com/shlinkio/shlink/issues/1132
#RUN chown 1001 /etc/shlink/data
#RUN chown 1001 /etc/shlink/data/locks
#RUN chown 1001 /etc/shlink/data/proxies
#RUN chown 1001 /etc/shlink/data/cache
#RUN chown 1001 /etc/shlink/data/log
#USER 1001
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View File

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

View File

@@ -16,64 +16,65 @@
"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",
"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-common": "dev-main#2832a4a as 4.0",
"shlinkio/shlink-config": "^1.2",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3",
"shlinkio/shlink-installer": "dev-develop#fa6a4ca as 6.1",
"shlinkio/shlink-importer": "^2.3.1",
"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": {
@@ -112,7 +113,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",
@@ -134,7 +135,7 @@
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db",

View File

@@ -3,7 +3,7 @@
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Shlinkio\Shlink\Common\Cache\RedisFactory;
use Predis\ClientInterface as PredisClient;
use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Symfony\Component\Lock;
@@ -42,7 +42,7 @@ return [
ConfigAbstractFactory::class => [
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
Lock\Store\RedisStore::class => [PredisClient::class],
Lock\LockFactory::class => ['lock_store'],
LOCAL_LOCK_FACTORY => ['local_lock_store'],
],

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
log_errors_max_len=0
zend.assertions=1
assert.exception=1
memory_limit=256M

View File

@@ -25,7 +25,7 @@ fi
# 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 "Starting periodic visite locate..."
echo "Configuring periodic visit locate..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi

View File

@@ -0,0 +1,59 @@
# Migrate to a new caching library
* Status: Accepted
* Date: 2021-08-05
## Context and problem statement
Shlink has always used the `doctrine/cache` library to handle anything related with cache.
It was convenient, as it provided several adapters, and it was the library used by other doctrine packages.
However, after the creation of the caching PSRs ([PSR-6 - Cache](https://www.php-fig.org/psr/psr-6) and [PSR-16 - Simple cache](https://www.php-fig.org/psr/psr-16)), most library authors have moved to those interfaces, and the doctrine team has decided to recommend using any other existing package and decommission their own solution.
Also, Shlink needs support for Redis clusters and Redis sentinels, which is not supported by `doctrine/cache` Redis adapters.
## Considered option
After some research, the only packages that seem to support the capabilities required by Shlink and also seem healthy, are these:
* [Symfony cache](https://symfony.com/doc/current/components/cache.html)
* 🟢 PSR-6 compliant: **yes**
* 🟢 PSR-16 compliant: **yes**
* 🟢 APCu support: **yes**
* 🟢 Redis support: **yes**
* 🟢 Redis cluster support: **yes**
* 🟢 Redis sentinel support: **yes**
* 🟢 Can use redis through Predis: **yes**
* 🔴 Individual packages per adapter: **no**
* [Laminas cache](https://docs.laminas.dev/laminas-cache/)
* 🟢 PSR-6 compliant: **yes**
* 🟢 PSR-16 compliant: **yes**
* 🟢 APCu support: **yes**
* 🟢 Redis support: **yes**
* 🟢 Redis cluster support: **yes**
* 🔴 Redis sentinel support: **no**
* 🔴 Can use redis through Predis: **no**
* 🟢 Individual packages per adapter: **yes**
## Decision outcome
Even though Symfony packs all their adapters in a single component, which means we will install some code that will never be used, Laminas relies on the native redis extension for anything related with redis.
That would make Shlink more complex to install, so it seems Symfony's package is the option where it's easier to migrate to.
Also, it's important that the cache component can share the Redis integration (through `Predis`, in this case), as it's also used by other components (the lock component, to name one).
## Pros and Cons of the Options
### Symfony cache
* Good because it supports Redis Sentinel.
* Good because it allows using a external `Predis` instance.
* Bad because it packs all the adapters in a single component.
### Laminas cache
* Good because allows installing only the adapters you are going to use, through separated packages.
* Bad because it requires the php-redis native extension in order to interact with Redis.
* Bad because it does ot seem to support Redis Sentinels.

View File

@@ -2,5 +2,6 @@
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
* [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md)
* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md)
* [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ 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
{
public function __construct(private DomainServiceInterface $domainService)
@@ -23,7 +25,7 @@ class RoleResolver implements RoleResolverInterface
if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
}
if ($domainAuthority !== null) {
if (is_string($domainAuthority)) {
$domain = $this->domainService->getOrCreate($domainAuthority);
$roleDefinitions[] = RoleDefinition::forDomain($domain);
}

View File

@@ -97,7 +97,7 @@ class GenerateKeyCommand extends BaseCommand
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
if (! $apiKey->isAdmin()) {
ShlinkTable::fromOutput($io)->render(
ShlinkTable::default($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
null,

View File

@@ -69,7 +69,7 @@ class ListKeysCommand extends BaseCommand
return $rowData;
});
ShlinkTable::fromOutput($output)->render(array_filter([
ShlinkTable::withRowSeparators($output)->render(array_filter([
'Key',
'Name',
! $enabledOnly ? 'Is enabled' : null,

View File

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

View File

@@ -32,6 +32,6 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
protected function getLockConfig(): LockedCommandConfig
{
return LockedCommandConfig::blocking($this->getName());
return LockedCommandConfig::blocking($this->getName() ?? static::class);
}
}

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
@@ -21,6 +20,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetVisitsCommand extends AbstractWithDateRangeCommand
@@ -73,7 +73,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$paginator = $this->visitsHelper->visitsForShortUrl(
$identifier,
new VisitsParams(new DateRange($startDate, $endDate)),
new VisitsParams(buildDateRange($startDate, $endDate)),
);
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
@@ -81,7 +81,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
});
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
return ExitCodes::EXIT_SUCCESS;
}

View File

@@ -164,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'),

View File

@@ -32,7 +32,7 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return ExitCodes::EXIT_SUCCESS;
}

View File

@@ -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;
}

View File

@@ -45,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) {

View File

@@ -139,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>]');
@@ -168,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) {
@@ -178,6 +183,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
protected function getLockConfig(): LockedCommandConfig
{
return LockedCommandConfig::nonBlocking($this->getName());
return LockedCommandConfig::nonBlocking(self::NAME);
}
}

View File

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

View File

@@ -5,20 +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 </>';
public function __construct(private Table $baseTable)
private function __construct(private Table $baseTable, private bool $withRowSeparators)
{
}
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
@@ -26,11 +39,12 @@ final class ShlinkTable
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
$tableRows = $this->withRowSeparators ? intersperse($rows, new TableSeparator()) : $rows;
$table = clone $this->baseTable;
$table->setStyle($style)
->setHeaders($headers)
->setRows($rows)
->setRows($tableRows)
->setFooterTitle($footerTitle)
->setHeaderTitle($headerTitle)
->render();

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(null, null)),
new VisitsParams(DateRange::emptyInstance()),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
@@ -61,7 +61,7 @@ class GetVisitsCommandTest extends TestCase
$endDate = '2016-02-01';
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
new VisitsParams(DateRange::withStartAndEndDate(Chronos::parse($startDate), Chronos::parse($endDate))),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
@@ -80,7 +80,7 @@ class GetVisitsCommandTest extends TestCase
$startDate = 'foo';
$info = $this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange()),
new VisitsParams(DateRange::emptyInstance()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([

View File

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

View File

@@ -46,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,
@@ -75,7 +77,8 @@ return [
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'],
@@ -112,12 +115,18 @@ 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\RequestTracker::class,

View File

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

View File

@@ -16,6 +16,7 @@ use function Functional\reduce_left;
use function is_array;
use function lcfirst;
use function print_r;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
use function str_repeat;
use function str_replace;
@@ -51,12 +52,7 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
$startDate = parseDateFromQuery($query, $startDateName);
$endDate = parseDateFromQuery($query, $endDateName);
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),
};
return buildDateRange($startDate, $endDate);
}
/**

View File

@@ -23,6 +23,7 @@ class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
public function handle(ServerRequestInterface $request): ResponseInterface
{
// @phpstan-ignore-next-line The "Response" phpdoc is wrong
return new Response(self::STATUS_OK, ['Content-type' => 'text/plain'], $this->buildRobots());
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,8 +19,11 @@ use function Functional\map;
class DomainService implements DomainServiceInterface
{
public function __construct(private EntityManagerInterface $em, private string $defaultDomain)
{
public function __construct(
private EntityManagerInterface $em,
private string $defaultDomain,
private NotFoundRedirectOptions $redirectOptions,
) {
}
/**
@@ -28,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?->hasRole(Role::DOMAIN_SPECIFIC)) {
return $mappedDomains;
}
return [
new DomainItem($this->defaultDomain, true),
DomainItem::forDefaultDomain($this->defaultDomain, $this->redirectOptions),
...$mappedDomains,
];
}
@@ -54,15 +60,58 @@ class DomainService implements DomainServiceInterface
return $domain;
}
public function getOrCreate(string $authority): Domain
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
{
$repo = $this->em->getRepository(Domain::class);
/** @var Domain|null $domain */
$domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority);
return $repo->findOneByAuthority($authority, $apiKey);
}
$this->em->persist($domain);
/**
* @throws DomainNotFoundException
*/
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain
{
$domain = $this->getPersistedDomain($authority, $apiKey);
$this->em->flush();
return $domain;
}
/**
* @throws DomainNotFoundException
* @throws InvalidDomainException
*/
public function configureNotFoundRedirects(
string $authority,
NotFoundRedirects $notFoundRedirects,
?ApiKey $apiKey = null
): Domain {
if ($authority === $this->defaultDomain) {
throw InvalidDomainException::forDefaultDomainRedirects();
}
$domain = $this->getPersistedDomain($authority, $apiKey);
$domain->configureNotFoundRedirects($notFoundRedirects);
$this->em->flush();
return $domain;
}
/**
* @throws DomainNotFoundException
*/
private function getPersistedDomain(string $authority, ?ApiKey $apiKey): Domain
{
$domain = $this->findByAuthority($authority, $apiKey);
if ($domain === null && $apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
// This API key is restricted to one domain and a different one was tried to be fetched
throw DomainNotFoundException::fromAuthority($authority);
}
$domain = $domain ?? Domain::withAuthority($authority);
$this->em->persist($domain);
return $domain;
}
}

View File

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

View File

@@ -5,28 +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
{
public function __construct(private string $domain, private bool $isDefault)
private function __construct(
private string $authority,
private NotFoundRedirectConfigInterface $notFoundRedirectConfig,
private bool $isDefault
) {
}
public static function forExistingDomain(Domain $domain): self
{
return new self($domain->getAuthority(), $domain, false);
}
public static function forDefaultDomain(string $authority, NotFoundRedirectConfigInterface $config): self
{
return new self($authority, $config, true);
}
public function jsonSerialize(): array
{
return [
'domain' => $this->domain,
'domain' => $this->authority,
'isDefault' => $this->isDefault,
'redirects' => NotFoundRedirects::fromConfig($this->notFoundRedirectConfig),
];
}
public function toString(): string
{
return $this->domain;
return $this->authority;
}
public function isDefault(): bool
{
return $this->isDefault;
}
public function notFoundRedirectConfig(): NotFoundRedirectConfigInterface
{
return $this->notFoundRedirectConfig;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,13 +6,24 @@ 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
{
public function __construct(private string $authority)
private ?string $baseUrlRedirect = null;
private ?string $regular404Redirect = null;
private ?string $invalidShortUrlRedirect = null;
private function __construct(private string $authority)
{
}
public static function withAuthority(string $authority): self
{
return new self($authority);
}
public function getAuthority(): string
{
return $this->authority;
@@ -22,4 +33,41 @@ class Domain extends AbstractEntity implements JsonSerializable
{
return $this->getAuthority();
}
public function invalidShortUrlRedirect(): ?string
{
return $this->invalidShortUrlRedirect;
}
public function hasInvalidShortUrlRedirect(): bool
{
return $this->invalidShortUrlRedirect !== null;
}
public function regular404Redirect(): ?string
{
return $this->regular404Redirect;
}
public function hasRegular404Redirect(): bool
{
return $this->regular404Redirect !== null;
}
public function baseUrlRedirect(): ?string
{
return $this->baseUrlRedirect;
}
public function hasBaseUrlRedirect(): bool
{
return $this->baseUrlRedirect !== null;
}
public function configureNotFoundRedirects(NotFoundRedirects $redirects): void
{
$this->baseUrlRedirect = $redirects->baseUrlRedirect();
$this->regular404Redirect = $redirects->regular404Redirect();
$this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect();
}
}

View File

@@ -8,15 +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\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
{
public function __construct(
private Options\NotFoundRedirectOptions $redirectOptions,
private RedirectResponseHelperInterface $redirectResponseHelper
private NotFoundRedirectResolverInterface $redirectResolver,
private DomainServiceInterface $domainService,
) {
}
@@ -24,23 +26,17 @@ class NotFoundRedirectHandler implements MiddlewareInterface
{
/** @var NotFoundType $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
$authority = $request->getUri()->getAuthority();
$domainSpecificRedirect = $this->resolveDomainSpecificRedirect($authority, $notFoundType);
if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) {
return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect());
}
return $domainSpecificRedirect
?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions)
?? $handler->handle($request);
}
if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) {
return $this->redirectResponseHelper->buildRedirectResponse(
$this->redirectOptions->getRegular404Redirect(),
);
}
if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) {
return $this->redirectResponseHelper->buildRedirectResponse(
$this->redirectOptions->getInvalidShortUrlRedirect(),
);
}
return $handler->handle($request);
private function resolveDomainSpecificRedirect(string $authority, NotFoundType $notFoundType): ?ResponseInterface
{
$domain = $this->domainService->findByAuthority($authority);
return $domain === null ? null : $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain);
}
}

View File

@@ -55,7 +55,7 @@ class LocateVisit
}
$isLocatable = $originalIpAddress !== null || $visit->isLocatable();
$addr = $originalIpAddress ?? $visit->getRemoteAddr();
$addr = $originalIpAddress ?? $visit->getRemoteAddr() ?? '';
try {
$location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance();

View File

@@ -15,7 +15,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Cannot delete short URL';
private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION
private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Deprecated: Should be INVALID_SHORT_URL_DELETION
public static function fromVisitsThreshold(int $threshold, string $shortCode): self
{

View File

@@ -17,16 +17,27 @@ class DomainNotFoundException extends DomainException implements ProblemDetailsE
private const TITLE = 'Domain not found';
private const TYPE = 'DOMAIN_NOT_FOUND';
private function __construct(string $message, array $additional)
{
parent::__construct($message);
$this->detail = $message;
$this->title = self::TITLE;
$this->type = self::TYPE;
$this->status = StatusCodeInterface::STATUS_NOT_FOUND;
$this->additional = $additional;
}
public static function fromId(string $id): self
{
$e = new self(sprintf('Domain with id "%s" could not be found', $id));
return new self(sprintf('Domain with id "%s" could not be found', $id), ['id' => $id]);
}
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
$e->additional = ['id' => $id];
return $e;
public static function fromAuthority(string $authority): self
{
return new self(
sprintf('Domain with authority "%s" could not be found', $authority),
['authority' => $authority],
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
class InvalidDomainException extends DomainException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Invalid domain';
private const TYPE = 'INVALID_DOMAIN';
private function __construct(string $message)
{
parent::__construct($message);
}
public static function forDefaultDomainRedirects(): self
{
$e = new self('You cannot configure default domain\'s redirects this way. Use the configuration or env vars.');
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_FORBIDDEN;
return $e;
}
}

View File

@@ -28,7 +28,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private ShortCodeHelperInterface $shortCodeHelper,
private DoctrineBatchHelperInterface $batchHelper
) {
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class);
}
/**

View File

@@ -42,7 +42,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
public function newShortUrlVisitUpdate(Visit $visit): Update
{
$shortUrl = $visit->getShortUrl();
$topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode());
$topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode());
return new Update($topic, $this->serialize([
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),

View File

@@ -32,7 +32,11 @@ final class ShortUrlIdentifier
public static function fromCli(InputInterface $input): self
{
// Using getArguments and getOptions instead of getArgument(...) and getOption(...) because
// the later throw an exception if requested options are not defined
/** @var string $shortCode */
$shortCode = $input->getArguments()['shortCode'] ?? '';
/** @var string|null $domain */
$domain = $input->getOptions()['domain'] ?? null;
return new self($shortCode, $domain);

View File

@@ -49,7 +49,6 @@ final class ShortUrlsOrdering
]);
}
/** @var string|array $orderBy */
if (! $isArray) {
[$field, $dir] = array_pad(explode('-', $orderBy), 2, null);
$this->orderField = $field;

View File

@@ -8,6 +8,7 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\parseDateField;
final class ShortUrlsParams
@@ -15,11 +16,11 @@ final class ShortUrlsParams
public const DEFAULT_ITEMS_PER_PAGE = 10;
private int $page;
private int $itemsPerPage;
private ?string $searchTerm;
private array $tags;
private ShortUrlsOrdering $orderBy;
private ?DateRange $dateRange;
private ?int $itemsPerPage = null;
private function __construct()
{
@@ -54,7 +55,7 @@ final class ShortUrlsParams
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
$this->dateRange = new DateRange(
$this->dateRange = buildDateRange(
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
);

View File

@@ -29,13 +29,16 @@ final class Visitor
$this->userAgent = $this->cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH);
$this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH);
$this->visitedUrl = $this->cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH);
$this->remoteAddress = $this->cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH);
$this->remoteAddress = $remoteAddress === null ? null : $this->cropToLength(
$remoteAddress,
self::REMOTE_ADDRESS_MAX_LENGTH,
);
$this->potentialBot = isCrawler($userAgent);
}
private function cropToLength(?string $value, int $length): ?string
private function cropToLength(string $value, int $length): string
{
return $value === null ? null : substr($value, 0, $length);
return substr($value, 0, $length);
}
public static function fromRequest(ServerRequestInterface $request): self

View File

@@ -13,19 +13,26 @@ final class VisitsParams
private const FIRST_PAGE = 1;
private const ALL_ITEMS = -1;
private ?DateRange $dateRange;
private DateRange $dateRange;
private int $page;
private int $itemsPerPage;
public function __construct(
?DateRange $dateRange = null,
private int $page = self::FIRST_PAGE,
int $page = self::FIRST_PAGE,
?int $itemsPerPage = null,
private bool $excludeBots = false
) {
$this->dateRange = $dateRange ?? new DateRange();
$this->dateRange = $dateRange ?? DateRange::emptyInstance();
$this->page = $this->determinePage($page);
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
}
private function determinePage(int $page): int
{
return $page > 0 ? $page : self::FIRST_PAGE;
}
private function determineItemsPerPage(?int $itemsPerPage): int
{
if ($itemsPerPage !== null && $itemsPerPage < 0) {
@@ -39,7 +46,7 @@ final class VisitsParams
{
return new self(
parseDateRangeFromQuery($query, 'startDate', 'endDate'),
(int) ($query['page'] ?? 1),
(int) ($query['page'] ?? self::FIRST_PAGE),
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
isset($query['excludeBots']),
);

View File

@@ -5,14 +5,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
class NotFoundRedirectOptions extends AbstractOptions
class NotFoundRedirectOptions extends AbstractOptions implements NotFoundRedirectConfigInterface
{
private ?string $invalidShortUrl = null;
private ?string $regular404 = null;
private ?string $baseUrl = null;
public function getInvalidShortUrlRedirect(): ?string
public function invalidShortUrlRedirect(): ?string
{
return $this->invalidShortUrl;
}
@@ -28,7 +29,7 @@ class NotFoundRedirectOptions extends AbstractOptions
return $this;
}
public function getRegular404Redirect(): ?string
public function regular404Redirect(): ?string
{
return $this->regular404;
}
@@ -44,7 +45,7 @@ class NotFoundRedirectOptions extends AbstractOptions
return $this;
}
public function getBaseUrlRedirect(): ?string
public function baseUrlRedirect(): ?string
{
return $this->baseUrl;
}

View File

@@ -18,7 +18,6 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use function array_column;
use function array_key_exists;
use function count;
use function Functional\contains;
@@ -59,6 +58,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
// visitsCount and visitCount are deprecated. Only visits should work
if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
// FIXME This query is inefficient. Debug it.
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
->groupBy('s')
@@ -75,9 +75,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
'dateCreated' => 'dateCreated',
'title' => 'title',
];
if (array_key_exists($fieldName, $fieldNameMap)) {
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
$resolvedFieldName = $fieldNameMap[$fieldName] ?? null;
if ($resolvedFieldName !== null) {
$qb->orderBy('s.' . $resolvedFieldName, $order);
}
return $qb->getQuery()->getResult();
}
@@ -103,13 +105,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->from(ShortUrl::class, 's')
->where('1=1');
if ($dateRange?->getStartDate() !== null) {
if ($dateRange?->startDate() !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
$qb->setParameter('startDate', $dateRange->startDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($dateRange?->getEndDate() !== null) {
if ($dateRange?->endDate() !== null) {
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
$qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME);
$qb->setParameter('endDate', $dateRange->endDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}
// Apply search term to every searchable field if not empty
@@ -194,10 +196,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?int $lockMode): bool
{
$qb = $this->createFindOneQueryBuilder($identifier, $spec);
$qb->select('s.id');
$qb = $this->createFindOneQueryBuilder($identifier, $spec)->select('s.id');
$query = $qb->getQuery();
$query = $qb->getQuery()->setLockMode($lockMode);
if ($lockMode !== null) {
$query = $query->setLockMode($lockMode);
}
return $query->getOneOrNullResult() !== null;
}

View File

@@ -187,11 +187,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
{
if ($dateRange?->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
if ($dateRange?->startDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->startDate()->toDateTimeString() . '\''));
}
if ($dateRange?->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
if ($dateRange?->endDate() !== null) {
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->endDate()->toDateTimeString() . '\''));
}
}

View File

@@ -41,7 +41,9 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
private function memoizeNewDomain(string $domain): Domain
{
return $this->memoizedNewDomains[$domain] = $this->memoizedNewDomains[$domain] ?? new Domain($domain);
return $this->memoizedNewDomains[$domain] = $this->memoizedNewDomains[$domain] ?? Domain::withAuthority(
$domain,
);
}
/**

View File

@@ -15,7 +15,7 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac
{
public function resolveDomain(?string $domain): ?Domain
{
return $domain !== null ? new Domain($domain) : null;
return $domain !== null ? Domain::withAuthority($domain) : null;
}
/**

View File

@@ -11,13 +11,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class BelongsToApiKey extends BaseSpecification
{
public function __construct(private ApiKey $apiKey, private ?string $dqlAlias = null)
public function __construct(private ApiKey $apiKey, ?string $context = null)
{
parent::__construct();
parent::__construct($context);
}
protected function getSpec(): Filter
{
return Spec::eq('authorApiKey', $this->apiKey, $this->dqlAlias);
return Spec::eq('authorApiKey', $this->apiKey);
}
}

View File

@@ -13,7 +13,7 @@ class BelongsToDomainInlined implements Filter
{
}
public function getFilter(QueryBuilder $qb, string $dqlAlias): string
public function getFilter(QueryBuilder $qb, string $context): string
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\'');

View File

@@ -20,12 +20,12 @@ class InDateRange extends BaseSpecification
{
$criteria = [];
if ($this->dateRange?->getStartDate() !== null) {
$criteria[] = Spec::gte($this->field, $this->dateRange->getStartDate()->toDateTimeString());
if ($this->dateRange?->startDate() !== null) {
$criteria[] = Spec::gte($this->field, $this->dateRange->startDate()->toDateTimeString());
}
if ($this->dateRange?->getEndDate() !== null) {
$criteria[] = Spec::lte($this->field, $this->dateRange->getEndDate()->toDateTimeString());
if ($this->dateRange?->endDate() !== null) {
$criteria[] = Spec::lte($this->field, $this->dateRange->endDate()->toDateTimeString());
}
return Spec::andX(...$criteria);

View File

@@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Core\Util;
use Cocur\Slugify\SlugifyInterface;
use Symfony\Component\String\AbstractUnicodeString;
use Symfony\Component\String\Slugger\SluggerInterface;
use function Symfony\Component\String\s;
use Symfony\Component\String\UnicodeString;
class CocurSymfonySluggerBridge implements SluggerInterface
{
@@ -18,6 +17,6 @@ class CocurSymfonySluggerBridge implements SluggerInterface
public function slug(string $string, string $separator = '-', ?string $locale = null): AbstractUnicodeString
{
return s($this->slugger->slugify($string, $separator));
return new UnicodeString($this->slugger->slugify($string, $separator));
}
}

View File

@@ -75,7 +75,7 @@ class ShortUrlInputFilter extends InputFilter
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([
'regexp' => CUSTOM_SLUGS_REGEXP,
'lowercase' => false, // We want to keep it case sensitive
'lowercase' => false, // We want to keep it case-sensitive
'rulesets' => ['default'],
]))));
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([

View File

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Domain\Repository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@@ -26,51 +27,83 @@ class DomainRepositoryTest extends DatabaseTestCase
}
/** @test */
public function findDomainsReturnsExpectedResult(): void
public function expectedDomainsAreFoundWhenNoApiKeyIsInvolved(): void
{
$fooDomain = new Domain('foo.com');
$fooDomain = Domain::withAuthority('foo.com');
$this->getEntityManager()->persist($fooDomain);
$this->getEntityManager()->persist($this->createShortUrl($fooDomain));
$barDomain = new Domain('bar.com');
$barDomain = Domain::withAuthority('bar.com');
$this->getEntityManager()->persist($barDomain);
$this->getEntityManager()->persist($this->createShortUrl($barDomain));
$bazDomain = new Domain('baz.com');
$bazDomain = Domain::withAuthority('baz.com');
$this->getEntityManager()->persist($bazDomain);
$this->getEntityManager()->persist($this->createShortUrl($bazDomain));
$detachedDomain = new Domain('detached.com');
$detachedDomain = Domain::withAuthority('detached.com');
$this->getEntityManager()->persist($detachedDomain);
$detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com');
$detachedWithRedirects->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com'));
$this->getEntityManager()->persist($detachedWithRedirects);
$this->getEntityManager()->flush();
self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout(null));
self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com'));
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com'));
self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com'));
self::assertEquals(
[$barDomain, $bazDomain, $detachedWithRedirects, $fooDomain],
$this->repo->findDomainsWithout(null),
);
self::assertEquals(
[$barDomain, $bazDomain, $detachedWithRedirects],
$this->repo->findDomainsWithout('foo.com'),
);
self::assertEquals(
[$bazDomain, $detachedWithRedirects, $fooDomain],
$this->repo->findDomainsWithout('bar.com'),
);
self::assertEquals(
[$barDomain, $detachedWithRedirects, $fooDomain],
$this->repo->findDomainsWithout('baz.com'),
);
self::assertEquals(
[$barDomain, $bazDomain, $fooDomain],
$this->repo->findDomainsWithout('detached-with-redirects.com'),
);
self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com'));
self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com'));
self::assertNull($this->repo->findOneByAuthority('does-not-exist.com'));
self::assertEquals($detachedDomain, $this->repo->findOneByAuthority('detached.com'));
}
/** @test */
public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void
public function expectedDomainsAreFoundWhenApiKeyIsProvided(): void
{
$authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
$this->getEntityManager()->persist($authorApiKey);
$authorAndDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
$this->getEntityManager()->persist($authorAndDomainApiKey);
$fooDomain = new Domain('foo.com');
$fooDomain = Domain::withAuthority('foo.com');
$this->getEntityManager()->persist($fooDomain);
$this->getEntityManager()->persist($this->createShortUrl($fooDomain, $authorApiKey));
$barDomain = new Domain('bar.com');
$barDomain = Domain::withAuthority('bar.com');
$this->getEntityManager()->persist($barDomain);
$this->getEntityManager()->persist($this->createShortUrl($barDomain, $authorAndDomainApiKey));
$bazDomain = new Domain('baz.com');
$bazDomain = Domain::withAuthority('baz.com');
$this->getEntityManager()->persist($bazDomain);
$this->getEntityManager()->persist($this->createShortUrl($bazDomain, $authorApiKey));
$detachedDomain = Domain::withAuthority('detached.com');
$this->getEntityManager()->persist($detachedDomain);
$detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com');
$detachedWithRedirects->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com'));
$this->getEntityManager()->persist($detachedWithRedirects);
$this->getEntityManager()->flush();
$authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain));
@@ -79,14 +112,32 @@ class DomainRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($fooDomainApiKey);
$barDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($barDomain)));
$this->getEntityManager()->persist($fooDomainApiKey);
$this->getEntityManager()->persist($barDomainApiKey);
$detachedWithRedirectsApiKey = ApiKey::fromMeta(
ApiKeyMeta::withRoles(RoleDefinition::forDomain($detachedWithRedirects)),
);
$this->getEntityManager()->persist($detachedWithRedirectsApiKey);
$this->getEntityManager()->flush();
self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey));
self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey));
self::assertEquals(
[$detachedWithRedirects],
$this->repo->findDomainsWithout(null, $detachedWithRedirectsApiKey),
);
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey));
self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey));
self::assertEquals($fooDomain, $this->repo->findOneByAuthority('foo.com', $authorApiKey));
self::assertNull($this->repo->findOneByAuthority('bar.com', $authorApiKey));
self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com', $barDomainApiKey));
self::assertEquals(
$detachedWithRedirects,
$this->repo->findOneByAuthority('detached-with-redirects.com', $detachedWithRedirectsApiKey),
);
self::assertNull($this->repo->findOneByAuthority('foo.com', $detachedWithRedirectsApiKey));
}
private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl

View File

@@ -133,16 +133,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
self::assertCount(3, $result);
self::assertSame($bar, $result[0]);
$result = $this->repo->findList(null, null, null, [], null, new DateRange(null, Chronos::now()->subDays(2)));
$result = $this->repo->findList(null, null, null, [], null, DateRange::withEndDate(Chronos::now()->subDays(2)));
self::assertCount(1, $result);
self::assertEquals(1, $this->repo->countList(null, [], new DateRange(null, Chronos::now()->subDays(2))));
self::assertEquals(1, $this->repo->countList(null, [], DateRange::withEndDate(Chronos::now()->subDays(2))));
self::assertSame($foo2, $result[0]);
self::assertCount(
2,
$this->repo->findList(null, null, null, [], null, new DateRange(Chronos::now()->subDays(2))),
$this->repo->findList(null, null, null, [], null, DateRange::withStartDate(Chronos::now()->subDays(2))),
);
self::assertEquals(2, $this->repo->countList(null, [], new DateRange(Chronos::now()->subDays(2))));
self::assertEquals(2, $this->repo->countList(null, [], DateRange::withStartDate(Chronos::now()->subDays(2))));
}
/** @test */
@@ -340,9 +340,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
{
$start = Chronos::parse('2020-03-05 20:18:30');
$wrongDomain = new Domain('wrong.com');
$wrongDomain = Domain::withAuthority('wrong.com');
$this->getEntityManager()->persist($wrongDomain);
$rightDomain = new Domain('right.com');
$rightDomain = Domain::withAuthority('right.com');
$this->getEntityManager()->persist($rightDomain);
$this->getEntityManager()->flush();

View File

@@ -97,7 +97,7 @@ class TagRepositoryTest extends DatabaseTestCase
/** @test */
public function tagExistsReturnsExpectedResultBasedOnApiKey(): void
{
$domain = new Domain('foo.com');
$domain = Domain::withAuthority('foo.com');
$this->getEntityManager()->persist($domain);
$this->getEntityManager()->flush();

View File

@@ -222,7 +222,7 @@ class VisitRepositoryTest extends DatabaseTestCase
/** @test */
public function countVisitsReturnsExpectedResultBasedOnApiKey(): void
{
$domain = new Domain('foo.com');
$domain = Domain::withAuthority('foo.com');
$this->getEntityManager()->persist($domain);
$this->getEntityManager()->flush();

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Config;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\Diactoros\Uri;
use Mezzio\Router\Route;
use Mezzio\Router\RouteResult;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolver;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
class NotFoundRedirectResolverTest extends TestCase
{
use ProphecyTrait;
private NotFoundRedirectResolver $resolver;
private ObjectProphecy $helper;
private NotFoundRedirectConfigInterface $config;
protected function setUp(): void
{
$this->helper = $this->prophesize(RedirectResponseHelperInterface::class);
$this->resolver = new NotFoundRedirectResolver($this->helper->reveal());
$this->config = new NotFoundRedirectOptions([
'invalidShortUrl' => 'invalidShortUrl',
'regular404' => 'regular404',
'baseUrl' => 'baseUrl',
]);
}
/**
* @test
* @dataProvider provideRedirects
*/
public function expectedRedirectionIsReturnedDependingOnTheCase(
NotFoundType $notFoundType,
string $expectedRedirectTo,
): void {
$expectedResp = new Response();
$buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp);
$resp = $this->resolver->resolveRedirectResponse($notFoundType, $this->config);
self::assertSame($expectedResp, $resp);
$buildResp->shouldHaveBeenCalledOnce();
}
public function provideRedirects(): iterable
{
yield 'base URL with trailing slash' => [
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))),
'baseUrl',
];
yield 'base URL without trailing slash' => [
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))),
'baseUrl',
];
yield 'regular 404' => [
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))),
'regular404',
];
yield 'invalid short URL' => [
$this->notFoundType($this->requestForRoute(RedirectAction::class)),
'invalidShortUrl',
];
}
/** @test */
public function noResponseIsReturnedIfNoConditionsMatch(): void
{
$notFoundType = $this->notFoundType($this->requestForRoute('foo'));
$result = $this->resolver->resolveRedirectResponse($notFoundType, $this->config);
self::assertNull($result);
$this->helper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled();
}
private function notFoundType(ServerRequestInterface $req): NotFoundType
{
return NotFoundType::fromRequest($req, '');
}
private function requestForRoute(string $routeName): ServerRequestInterface
{
return ServerRequestFactory::fromGlobals()
->withAttribute(
RouteResult::class,
RouteResult::fromRoute(
new Route(
'',
$this->prophesize(MiddlewareInterface::class)->reveal(),
['GET'],
$routeName,
),
),
)
->withUri(new Uri('/abc123'));
}
}

View File

@@ -9,11 +9,14 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -28,7 +31,7 @@ class DomainServiceTest extends TestCase
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->domainService = new DomainService($this->em->reveal(), 'default.com');
$this->domainService = new DomainService($this->em->reveal(), 'default.com', new NotFoundRedirectOptions());
}
/**
@@ -50,45 +53,56 @@ class DomainServiceTest extends TestCase
public function provideExcludedDomains(): iterable
{
$default = new DomainItem('default.com', true);
$default = DomainItem::forDefaultDomain('default.com', new NotFoundRedirectOptions());
$adminApiKey = ApiKey::create();
$domainSpecificApiKey = ApiKey::fromMeta(
ApiKeyMeta::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123'))),
ApiKeyMeta::withRoles(RoleDefinition::forDomain(Domain::withAuthority('')->setId('123'))),
);
yield 'empty list without API key' => [[], [$default], null];
yield 'one item without API key' => [
[new Domain('bar.com')],
[$default, new DomainItem('bar.com', false)],
[Domain::withAuthority('bar.com')],
[$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))],
null,
];
yield 'multiple items without API key' => [
[new Domain('foo.com'), new Domain('bar.com')],
[$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)],
[Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')],
[
$default,
DomainItem::forExistingDomain(Domain::withAuthority('foo.com')),
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
],
null,
];
yield 'empty list with admin API key' => [[], [$default], $adminApiKey];
yield 'one item with admin API key' => [
[new Domain('bar.com')],
[$default, new DomainItem('bar.com', false)],
[Domain::withAuthority('bar.com')],
[$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))],
$adminApiKey,
];
yield 'multiple items with admin API key' => [
[new Domain('foo.com'), new Domain('bar.com')],
[$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)],
[Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')],
[
$default,
DomainItem::forExistingDomain(Domain::withAuthority('foo.com')),
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
],
$adminApiKey,
];
yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey];
yield 'one item with domain-specific API key' => [
[new Domain('bar.com')],
[new DomainItem('bar.com', false)],
[Domain::withAuthority('bar.com')],
[DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))],
$domainSpecificApiKey,
];
yield 'multiple items with domain-specific API key' => [
[new Domain('foo.com'), new Domain('bar.com')],
[new DomainItem('foo.com', false), new DomainItem('bar.com', false)],
[Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')],
[
DomainItem::forExistingDomain(Domain::withAuthority('foo.com')),
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
],
$domainSpecificApiKey,
];
}
@@ -107,7 +121,7 @@ class DomainServiceTest extends TestCase
/** @test */
public function getDomainReturnsEntityWhenFound(): void
{
$domain = new Domain('');
$domain = Domain::withAuthority('');
$find = $this->em->find(Domain::class, '123')->willReturn($domain);
$result = $this->domainService->getDomain('123');
@@ -120,16 +134,16 @@ class DomainServiceTest extends TestCase
* @test
* @dataProvider provideFoundDomains
*/
public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain): void
public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void
{
$authority = 'example.com';
$repo = $this->prophesize(DomainRepositoryInterface::class);
$repo->findOneBy(['authority' => $authority])->willReturn($foundDomain);
$repo->findOneByAuthority($authority, $apiKey)->willReturn($foundDomain);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class));
$flush = $this->em->flush();
$result = $this->domainService->getOrCreate($authority);
$result = $this->domainService->getOrCreate($authority, $apiKey);
if ($foundDomain !== null) {
self::assertSame($result, $foundDomain);
@@ -139,9 +153,76 @@ class DomainServiceTest extends TestCase
$flush->shouldHaveBeenCalledOnce();
}
/** @test */
public function getOrCreateThrowsExceptionForApiKeysWithDomainRole(): void
{
$authority = 'example.com';
$domain = Domain::withAuthority($authority)->setId('1');
$apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain)));
$repo = $this->prophesize(DomainRepositoryInterface::class);
$repo->findOneByAuthority($authority, $apiKey)->willReturn(null);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$this->expectException(DomainNotFoundException::class);
$getRepo->shouldBeCalledOnce();
$this->em->persist(Argument::cetera())->shouldNotBeCalled();
$this->em->flush()->shouldNotBeCalled();
$this->domainService->getOrCreate($authority, $apiKey);
}
/**
* @test
* @dataProvider provideFoundDomains
*/
public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void
{
$authority = 'example.com';
$repo = $this->prophesize(DomainRepositoryInterface::class);
$repo->findOneByAuthority($authority, $apiKey)->willReturn($foundDomain);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class));
$flush = $this->em->flush();
$result = $this->domainService->configureNotFoundRedirects($authority, NotFoundRedirects::withRedirects(
'foo.com',
'bar.com',
'baz.com',
), $apiKey);
if ($foundDomain !== null) {
self::assertSame($result, $foundDomain);
}
self::assertEquals('foo.com', $result->baseUrlRedirect());
self::assertEquals('bar.com', $result->regular404Redirect());
self::assertEquals('baz.com', $result->invalidShortUrlRedirect());
$getRepo->shouldHaveBeenCalledOnce();
$persist->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
}
public function provideFoundDomains(): iterable
{
yield 'domain not found' => [null];
yield 'domain found' => [new Domain('')];
$domain = Domain::withAuthority('');
$adminApiKey = ApiKey::create();
$authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
yield 'domain not found and no API key' => [null, null];
yield 'domain found and no API key' => [$domain, null];
yield 'domain not found and admin API key' => [null, $adminApiKey];
yield 'domain found and admin API key' => [$domain, $adminApiKey];
yield 'domain not found and author API key' => [null, $authorApiKey];
yield 'domain found and author API key' => [$domain, $authorApiKey];
}
/** @test */
public function anExceptionIsThrowsWhenTryingToEditRedirectsForDefaultDomain(): void
{
$this->expectException(InvalidDomainException::class);
$this->expectExceptionMessage(
'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.',
);
$this->domainService->configureNotFoundRedirects('default.com', NotFoundRedirects::withoutRedirects());
}
}

View File

@@ -6,21 +6,18 @@ namespace ShlinkioTest\Shlink\Core\ErrorHandler;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\Diactoros\Uri;
use Mezzio\Router\Route;
use Mezzio\Router\RouteResult;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
class NotFoundRedirectHandlerTest extends TestCase
{
@@ -28,93 +25,103 @@ class NotFoundRedirectHandlerTest extends TestCase
private NotFoundRedirectHandler $middleware;
private NotFoundRedirectOptions $redirectOptions;
private ObjectProphecy $helper;
private ObjectProphecy $resolver;
private ObjectProphecy $domainService;
private ObjectProphecy $next;
private ServerRequestInterface $req;
public function setUp(): void
{
$this->redirectOptions = new NotFoundRedirectOptions();
$this->helper = $this->prophesize(RedirectResponseHelperInterface::class);
$this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal());
$this->resolver = $this->prophesize(NotFoundRedirectResolverInterface::class);
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->middleware = new NotFoundRedirectHandler(
$this->redirectOptions,
$this->resolver->reveal(),
$this->domainService->reveal(),
);
$this->next = $this->prophesize(RequestHandlerInterface::class);
$this->req = ServerRequestFactory::fromGlobals()->withAttribute(
NotFoundType::class,
$this->prophesize(NotFoundType::class)->reveal(),
);
}
/**
* @test
* @dataProvider provideRedirects
* @dataProvider provideNonRedirectScenarios
*/
public function expectedRedirectionIsReturnedDependingOnTheCase(
ServerRequestInterface $request,
string $expectedRedirectTo,
): void {
$this->redirectOptions->invalidShortUrl = 'invalidShortUrl';
$this->redirectOptions->regular404 = 'regular404';
$this->redirectOptions->baseUrl = 'baseUrl';
public function nextIsCalledWhenNoRedirectIsResolved(callable $setUp): void
{
$expectedResp = new Response();
$buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp);
$next = $this->prophesize(RequestHandlerInterface::class);
$handle = $next->handle($request)->willReturn(new Response());
$setUp($this->domainService, $this->resolver);
$handle = $this->next->handle($this->req)->willReturn($expectedResp);
$resp = $this->middleware->process($request, $next->reveal());
$result = $this->middleware->process($this->req, $this->next->reveal());
self::assertSame($expectedResp, $resp);
$buildResp->shouldHaveBeenCalledOnce();
$handle->shouldNotHaveBeenCalled();
}
public function provideRedirects(): iterable
{
yield 'base URL with trailing slash' => [
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))),
'baseUrl',
];
yield 'base URL without trailing slash' => [
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))),
'baseUrl',
];
yield 'regular 404' => [
$this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))),
'regular404',
];
yield 'invalid short URL' => [
$this->withNotFoundType(ServerRequestFactory::fromGlobals()
->withAttribute(
RouteResult::class,
RouteResult::fromRoute(
new Route(
'',
$this->prophesize(MiddlewareInterface::class)->reveal(),
['GET'],
RedirectAction::class,
),
),
)
->withUri(new Uri('/abc123'))),
'invalidShortUrl',
];
}
/** @test */
public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void
{
$req = $this->withNotFoundType(ServerRequestFactory::fromGlobals());
$resp = new Response();
$buildResp = $this->helper->buildRedirectResponse(Argument::cetera());
$next = $this->prophesize(RequestHandlerInterface::class);
$handle = $next->handle($req)->willReturn($resp);
$result = $this->middleware->process($req, $next->reveal());
self::assertSame($resp, $result);
$buildResp->shouldNotHaveBeenCalled();
self::assertSame($expectedResp, $result);
$handle->shouldHaveBeenCalledOnce();
}
private function withNotFoundType(ServerRequestInterface $req): ServerRequestInterface
public function provideNonRedirectScenarios(): iterable
{
$type = NotFoundType::fromRequest($req, '');
return $req->withAttribute(NotFoundType::class, $type);
yield 'no domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void {
$domainService->findByAuthority(Argument::cetera())
->willReturn(null)
->shouldBeCalledOnce();
$resolver->resolveRedirectResponse(Argument::cetera())
->willReturn(null)
->shouldBeCalledOnce();
}];
yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void {
$domainService->findByAuthority(Argument::cetera())
->willReturn(Domain::withAuthority(''))
->shouldBeCalledOnce();
$resolver->resolveRedirectResponse(Argument::cetera())
->willReturn(null)
->shouldBeCalledTimes(2);
}];
}
/** @test */
public function globalRedirectIsUsedIfDomainRedirectIsNotFound(): void
{
$expectedResp = new Response();
$findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn(null);
$resolveRedirect = $this->resolver->resolveRedirectResponse(
Argument::type(NotFoundType::class),
$this->redirectOptions,
)->willReturn($expectedResp);
$result = $this->middleware->process($this->req, $this->next->reveal());
self::assertSame($expectedResp, $result);
$findDomain->shouldHaveBeenCalledOnce();
$resolveRedirect->shouldHaveBeenCalledOnce();
$this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled();
}
/** @test */
public function domainRedirectIsUsedIfFound(): void
{
$expectedResp = new Response();
$domain = Domain::withAuthority('');
$findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn($domain);
$resolveRedirect = $this->resolver->resolveRedirectResponse(
Argument::type(NotFoundType::class),
$domain,
)->willReturn($expectedResp);
$result = $this->middleware->process($this->req, $this->next->reveal());
self::assertSame($expectedResp, $result);
$findDomain->shouldHaveBeenCalledOnce();
$resolveRedirect->shouldHaveBeenCalledOnce();
$this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled();
}
}

View File

@@ -12,7 +12,7 @@ use function sprintf;
class DomainNotFoundExceptionTest extends TestCase
{
/** @test */
public function properlyCreatesExceptionFromNotFoundTag(): void
public function properlyCreatesExceptionFromId(): void
{
$id = '123';
$expectedMessage = sprintf('Domain with id "%s" could not be found', $id);
@@ -25,4 +25,19 @@ class DomainNotFoundExceptionTest extends TestCase
self::assertEquals(['id' => $id], $e->getAdditionalData());
self::assertEquals(404, $e->getStatus());
}
/** @test */
public function properlyCreatesExceptionFromAuthority(): void
{
$authority = 'example.com';
$expectedMessage = sprintf('Domain with authority "%s" could not be found', $authority);
$e = DomainNotFoundException::fromAuthority($authority);
self::assertEquals($expectedMessage, $e->getMessage());
self::assertEquals($expectedMessage, $e->getDetail());
self::assertEquals('Domain not found', $e->getTitle());
self::assertEquals('DOMAIN_NOT_FOUND', $e->getType());
self::assertEquals(['authority' => $authority], $e->getAdditionalData());
self::assertEquals(404, $e->getStatus());
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
class InvalidDomainExceptionTest extends TestCase
{
/** @test */
public function configuresTheExceptionAsExpected(): void
{
$e = InvalidDomainException::forDefaultDomainRedirects();
$expected = 'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.';
self::assertEquals($expected, $e->getMessage());
self::assertEquals($expected, $e->getDetail());
self::assertEquals('Invalid domain', $e->getTitle());
self::assertEquals('INVALID_DOMAIN', $e->getType());
self::assertEquals(403, $e->getStatus());
}
}

View File

@@ -35,7 +35,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
$adapter = $this->createAdapter(null);
$findVisits = $this->repo->findVisitsByTag(
'foo',
new VisitsListFiltering(new DateRange(), false, null, $limit, $offset),
new VisitsListFiltering(DateRange::emptyInstance(), false, null, $limit, $offset),
)->willReturn([]);
for ($i = 0; $i < $count; $i++) {

View File

@@ -36,7 +36,7 @@ class VisitsPaginatorAdapterTest extends TestCase
$adapter = $this->createAdapter(null);
$findVisits = $this->repo->findVisitsByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain(''),
new VisitsListFiltering(new DateRange(), false, null, $limit, $offset),
new VisitsListFiltering(DateRange::emptyInstance(), false, null, $limit, $offset),
)->willReturn([]);
for ($i = 0; $i < $count; $i++) {
@@ -54,7 +54,7 @@ class VisitsPaginatorAdapterTest extends TestCase
$adapter = $this->createAdapter($apiKey);
$countVisits = $this->repo->countVisitsByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain(''),
new VisitsCountFiltering(new DateRange(), false, $apiKey->spec()),
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey->spec()),
)->willReturn(3);
for ($i = 0; $i < $count; $i++) {

View File

@@ -60,7 +60,7 @@ class ShortCodeHelperTest extends TestCase
public function provideDomains(): iterable
{
yield 'no domain' => [null, null];
yield 'domain' => [new Domain($authority = 'doma.in'), $authority];
yield 'domain' => [Domain::withAuthority($authority = 'doma.in'), $authority];
}
/** @test */

View File

@@ -68,7 +68,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
$authority = 'doma.in';
yield 'not found domain' => [null, $authority];
yield 'found domain' => [new Domain($authority), $authority];
yield 'found domain' => [Domain::withAuthority($authority), $authority];
}
/**

View File

@@ -40,6 +40,7 @@ return [
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class,
Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class,
Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class,
ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class,
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
@@ -81,6 +82,7 @@ return [
Action\Tag\CreateTagsAction::class => [TagService::class],
Action\Tag\UpdateTagAction::class => [TagService::class],
Action\Domain\ListDomainsAction::class => [DomainService::class],
Action\Domain\DomainRedirectsAction::class => [DomainService::class],
Middleware\CrossDomainMiddleware::class => ['config.cors'],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],

View File

@@ -44,6 +44,7 @@ return [
// Domains
Action\Domain\ListDomainsAction::getRouteDef(),
Action\Domain\DomainRedirectsAction::getRouteDef(),
Action\MercureInfoAction::getRouteDef(),
],

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\Domain;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Action\Domain\Request\DomainRedirectsRequest;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class DomainRedirectsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/domains/redirects';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH];
public function __construct(private DomainServiceInterface $domainService)
{
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
/** @var array $body */
$body = $request->getParsedBody();
$requestData = DomainRedirectsRequest::fromRawData($body);
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$authority = $requestData->authority();
$domain = $this->domainService->getOrCreate($authority);
$notFoundRedirects = $requestData->toNotFoundRedirects($domain);
$this->domainService->configureNotFoundRedirects($authority, $notFoundRedirects, $apiKey);
return new JsonResponse($notFoundRedirects);
}
}

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