mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-05 14:53:12 +08:00
Compare commits
66 Commits
v2.8.0-alp
...
v2.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8393d44c50 | ||
|
|
3e8ce80f80 | ||
|
|
ff6747dab5 | ||
|
|
555e6f804c | ||
|
|
98c5c7990f | ||
|
|
27dcdb517d | ||
|
|
916d75d161 | ||
|
|
57bd16f4f5 | ||
|
|
444a1756a2 | ||
|
|
0c97c8f04f | ||
|
|
de81e81ecb | ||
|
|
40a7d5a112 | ||
|
|
7c06633a67 | ||
|
|
9abf611d63 | ||
|
|
565fe4c348 | ||
|
|
7b43403b1c | ||
|
|
9f25979b4c | ||
|
|
20f70b8b07 | ||
|
|
8fbf05acd4 | ||
|
|
6860855c71 | ||
|
|
b78660c685 | ||
|
|
6a40bbdcb5 | ||
|
|
5a1a4f5594 | ||
|
|
2ac7be4363 | ||
|
|
4ef5ab7a90 | ||
|
|
192308a6a3 | ||
|
|
c9ce111643 | ||
|
|
32fda231ad | ||
|
|
e4d4686717 | ||
|
|
ca6c6a1b6e | ||
|
|
806c4ce168 | ||
|
|
9d14597be0 | ||
|
|
dc68bb907c | ||
|
|
e4598c058a | ||
|
|
377562cdff | ||
|
|
969fcccc1f | ||
|
|
4c00764146 | ||
|
|
e98ee64695 | ||
|
|
51c7d0ed3e | ||
|
|
db93498ee6 | ||
|
|
b3af493758 | ||
|
|
7b9ebbbb5f | ||
|
|
ea735fc0a0 | ||
|
|
06227e97d0 | ||
|
|
dbc50b6d4f | ||
|
|
8b75ad1e7f | ||
|
|
8f3c740b57 | ||
|
|
24a6a0c23f | ||
|
|
267d72a76c | ||
|
|
021cecc216 | ||
|
|
4642480bbb | ||
|
|
4d48482d1e | ||
|
|
2054784a4a | ||
|
|
57d816b862 | ||
|
|
32bb66c42b | ||
|
|
e4d15e64b6 | ||
|
|
b11daeae7d | ||
|
|
8e78f8527e | ||
|
|
bc385744db | ||
|
|
02fd28edec | ||
|
|
95770ac104 | ||
|
|
2eeb762cd9 | ||
|
|
de5666d262 | ||
|
|
934d266880 | ||
|
|
b8fa234dbb | ||
|
|
bceea090ed |
68
CHANGELOG.md
68
CHANGELOG.md
@@ -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*
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -1,8 +1,8 @@
|
||||
FROM php:8.0.6-alpine3.13 as base
|
||||
FROM php:8.0.9-alpine3.14 as base
|
||||
|
||||
ARG SHLINK_VERSION=latest
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.6.7
|
||||
ENV SWOOLE_VERSION 4.7.0
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
ENV LC_ALL "C"
|
||||
@@ -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"]
|
||||
|
||||
7
bin/cli
7
bin/cli
@@ -3,5 +3,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$run = require __DIR__ . '/../config/run.php';
|
||||
$run(true);
|
||||
use Symfony\Component\Console\Application;
|
||||
|
||||
/** @var Application $app */
|
||||
$app = require __DIR__ . '/../config/cli-app.php';
|
||||
$app->run();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$isSwoole = extension_loaded('swoole');
|
||||
|
||||
return [
|
||||
|
||||
'url_shortener' => [
|
||||
'domain' => [
|
||||
'schema' => 'http',
|
||||
'hostname' => 'localhost:8080',
|
||||
'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'),
|
||||
],
|
||||
'auto_resolve_titles' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
12
config/cli-app.php
Normal file
12
config/cli-app.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as CliApp;
|
||||
|
||||
return (static function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
return $container->get(CliApp::class);
|
||||
})();
|
||||
@@ -4,12 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Tools\Console\ConsoleRunner;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return (function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
$em = $container->get(EntityManager::class);
|
||||
|
||||
return (static function () {
|
||||
/** @var EntityManager $em */
|
||||
$em = include __DIR__ . '/entity-manager.php';
|
||||
return ConsoleRunner::createHelperSet($em);
|
||||
})();
|
||||
|
||||
12
config/entity-manager.php
Normal file
12
config/entity-manager.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return (static function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
return $container->get(EntityManager::class);
|
||||
})();
|
||||
@@ -4,12 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
use Mezzio\Application;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as CliApp;
|
||||
|
||||
return function (bool $isCli = false): void {
|
||||
return static function (): void {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
$app = $container->get($isCli ? CliApp::class : Application::class);
|
||||
$app = $container->get(Application::class);
|
||||
|
||||
$app->run();
|
||||
};
|
||||
|
||||
@@ -35,26 +35,17 @@ if ($isApiTest) {
|
||||
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
|
||||
}
|
||||
|
||||
$buildDbConnection = function (): array {
|
||||
$buildDbConnection = static function (): array {
|
||||
$driver = env('DB_DRIVER', 'sqlite');
|
||||
$isCi = env('CI', false);
|
||||
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
||||
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
||||
$getMysqlHost = static fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
||||
$getCiMysqlPort = static fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
||||
|
||||
$driverConfigMap = [
|
||||
return match ($driver) {
|
||||
'sqlite' => [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => sys_get_temp_dir() . '/shlink-tests.db',
|
||||
],
|
||||
'mysql' => [
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
|
||||
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
|
||||
'user' => 'root',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
|
||||
@@ -71,10 +62,16 @@ $buildDbConnection = function (): array {
|
||||
'password' => 'Passw0rd!',
|
||||
'dbname' => 'shlink_test',
|
||||
],
|
||||
];
|
||||
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
|
||||
|
||||
return $driverConfigMap[$driver] ?? [];
|
||||
default => [ // mysql and maria
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
|
||||
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
|
||||
'user' => 'root',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [
|
||||
@@ -120,7 +117,7 @@ return [
|
||||
'name' => 'start_collecting_coverage',
|
||||
'path' => '/api-tests/start-coverage',
|
||||
'middleware' => middleware(static function () use (&$coverage) {
|
||||
if ($coverage) {
|
||||
if ($coverage) { // @phpstan-ignore-line
|
||||
$coverage->start('API tests');
|
||||
}
|
||||
return new EmptyResponse();
|
||||
@@ -131,7 +128,7 @@ return [
|
||||
'name' => 'dump_coverage',
|
||||
'path' => '/api-tests/stop-coverage',
|
||||
'middleware' => middleware(static function () use (&$coverage) {
|
||||
if ($coverage) {
|
||||
if ($coverage) { // @phpstan-ignore-line
|
||||
$basePath = __DIR__ . '/../../build/coverage-api';
|
||||
$coverage->stop();
|
||||
(new PHP())->process($coverage, $basePath . '.cov');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM php:8.0.6-fpm-alpine3.13
|
||||
FROM php:8.0.9-fpm-alpine3.14
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.19
|
||||
ENV APCU_VERSION 5.1.20
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM php:8.0.6-alpine3.13
|
||||
FROM php:8.0.9-alpine3.14
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.19
|
||||
ENV APCU_VERSION 5.1.20
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV INOTIFY_VERSION 3.0.0
|
||||
ENV SWOOLE_VERSION 4.6.7
|
||||
ENV SWOOLE_VERSION 4.7.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
41
data/migrations/Version20210720143824.php
Normal file
41
data/migrations/Version20210720143824.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Schema\Table;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210720143824 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$domainsTable = $schema->getTable('domains');
|
||||
$this->skipIf($domainsTable->hasColumn('base_url_redirect'));
|
||||
|
||||
$this->createRedirectColumn($domainsTable, 'base_url_redirect');
|
||||
$this->createRedirectColumn($domainsTable, 'regular_not_found_redirect');
|
||||
$this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect');
|
||||
}
|
||||
|
||||
private function createRedirectColumn(Table $table, string $columnName): void
|
||||
{
|
||||
$table->addColumn($columnName, Types::STRING, [
|
||||
'notnull' => false,
|
||||
'default' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$domainsTable = $schema->getTable('domains');
|
||||
$this->skipIf(! $domainsTable->hasColumn('base_url_redirect'));
|
||||
|
||||
$domainsTable->dropColumn('base_url_redirect');
|
||||
$domainsTable->dropColumn('regular_not_found_redirect');
|
||||
$domainsTable->dropColumn('invalid_short_url_redirect');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
log_errors_max_len=0
|
||||
zend.assertions=1
|
||||
assert.exception=1
|
||||
memory_limit=256M
|
||||
|
||||
@@ -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
|
||||
|
||||
20
docs/swagger/definitions/NotFoundRedirects.json
Normal file
20
docs/swagger/definitions/NotFoundRedirects.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrlRedirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits the domain's base URL"
|
||||
},
|
||||
"regular404Redirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits a not found URL other than an invalid short URL"
|
||||
},
|
||||
"invalidShortUrlRedirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits an invalid short URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The list of tags",
|
||||
"description": "The list of domains",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -33,13 +33,16 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["domain", "isDefault"],
|
||||
"required": ["domain", "isDefault", "redirects"],
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"redirects": {
|
||||
"$ref": "../definitions/NotFoundRedirects.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,15 +59,30 @@
|
||||
"data": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"isDefault": true
|
||||
"isDefault": true,
|
||||
"redirects": {
|
||||
"baseUrlRedirect": "https://example.com/my-landing-page",
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||
}
|
||||
},
|
||||
{
|
||||
"domain": "aaa.com",
|
||||
"isDefault": false
|
||||
"isDefault": false,
|
||||
"redirects": {
|
||||
"baseUrlRedirect": null,
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"domain": "bbb.com",
|
||||
"isDefault": false
|
||||
"isDefault": false,
|
||||
"redirects": {
|
||||
"baseUrlRedirect": null,
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
124
docs/swagger/paths/v2_domains_redirects.json
Normal file
124
docs/swagger/paths/v2_domains_redirects.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"patch": {
|
||||
"operationId": "setDomainRedirects",
|
||||
"tags": [
|
||||
"Domains"
|
||||
],
|
||||
"summary": "Sets domain \"not found\" redirects",
|
||||
"description": "Sets the URLs that you want a visitor to get redirected to for \not found\" URLs for a specific domain",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Request body.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"required": ["domain"],
|
||||
"properties": {
|
||||
"domain": {
|
||||
"description": "The domain's authority for which you want to set redirects",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../definitions/NotFoundRedirects.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The domain's redirects after the update, when existing redirects have been merged with provided ones.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"required": ["baseUrlRedirect", "regular404Redirect", "invalidShortUrlRedirect"]
|
||||
},
|
||||
{
|
||||
"$ref": "../definitions/NotFoundRedirects.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"baseUrlRedirect": "https://example.com/my-landing-page",
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Provided data is invalid.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["invalidElements"],
|
||||
"properties": {
|
||||
"invalidElements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"domain",
|
||||
"baseUrlRedirect",
|
||||
"regular404Redirect",
|
||||
"invalidShortUrlRedirect"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Default domain was provided, and it cannot be edited this way.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Shlink",
|
||||
"description": "Shlink, the self-hosted URL shortener",
|
||||
@@ -102,6 +102,9 @@
|
||||
"/rest/v{version}/domains": {
|
||||
"$ref": "paths/v2_domains.json"
|
||||
},
|
||||
"/rest/v{version}/domains/redirects": {
|
||||
"$ref": "paths/v2_domains_redirects.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/mercure-info": {
|
||||
"$ref": "paths/v2_mercure-info.json"
|
||||
|
||||
@@ -27,6 +27,7 @@ return [
|
||||
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
|
||||
|
||||
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class,
|
||||
Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class,
|
||||
|
||||
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
|
||||
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
|
||||
|
||||
@@ -61,6 +61,7 @@ return [
|
||||
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -104,6 +105,7 @@ return [
|
||||
Command\Tag\DeleteTagsCommand::class => [TagService::class],
|
||||
|
||||
Command\Domain\ListDomainsCommand::class => [DomainService::class],
|
||||
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
|
||||
@@ -8,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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
114
module/CLI/src/Command/Domain/DomainRedirectsCommand.php
Normal file
114
module/CLI/src/Command/Domain/DomainRedirectsCommand.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Functional\filter;
|
||||
use function Functional\invoke;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
|
||||
class DomainRedirectsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:redirects';
|
||||
|
||||
public function __construct(private DomainServiceInterface $domainService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Set specific "not found" redirects for individual domains.')
|
||||
->addArgument(
|
||||
'domain',
|
||||
InputArgument::REQUIRED,
|
||||
'The domain authority to which you want to set the specific redirects',
|
||||
);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
/** @var string|null $domain */
|
||||
$domain = $input->getArgument('domain');
|
||||
if ($domain !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
|
||||
|
||||
/** @var string[] $availableDomains */
|
||||
$availableDomains = invoke(
|
||||
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()),
|
||||
'toString',
|
||||
);
|
||||
if (empty($availableDomains)) {
|
||||
$input->setArgument('domain', $askNewDomain());
|
||||
return;
|
||||
}
|
||||
|
||||
$selectedOption = $io->choice(
|
||||
'Select the domain to configure',
|
||||
[...$availableDomains, '<options=bold>New domain</>'],
|
||||
);
|
||||
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$domainAuthority = $input->getArgument('domain');
|
||||
$domain = $this->domainService->findByAuthority($domainAuthority);
|
||||
|
||||
$ask = static function (string $message, ?string $current) use ($io): ?string {
|
||||
if ($current === null) {
|
||||
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
|
||||
}
|
||||
|
||||
$choice = $io->choice($message, [
|
||||
sprintf('Keep current one: [%s]', $current),
|
||||
'Set new redirect URL',
|
||||
'Remove redirect',
|
||||
]);
|
||||
|
||||
return match ($choice) {
|
||||
'Set new redirect URL' => $io->ask('New redirect URL'),
|
||||
'Remove redirect' => null,
|
||||
default => $current,
|
||||
};
|
||||
};
|
||||
|
||||
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
|
||||
$ask(
|
||||
'URL to redirect to when a user hits this domain\'s base URL',
|
||||
$domain?->baseUrlRedirect(),
|
||||
),
|
||||
$ask(
|
||||
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
|
||||
$domain?->regular404Redirect(),
|
||||
),
|
||||
$ask(
|
||||
'URL to redirect to when a user hits an invalid short URL',
|
||||
$domain?->invalidShortUrlRedirect(),
|
||||
),
|
||||
));
|
||||
|
||||
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function Functional\map;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -53,7 +53,9 @@ class ListKeysCommandTest extends TestCase
|
||||
| Key | Name | Is enabled | Expiration date | Roles |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
| {$apiKey1} | - | +++ | - | Admin |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
| {$apiKey2} | - | +++ | - | Admin |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
| {$apiKey3} | - | +++ | - | Admin |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
|
||||
@@ -67,6 +69,7 @@ class ListKeysCommandTest extends TestCase
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| {$apiKey1} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| {$apiKey2} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
|
||||
@@ -76,11 +79,13 @@ class ListKeysCommandTest extends TestCase
|
||||
[
|
||||
$apiKey1 = ApiKey::create(),
|
||||
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
|
||||
$apiKey3 = $this->apiKeyWithRoles([RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]),
|
||||
$apiKey3 = $this->apiKeyWithRoles(
|
||||
[RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1'))],
|
||||
),
|
||||
$apiKey4 = ApiKey::create(),
|
||||
$apiKey5 = $this->apiKeyWithRoles([
|
||||
RoleDefinition::forAuthoredShortUrls(),
|
||||
RoleDefinition::forDomain((new Domain('example.com'))->setId('1')),
|
||||
RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1')),
|
||||
]),
|
||||
$apiKey6 = ApiKey::create(),
|
||||
],
|
||||
@@ -90,11 +95,16 @@ class ListKeysCommandTest extends TestCase
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey1} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey2} | - | - | Author only |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey3} | - | - | Domain only: example.com |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey4} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey5} | - | - | Author only |
|
||||
| | | | Domain only: example.com |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey6} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
|
||||
@@ -113,8 +123,11 @@ class ListKeysCommandTest extends TestCase
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey1} | Alice | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey2} | Alice and Bob | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey3} | | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey4} | - | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
|
||||
|
||||
180
module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php
Normal file
180
module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function substr_count;
|
||||
|
||||
class DomainRedirectsCommandTest extends TestCase
|
||||
{
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $domainService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDomains
|
||||
*/
|
||||
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
|
||||
{
|
||||
$domainAuthority = 'my-domain.com';
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withRedirects('foo.com', null, 'baz.com'),
|
||||
)->willReturn(Domain::withAuthority(''));
|
||||
|
||||
$this->commandTester->setInputs(['foo.com', '', 'baz.com']);
|
||||
$this->commandTester->execute(['domain' => $domainAuthority]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('[OK] "Not found" redirects properly set for "my-domain.com"', $output);
|
||||
self::assertStringContainsString('URL to redirect to when a user hits this domain\'s base URL', $output);
|
||||
self::assertStringContainsString(
|
||||
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
|
||||
$output,
|
||||
);
|
||||
self::assertStringContainsString('URL to redirect to when a user hits an invalid short URL', $output);
|
||||
self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)'));
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideDomains(): iterable
|
||||
{
|
||||
yield 'no domain' => [null];
|
||||
yield 'domain without redirects' => [Domain::withAuthority('')];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function offersNewOptionsForDomainsWithExistingRedirects(): void
|
||||
{
|
||||
$domainAuthority = 'example.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
$domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com', 'baz.com'));
|
||||
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withRedirects(null, 'edited.com', 'baz.com'),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['2', '1', 'edited.com', '0']);
|
||||
$this->commandTester->execute(['domain' => $domainAuthority]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('[OK] "Not found" redirects properly set for "example.com"', $output);
|
||||
self::assertStringContainsString('Keep current one: [bar.com]', $output);
|
||||
self::assertStringContainsString('Keep current one: [baz.com]', $output);
|
||||
self::assertStringContainsString('Keep current one: [baz.com]', $output);
|
||||
self::assertStringNotContainsStringIgnoringCase('(Leave empty for no redirect)', $output);
|
||||
self::assertEquals(3, substr_count($output, 'Set new redirect URL'));
|
||||
self::assertEquals(3, substr_count($output, 'Remove redirect'));
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function authorityIsRequestedWhenNotProvidedAndNoOtherDomainsExist(): void
|
||||
{
|
||||
$domainAuthority = 'example.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs([$domainAuthority, '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function oneOfTheExistingDomainsCanBeSelected(): void
|
||||
{
|
||||
$domainAuthority = 'existing-two.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority($domainAuthority)),
|
||||
]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['1', '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringNotContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
self::assertStringNotContainsString('default-domain.com', $output);
|
||||
self::assertStringContainsString('existing-one.com', $output);
|
||||
self::assertStringContainsString($domainAuthority, $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function aNewDomainCanBeCreatedEvenIfOthersAlreadyExist(): void
|
||||
{
|
||||
$domainAuthority = 'new-domain.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-two.com')),
|
||||
]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['2', $domainAuthority, '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
self::assertStringNotContainsString('default-domain.com', $output);
|
||||
self::assertStringContainsString('existing-one.com', $output);
|
||||
self::assertStringContainsString('existing-two.com', $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
@@ -26,10 +29,38 @@ class ListDomainsCommandTest extends TestCase
|
||||
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function allDomainsAreProperlyPrinted(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInputsAndOutputs
|
||||
*/
|
||||
public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void
|
||||
{
|
||||
$expectedOutput = <<<OUTPUT
|
||||
$bazDomain = Domain::withAuthority('baz.com');
|
||||
$bazDomain->configureNotFoundRedirects(NotFoundRedirects::withRedirects(
|
||||
null,
|
||||
'https://foo.com/baz-domain/regular',
|
||||
'https://foo.com/baz-domain/invalid',
|
||||
));
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions([
|
||||
'base_url' => 'https://foo.com/default/base',
|
||||
'invalid_short_url' => 'https://foo.com/default/invalid',
|
||||
])),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
|
||||
DomainItem::forExistingDomain($bazDomain),
|
||||
]);
|
||||
|
||||
$this->commandTester->execute($input);
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideInputsAndOutputs(): iterable
|
||||
{
|
||||
$withoutRedirectsOutput = <<<OUTPUT
|
||||
+---------+------------+
|
||||
| Domain | Is default |
|
||||
+---------+------------+
|
||||
@@ -39,16 +70,27 @@ class ListDomainsCommandTest extends TestCase
|
||||
+---------+------------+
|
||||
|
||||
OUTPUT;
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
new DomainItem('foo.com', true),
|
||||
new DomainItem('bar.com', false),
|
||||
new DomainItem('baz.com', false),
|
||||
]);
|
||||
$withRedirectsOutput = <<<OUTPUT
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| Domain | Is default | "Not found" redirects |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| foo.com | Yes | * Base URL: https://foo.com/default/base |
|
||||
| | | * Regular 404: N/A |
|
||||
| | | * Invalid short URL: https://foo.com/default/invalid |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| bar.com | No | * Base URL: N/A |
|
||||
| | | * Regular 404: N/A |
|
||||
| | | * Invalid short URL: N/A |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| baz.com | No | * Base URL: N/A |
|
||||
| | | * Regular 404: https://foo.com/baz-domain/regular |
|
||||
| | | * Invalid short URL: https://foo.com/baz-domain/invalid |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
OUTPUT;
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
yield 'no args' => [[], $withoutRedirectsOutput];
|
||||
yield 'no show redirects' => [['--show-redirects' => false], $withoutRedirectsOutput];
|
||||
yield 'show redirects' => [['--show-redirects' => true], $withRedirectsOutput];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
20
module/Core/src/Config/NotFoundRedirectConfigInterface.php
Normal file
20
module/Core/src/Config/NotFoundRedirectConfigInterface.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
interface NotFoundRedirectConfigInterface
|
||||
{
|
||||
public function invalidShortUrlRedirect(): ?string;
|
||||
|
||||
public function hasInvalidShortUrlRedirect(): bool;
|
||||
|
||||
public function regular404Redirect(): ?string;
|
||||
|
||||
public function hasRegular404Redirect(): bool;
|
||||
|
||||
public function baseUrlRedirect(): ?string;
|
||||
|
||||
public function hasBaseUrlRedirect(): bool;
|
||||
}
|
||||
34
module/Core/src/Config/NotFoundRedirectResolver.php
Normal file
34
module/Core/src/Config/NotFoundRedirectResolver.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||
|
||||
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
|
||||
{
|
||||
public function __construct(private RedirectResponseHelperInterface $redirectResponseHelper)
|
||||
{
|
||||
}
|
||||
|
||||
public function resolveRedirectResponse(
|
||||
NotFoundType $notFoundType,
|
||||
NotFoundRedirectConfigInterface $config
|
||||
): ?ResponseInterface {
|
||||
return match (true) {
|
||||
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() =>
|
||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||
$this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()),
|
||||
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() =>
|
||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||
$this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()),
|
||||
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
|
||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||
$this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
module/Core/src/Config/NotFoundRedirectResolverInterface.php
Normal file
16
module/Core/src/Config/NotFoundRedirectResolverInterface.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
|
||||
interface NotFoundRedirectResolverInterface
|
||||
{
|
||||
public function resolveRedirectResponse(
|
||||
NotFoundType $notFoundType,
|
||||
NotFoundRedirectConfigInterface $config
|
||||
): ?ResponseInterface;
|
||||
}
|
||||
59
module/Core/src/Config/NotFoundRedirects.php
Normal file
59
module/Core/src/Config/NotFoundRedirects.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
final class NotFoundRedirects implements JsonSerializable
|
||||
{
|
||||
private function __construct(
|
||||
private ?string $baseUrlRedirect,
|
||||
private ?string $regular404Redirect,
|
||||
private ?string $invalidShortUrlRedirect,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function withRedirects(
|
||||
?string $baseUrlRedirect,
|
||||
?string $regular404Redirect = null,
|
||||
?string $invalidShortUrlRedirect = null,
|
||||
): self {
|
||||
return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect);
|
||||
}
|
||||
|
||||
public static function withoutRedirects(): self
|
||||
{
|
||||
return new self(null, null, null);
|
||||
}
|
||||
|
||||
public static function fromConfig(NotFoundRedirectConfigInterface $config): self
|
||||
{
|
||||
return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect());
|
||||
}
|
||||
|
||||
public function baseUrlRedirect(): ?string
|
||||
{
|
||||
return $this->baseUrlRedirect;
|
||||
}
|
||||
|
||||
public function regular404Redirect(): ?string
|
||||
{
|
||||
return $this->regular404Redirect;
|
||||
}
|
||||
|
||||
public function invalidShortUrlRedirect(): ?string
|
||||
{
|
||||
return $this->invalidShortUrlRedirect;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'baseUrlRedirect' => $this->baseUrlRedirect,
|
||||
'regular404Redirect' => $this->regular404Redirect,
|
||||
'invalidShortUrlRedirect' => $this->invalidShortUrlRedirect,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain;
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface DomainServiceInterface
|
||||
@@ -21,5 +23,20 @@ interface DomainServiceInterface
|
||||
*/
|
||||
public function getDomain(string $domainId): Domain;
|
||||
|
||||
public function getOrCreate(string $authority): Domain;
|
||||
/**
|
||||
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
|
||||
*/
|
||||
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain;
|
||||
|
||||
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
|
||||
|
||||
/**
|
||||
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
|
||||
* @throws InvalidDomainException If default domain is provided
|
||||
*/
|
||||
public function configureNotFoundRedirects(
|
||||
string $authority,
|
||||
NotFoundRedirects $notFoundRedirects,
|
||||
?ApiKey $apiKey = null,
|
||||
): Domain;
|
||||
}
|
||||
|
||||
@@ -5,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ namespace Shlinkio\Shlink\Core\Domain\Repository;
|
||||
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Shlinkio\Shlink\Core\Domain\Spec\IsDomain;
|
||||
use Shlinkio\Shlink\Core\Domain\Spec\IsNotAuthority;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
|
||||
@@ -18,18 +23,51 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
|
||||
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('d');
|
||||
$qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d')
|
||||
->orderBy('d.authority', 'ASC');
|
||||
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
|
||||
->groupBy('d')
|
||||
->orderBy('d.authority', 'ASC')
|
||||
->having($qb->expr()->gt('COUNT(s.id)', '0'))
|
||||
->orHaving($qb->expr()->isNotNull('d.baseUrlRedirect'))
|
||||
->orHaving($qb->expr()->isNotNull('d.regular404Redirect'))
|
||||
->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect'));
|
||||
|
||||
if ($excludedAuthority !== null) {
|
||||
$qb->where($qb->expr()->neq('d.authority', ':excludedAuthority'))
|
||||
->setParameter('excludedAuthority', $excludedAuthority);
|
||||
}
|
||||
|
||||
if ($apiKey !== null) {
|
||||
$this->applySpecification($qb, $apiKey->spec(), 's');
|
||||
$specs = $this->determineExtraSpecs($excludedAuthority, $apiKey);
|
||||
foreach ($specs as [$alias, $spec]) {
|
||||
$this->applySpecification($qb, $spec, $alias);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
|
||||
{
|
||||
$qb = $this->createQueryBuilder('d');
|
||||
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
|
||||
->where($qb->expr()->eq('d.authority', ':authority'))
|
||||
->setParameter('authority', $authority)
|
||||
->setMaxResults(1);
|
||||
|
||||
$specs = $this->determineExtraSpecs(null, $apiKey);
|
||||
foreach ($specs as [$alias, $spec]) {
|
||||
$this->applySpecification($qb, $spec, $alias);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
private function determineExtraSpecs(?string $excludedAuthority, ?ApiKey $apiKey): iterable
|
||||
{
|
||||
if ($excludedAuthority !== null) {
|
||||
yield ['d', new IsNotAuthority($excludedAuthority)];
|
||||
}
|
||||
|
||||
// FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the
|
||||
// ShortUrl is the root entity. Here, the Domain is the root entity.
|
||||
// Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible.
|
||||
yield from $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) {
|
||||
Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))],
|
||||
Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)],
|
||||
default => [null, Spec::andX()],
|
||||
}) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,6 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array;
|
||||
|
||||
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
|
||||
}
|
||||
|
||||
22
module/Core/src/Domain/Spec/IsDomain.php
Normal file
22
module/Core/src/Domain/Spec/IsDomain.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\Filter\Filter;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||
|
||||
class IsDomain extends BaseSpecification
|
||||
{
|
||||
public function __construct(private string $domainId, ?string $context = null)
|
||||
{
|
||||
parent::__construct($context);
|
||||
}
|
||||
|
||||
protected function getSpec(): Filter
|
||||
{
|
||||
return Spec::eq('id', $this->domainId);
|
||||
}
|
||||
}
|
||||
22
module/Core/src/Domain/Spec/IsNotAuthority.php
Normal file
22
module/Core/src/Domain/Spec/IsNotAuthority.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\Filter\Filter;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||
|
||||
class IsNotAuthority extends BaseSpecification
|
||||
{
|
||||
public function __construct(private string $authority, ?string $context = null)
|
||||
{
|
||||
parent::__construct($context);
|
||||
}
|
||||
|
||||
protected function getSpec(): Filter
|
||||
{
|
||||
return Spec::not(Spec::eq('authority', $this->authority));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Validation;
|
||||
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
|
||||
class DomainRedirectsInputFilter extends InputFilter
|
||||
{
|
||||
use Validation\InputFactoryTrait;
|
||||
|
||||
public const DOMAIN = 'domain';
|
||||
public const BASE_URL_REDIRECT = 'baseUrlRedirect';
|
||||
public const REGULAR_404_REDIRECT = 'regular404Redirect';
|
||||
public const INVALID_SHORT_URL_REDIRECT = 'invalidShortUrlRedirect';
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function withData(array $data): self
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$instance->initializeInputs();
|
||||
$instance->setData($data);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
private function initializeInputs(): void
|
||||
{
|
||||
$domain = $this->createInput(self::DOMAIN);
|
||||
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
||||
$this->add($domain);
|
||||
|
||||
$this->add($this->createInput(self::BASE_URL_REDIRECT, false));
|
||||
$this->add($this->createInput(self::REGULAR_404_REDIRECT, false));
|
||||
$this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false));
|
||||
}
|
||||
}
|
||||
@@ -6,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
33
module/Core/src/Exception/InvalidDomainException.php
Normal file
33
module/Core/src/Exception/InvalidDomainException.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -49,7 +49,6 @@ final class ShortUrlsOrdering
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var string|array $orderBy */
|
||||
if (! $isArray) {
|
||||
[$field, $dir] = array_pad(explode('-', $orderBy), 2, null);
|
||||
$this->orderField = $field;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 . '\'');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
114
module/Core/test/Config/NotFoundRedirectResolverTest.php
Normal file
114
module/Core/test/Config/NotFoundRedirectResolverTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
24
module/Core/test/Exception/InvalidDomainExceptionTest.php
Normal file
24
module/Core/test/Exception/InvalidDomainExceptionTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -44,6 +44,7 @@ return [
|
||||
|
||||
// Domains
|
||||
Action\Domain\ListDomainsAction::getRouteDef(),
|
||||
Action\Domain\DomainRedirectsAction::getRouteDef(),
|
||||
|
||||
Action\MercureInfoAction::getRouteDef(),
|
||||
],
|
||||
|
||||
39
module/Rest/src/Action/Domain/DomainRedirectsAction.php
Normal file
39
module/Rest/src/Action/Domain/DomainRedirectsAction.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class EditShortUrlTagsAction extends AbstractRestAction
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
/** @var array $bodyParams */
|
||||
$bodyParams = $request->getParsedBody();
|
||||
|
||||
if (! isset($bodyParams['tags'])) {
|
||||
|
||||
@@ -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'] ?? [];
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class UpdateTagAction extends AbstractRestAction
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
/** @var array $body */
|
||||
$body = $request->getParsedBody();
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user