Compare commits

...

66 Commits

Author SHA1 Message Date
Alejandro Celaya
8393d44c50 Merge pull request #1156 from acelaya-forks/feature/query-num-keys
Fixed numeric query params being replaced by 0 in long URLs
2021-08-15 19:25:13 +02:00
Alejandro Celaya
3e8ce80f80 Fixed numeric query params being replaced by 0 in long URLs 2021-08-15 19:13:26 +02:00
Alejandro Celaya
ff6747dab5 Merge pull request #1143 from shlinkio/develop
Release 2.8.0
2021-08-04 15:43:59 +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
113 changed files with 2306 additions and 380 deletions

View File

@@ -4,21 +4,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
## [2.8.1] - 2021-08-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1155](https://github.com/shlinkio/shlink/issues/1155) Fixed numeric query params in long URLs being replaced by `0`.
## [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,66 @@
"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/cache": "^1.12",
"doctrine/migrations": "^3.2",
"doctrine/orm": "^2.9",
"endroid/qr-code": "^4.2",
"geoip2/geoip2": "^2.11",
"guzzlehttp/guzzle": "^7.3",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2",
"laminas/laminas-config": "^3.3",
"laminas/laminas-config-aggregator": "^1.1",
"laminas/laminas-diactoros": "^2.1.3",
"laminas/laminas-inputfilter": "^2.10",
"laminas/laminas-servicemanager": "^3.6",
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0",
"league/uri": "^6.2",
"laminas/laminas-config": "^3.5",
"laminas/laminas-config-aggregator": "^1.5",
"laminas/laminas-diactoros": "^2.6",
"laminas/laminas-inputfilter": "^2.12",
"laminas/laminas-servicemanager": "^3.7",
"laminas/laminas-stdlib": "^3.5",
"lcobucci/jwt": "^4.1",
"league/uri": "^6.4",
"lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.3",
"mezzio/mezzio-fastroute": "^3.1",
"mezzio/mezzio-problem-details": "^1.3",
"mezzio/mezzio": "^3.5",
"mezzio/mezzio-fastroute": "^3.2",
"mezzio/mezzio-problem-details": "^1.4",
"mezzio/mezzio-swoole": "^3.3",
"monolog/monolog": "^2.0",
"monolog/monolog": "^2.3",
"nikolaposa/monolog-factory": "^3.1",
"ocramius/proxy-manager": "^2.11",
"pagerfanta/core": "^2.5",
"pagerfanta/core": "^2.7",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.7",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-config": "^1.2",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3",
"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 +114,7 @@
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8",
"test": [
"@test:unit",
"@test:db",
@@ -134,7 +136,7 @@
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db",

View File

@@ -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,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

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

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

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

@@ -15,11 +15,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()
{

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

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use GuzzleHttp\Psr7\Query;
use Laminas\Stdlib\ArrayUtils;
use League\Uri\Uri;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use function array_merge;
use function sprintf;
class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
@@ -37,7 +37,8 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
unset($currentQuery[$disableTrackParam]);
}
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
// We want to merge preserving numeric keys, as some params might be numbers
$mergedQuery = ArrayUtils::merge($hardcodedQuery, $currentQuery, true);
return empty($mergedQuery) ? null : Query::build($mergedQuery);
}

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

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

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

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

@@ -37,6 +37,8 @@ class ShortUrlRedirectionBuilderTest extends TestCase
yield ['https://domain.com/foo/bar?some=thing', [], null];
yield ['https://domain.com/foo/bar?some=thing&else', ['else' => null], null];
yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null];
yield ['https://domain.com/foo/bar?some=thing&123=foo', ['123' => 'foo'], null];
yield ['https://domain.com/foo/bar?some=thing&456=foo', [456 => 'foo'], null];
yield ['https://domain.com/foo/bar?some=overwritten&foo=bar', ['foo' => 'bar', 'some' => 'overwritten'], null];
yield ['https://domain.com/foo/bar?some=overwritten', ['foobar' => 'notrack', 'some' => 'overwritten'], null];
yield ['https://domain.com/foo/bar/something/else-baz?some=thing', [], '/something/else-baz'];

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

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\Domain\Request;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\Validation\DomainRedirectsInputFilter;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use function array_key_exists;
class DomainRedirectsRequest
{
private string $authority;
private ?string $baseUrlRedirect = null;
private bool $baseUrlRedirectWasProvided = false;
private ?string $regular404Redirect = null;
private bool $regular404RedirectWasProvided = false;
private ?string $invalidShortUrlRedirect = null;
private bool $invalidShortUrlRedirectWasProvided = false;
private function __construct()
{
}
public static function fromRawData(array $payload): self
{
$instance = new self();
$instance->validateAndInit($payload);
return $instance;
}
/**
* @throws ValidationException
*/
private function validateAndInit(array $payload): void
{
$inputFilter = DomainRedirectsInputFilter::withData($payload);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
$this->baseUrlRedirectWasProvided = array_key_exists(
DomainRedirectsInputFilter::BASE_URL_REDIRECT,
$payload,
);
$this->regular404RedirectWasProvided = array_key_exists(
DomainRedirectsInputFilter::REGULAR_404_REDIRECT,
$payload,
);
$this->invalidShortUrlRedirectWasProvided = array_key_exists(
DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT,
$payload,
);
$this->authority = $inputFilter->getValue(DomainRedirectsInputFilter::DOMAIN);
$this->baseUrlRedirect = $inputFilter->getValue(DomainRedirectsInputFilter::BASE_URL_REDIRECT);
$this->regular404Redirect = $inputFilter->getValue(DomainRedirectsInputFilter::REGULAR_404_REDIRECT);
$this->invalidShortUrlRedirect = $inputFilter->getValue(DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT);
}
public function authority(): string
{
return $this->authority;
}
public function toNotFoundRedirects(?NotFoundRedirectConfigInterface $defaults = null): NotFoundRedirects
{
return NotFoundRedirects::withRedirects(
$this->baseUrlRedirectWasProvided ? $this->baseUrlRedirect : $defaults?->baseUrlRedirect(),
$this->regular404RedirectWasProvided ? $this->regular404Redirect : $defaults?->regular404Redirect(),
$this->invalidShortUrlRedirectWasProvided
? $this->invalidShortUrlRedirect
: $defaults?->invalidShortUrlRedirect(),
);
}
}

View File

@@ -27,6 +27,7 @@ class EditShortUrlTagsAction extends AbstractRestAction
public function handle(Request $request): Response
{
/** @var array $bodyParams */
$bodyParams = $request->getParsedBody();
if (! isset($bodyParams['tags'])) {

View File

@@ -20,15 +20,9 @@ class CreateTagsAction extends AbstractRestAction
{
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
*
* @throws \InvalidArgumentException
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
/** @var array $body */
$body = $request->getParsedBody();
$tags = $body['tags'] ?? [];

View File

@@ -23,6 +23,7 @@ class UpdateTagAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
/** @var array $body */
$body = $request->getParsedBody();
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);

View File

@@ -119,6 +119,11 @@ class ApiKey extends AbstractEntity
return $role?->meta() ?? [];
}
/**
* @template T
* @param callable(string $roleName, array $meta): T $fun
* @return T[]
*/
public function mapRoles(callable $fun): array
{
return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->name(), $role->meta()))->getValues();

View File

@@ -6,11 +6,18 @@ namespace Shlinkio\Shlink\Rest\Middleware;
use Laminas\Diactoros\Response\EmptyResponse;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
class EmptyResponseImplicitOptionsMiddlewareFactory
{
public function __invoke(): ImplicitOptionsMiddleware
{
return new ImplicitOptionsMiddleware(fn () => new EmptyResponse());
return new ImplicitOptionsMiddleware(new class implements ResponseFactoryInterface {
public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface
{
return new EmptyResponse();
}
});
}
}

View File

@@ -18,6 +18,7 @@ class DefaultShortCodesLengthMiddleware implements MiddlewareInterface
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var array $body */
$body = $request->getParsedBody();
if (! isset($body[ShortUrlInputFilter::SHORT_CODE_LENGTH])) {
$body[ShortUrlInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength;

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