mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-06 23:33:13 +08:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8efda2ef56 | ||
|
|
f86cda6730 | ||
|
|
43f59a19fb | ||
|
|
eabaa94e06 | ||
|
|
20575a2b0f | ||
|
|
0096a778ac | ||
|
|
050f83e3bb | ||
|
|
32f7b4fbf6 | ||
|
|
265e8cdeaf | ||
|
|
fe5460e0c5 | ||
|
|
d4cad337fc | ||
|
|
0af6ecbd34 | ||
|
|
6466045363 | ||
|
|
67c7e503d9 | ||
|
|
01e06f0503 | ||
|
|
d6e155d874 | ||
|
|
5a2350bac1 | ||
|
|
2b97f9ac9e | ||
|
|
090b215179 | ||
|
|
32f483c333 | ||
|
|
655652f94f | ||
|
|
53b84c147c | ||
|
|
d8b4827601 | ||
|
|
5737acf759 | ||
|
|
58262e8604 | ||
|
|
b9e5eaf689 | ||
|
|
6d78cd59e9 | ||
|
|
bfdece1c23 | ||
|
|
a68f450d36 | ||
|
|
d1df225e47 | ||
|
|
9c6ba4bc61 | ||
|
|
c01121d61a | ||
|
|
e0f0bb5523 |
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer test:unit:ci
|
- run: composer test:unit:ci
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v2
|
||||||
if: ${{ matrix.php-version == '7.4' }}
|
if: ${{ matrix.php-version == '8.0' }}
|
||||||
with:
|
with:
|
||||||
name: coverage-unit
|
name: coverage-unit
|
||||||
path: |
|
path: |
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer test:db:sqlite:ci
|
- run: composer test:db:sqlite:ci
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v2
|
||||||
if: ${{ matrix.php-version == '7.4' }}
|
if: ${{ matrix.php-version == '8.0' }}
|
||||||
with:
|
with:
|
||||||
name: coverage-db
|
name: coverage-db
|
||||||
path: |
|
path: |
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -140,7 +140,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -160,7 +160,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -184,7 +184,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -201,7 +201,7 @@ jobs:
|
|||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: bin/test/run-api-tests.sh
|
- run: bin/test/run-api-tests.sh
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v2
|
||||||
if: ${{ matrix.php-version == '7.4' }}
|
if: ${{ matrix.php-version == '8.0' }}
|
||||||
with:
|
with:
|
||||||
name: coverage-api
|
name: coverage-api
|
||||||
path: |
|
path: |
|
||||||
@@ -216,7 +216,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
test-group: ['unit', 'db']
|
test-group: ['unit', 'db']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
5
.github/workflows/publish-release.yml
vendored
5
.github/workflows/publish-release.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['7.4', '8.0']
|
php-version: ['8.0']
|
||||||
swoole: ['yes', 'no']
|
swoole: ['yes', 'no']
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -43,7 +43,6 @@ jobs:
|
|||||||
uses: docker://antonyurchenko/git-release:latest
|
uses: docker://antonyurchenko/git-release:latest
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
ALLOW_TAG_PREFIX: "true"
|
|
||||||
ALLOW_EMPTY_CHANGELOG: "true"
|
ALLOW_EMPTY_CHANGELOG: "true"
|
||||||
with:
|
with:
|
||||||
args: |
|
args: |
|
||||||
@@ -54,7 +53,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: [ '7.4', '8.0' ]
|
php-version: [ '8.0' ]
|
||||||
swoole: [ 'yes', 'no' ]
|
swoole: [ 'yes', 'no' ]
|
||||||
steps:
|
steps:
|
||||||
- uses: geekyeggo/delete-artifact@v1
|
- uses: geekyeggo/delete-artifact@v1
|
||||||
|
|||||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -4,6 +4,73 @@ 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).
|
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [2.8.0] - 2021-08-04
|
||||||
|
### Added
|
||||||
|
* [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`.
|
||||||
|
* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes.
|
||||||
|
|
||||||
|
Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High.
|
||||||
|
|
||||||
|
* [#1080](https://github.com/shlinkio/shlink/issues/1080) Added support to redirect to URLs as soon as the path starts with a valid short code, appending the rest of the path to the redirected long URL.
|
||||||
|
|
||||||
|
With this, if you have the `https://example.com/abc123` short URL redirecting to `https://www.twitter.com`, a visit to `https://example.com/abc123/shlinkio` will take you to `https://www.twitter.com/shlinkio`.
|
||||||
|
|
||||||
|
This behavior needs to be actively opted in, via installer config options or env vars.
|
||||||
|
|
||||||
|
* [#943](https://github.com/shlinkio/shlink/issues/943) Added support to define different "not-found" redirects for every domain handled by Shlink.
|
||||||
|
|
||||||
|
Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command or the `PATCH /domains/redirects` REST endpoint to define specific values for every single domain.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8.
|
||||||
|
* [#1127](https://github.com/shlinkio/shlink/issues/1127) Updated to infection 0.24.
|
||||||
|
* [#1139](https://github.com/shlinkio/shlink/issues/1139) Updated project dependencies, including base docker image to use PHP 8.0.9 and Alpine 3.14.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
|
## [2.7.3] - 2021-08-02
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### 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
|
## [2.7.1] - 2021-05-30
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
|
|||||||
For example, `test:db:postgres`.
|
For example, `test:db:postgres`.
|
||||||
|
|
||||||
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
|
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
|
||||||
* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
|
* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
|
||||||
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
|
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
|
||||||
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.
|
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.
|
||||||
|
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,8 +1,8 @@
|
|||||||
FROM php:8.0.6-alpine3.13 as base
|
FROM php:8.0.9-alpine3.14 as base
|
||||||
|
|
||||||
ARG SHLINK_VERSION=latest
|
ARG SHLINK_VERSION=latest
|
||||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
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 PDO_SQLSRV_VERSION 5.9.0
|
||||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||||
ENV LC_ALL "C"
|
ENV LC_ALL "C"
|
||||||
@@ -78,4 +78,14 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh
|
|||||||
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
|
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
|
||||||
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
|
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
|
||||||
|
|
||||||
|
# Change the ownership of /etc/shlink/data to be writable, then change the user to non-root
|
||||||
|
# FIXME Disabled for now, as it conflicts with ENABLE_PERIODIC_VISIT_LOCATE, which is used to configure a cron as root.
|
||||||
|
# Ref: https://github.com/shlinkio/shlink/issues/1132
|
||||||
|
#RUN chown 1001 /etc/shlink/data
|
||||||
|
#RUN chown 1001 /etc/shlink/data/locks
|
||||||
|
#RUN chown 1001 /etc/shlink/data/proxies
|
||||||
|
#RUN chown 1001 /etc/shlink/data/cache
|
||||||
|
#RUN chown 1001 /etc/shlink/data/log
|
||||||
|
#USER 1001
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]
|
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ The idea is that you can just generate a container using the image and provide t
|
|||||||
|
|
||||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||||
|
|
||||||
* PHP 7.4 or 8.0
|
* PHP 8.0
|
||||||
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
|
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
|
||||||
* apcu extension is recommended if you don't plan to use swoole.
|
* apcu extension is recommended if you don't plan to use swoole.
|
||||||
* xml extension is required if you want to generate QR codes in svg format.
|
* xml extension is required if you want to generate QR codes in svg format.
|
||||||
|
|||||||
7
bin/cli
7
bin/cli
@@ -3,5 +3,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$run = require __DIR__ . '/../config/run.php';
|
use Symfony\Component\Console\Application;
|
||||||
$run(true);
|
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require __DIR__ . '/../config/cli-app.php';
|
||||||
|
$app->run();
|
||||||
|
|||||||
@@ -12,68 +12,70 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^7.4 || ^8.0",
|
"php": "^8.0",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"akrabat/ip-address-middleware": "^2.0",
|
"akrabat/ip-address-middleware": "^2.0",
|
||||||
"cakephp/chronos": "^2.0",
|
"cakephp/chronos": "^2.2",
|
||||||
"cocur/slugify": "^4.0",
|
"cocur/slugify": "^4.0",
|
||||||
"doctrine/cache": "^1.9",
|
"doctrine/cache": "^1.12",
|
||||||
"doctrine/migrations": "^3.1.1",
|
"doctrine/migrations": "^3.2",
|
||||||
"doctrine/orm": "^2.8.4",
|
"doctrine/orm": "^2.9",
|
||||||
"endroid/qr-code": "^4.0",
|
"endroid/qr-code": "^4.2",
|
||||||
"geoip2/geoip2": "^2.9",
|
"geoip2/geoip2": "^2.11",
|
||||||
"guzzlehttp/guzzle": "^7.0",
|
"guzzlehttp/guzzle": "^7.3",
|
||||||
"happyr/doctrine-specification": "^2.0",
|
"happyr/doctrine-specification": "^2.0",
|
||||||
"jaybizzle/crawler-detect": "^1.2",
|
"jaybizzle/crawler-detect": "^1.2",
|
||||||
"laminas/laminas-config": "^3.3",
|
"laminas/laminas-config": "^3.5",
|
||||||
"laminas/laminas-config-aggregator": "^1.1",
|
"laminas/laminas-config-aggregator": "^1.5",
|
||||||
"laminas/laminas-diactoros": "^2.1.3",
|
"laminas/laminas-diactoros": "^2.6",
|
||||||
"laminas/laminas-inputfilter": "^2.10",
|
"laminas/laminas-inputfilter": "^2.12",
|
||||||
"laminas/laminas-servicemanager": "^3.6",
|
"laminas/laminas-servicemanager": "^3.7",
|
||||||
"laminas/laminas-stdlib": "^3.2",
|
"laminas/laminas-stdlib": "^3.5",
|
||||||
"lcobucci/jwt": "^4.0",
|
"lcobucci/jwt": "^4.1",
|
||||||
"league/uri": "^6.2",
|
"league/uri": "^6.4",
|
||||||
"lstrojny/functional-php": "^1.17",
|
"lstrojny/functional-php": "^1.17",
|
||||||
"mezzio/mezzio": "^3.3",
|
"mezzio/mezzio": "^3.5",
|
||||||
"mezzio/mezzio-fastroute": "^3.1",
|
"mezzio/mezzio-fastroute": "^3.2",
|
||||||
"mezzio/mezzio-problem-details": "^1.3",
|
"mezzio/mezzio-problem-details": "^1.4",
|
||||||
"mezzio/mezzio-swoole": "^3.3",
|
"mezzio/mezzio-swoole": "^3.3",
|
||||||
"monolog/monolog": "^2.0",
|
"monolog/monolog": "^2.3",
|
||||||
"nikolaposa/monolog-factory": "^3.1",
|
"nikolaposa/monolog-factory": "^3.1",
|
||||||
"ocramius/proxy-manager": "^2.11",
|
"ocramius/proxy-manager": "^2.11",
|
||||||
"pagerfanta/core": "^2.5",
|
"pagerfanta/core": "^2.7",
|
||||||
"php-middleware/request-id": "^4.1",
|
"php-middleware/request-id": "^4.1",
|
||||||
"predis/predis": "^1.1",
|
"predis/predis": "^1.1",
|
||||||
"pugx/shortid-php": "^0.7",
|
"pugx/shortid-php": "^0.7",
|
||||||
"ramsey/uuid": "^3.9",
|
"ramsey/uuid": "^3.9",
|
||||||
"shlinkio/shlink-common": "^3.7",
|
"shlinkio/shlink-common": "^3.7",
|
||||||
"shlinkio/shlink-config": "^1.0",
|
"shlinkio/shlink-config": "^1.2",
|
||||||
"shlinkio/shlink-event-dispatcher": "^2.1",
|
"shlinkio/shlink-event-dispatcher": "^2.1",
|
||||||
"shlinkio/shlink-importer": "^2.3",
|
"shlinkio/shlink-importer": "^2.3.1",
|
||||||
"shlinkio/shlink-installer": "^6.0",
|
"shlinkio/shlink-installer": "^6.1",
|
||||||
"shlinkio/shlink-ip-geolocation": "^2.0",
|
"shlinkio/shlink-ip-geolocation": "^2.0",
|
||||||
"symfony/console": "^5.1",
|
"symfony/console": "^5.3",
|
||||||
"symfony/filesystem": "^5.1",
|
"symfony/filesystem": "^5.3",
|
||||||
"symfony/lock": "^5.1",
|
"symfony/lock": "^5.3",
|
||||||
"symfony/mercure": "^0.5.1",
|
"symfony/mercure": "^0.5.3",
|
||||||
"symfony/process": "^5.1",
|
"symfony/process": "^5.3",
|
||||||
"symfony/string": "^5.1"
|
"symfony/string": "^5.3"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"devster/ubench": "^2.1",
|
"devster/ubench": "^2.1",
|
||||||
"dms/phpunit-arraysubset-asserts": "^0.2.1",
|
"dms/phpunit-arraysubset-asserts": "^0.3.0",
|
||||||
"eaglewu/swoole-ide-helper": "dev-master",
|
"eaglewu/swoole-ide-helper": "dev-master",
|
||||||
"infection/infection": "^0.21.0",
|
"infection/infection": "^0.24.0",
|
||||||
"phpspec/prophecy-phpunit": "^2.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/php-code-coverage": "^9.2",
|
||||||
"phpunit/phpunit": "^9.5",
|
"phpunit/phpunit": "^9.5",
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
"shlinkio/php-coding-standard": "~2.1.1",
|
"shlinkio/php-coding-standard": "~2.1.1",
|
||||||
"shlinkio/shlink-test-utils": "^2.1",
|
"shlinkio/shlink-test-utils": "^2.2",
|
||||||
"symfony/var-dumper": "^5.2",
|
"symfony/var-dumper": "^5.3",
|
||||||
"veewee/composer-run-parallel": "^0.1.0"
|
"veewee/composer-run-parallel": "^1.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -112,7 +114,7 @@
|
|||||||
],
|
],
|
||||||
"cs": "phpcs",
|
"cs": "phpcs",
|
||||||
"cs:fix": "phpcbf",
|
"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": [
|
||||||
"@test:unit",
|
"@test:unit",
|
||||||
"@test:db",
|
"@test:db",
|
||||||
@@ -134,7 +136,7 @@
|
|||||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||||
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||||
"test:api": "bin/test/run-api-tests.sh",
|
"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: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:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
|
||||||
"infect:ci": "@parallel infect:ci:unit infect:ci:db",
|
"infect:ci": "@parallel infect:ci:unit infect:ci:db",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ return [
|
|||||||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||||
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
|
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
|
||||||
|
Option\UrlShortener\AppendExtraPathConfigOption::class,
|
||||||
Option\Tracking\IpAnonymizationConfigOption::class,
|
Option\Tracking\IpAnonymizationConfigOption::class,
|
||||||
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
||||||
Option\Tracking\DisableTrackParamConfigOption::class,
|
Option\Tracking\DisableTrackParamConfigOption::class,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ return [
|
|||||||
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
|
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
|
||||||
IpAddress::class,
|
IpAddress::class,
|
||||||
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
|
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
|
||||||
|
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
|
||||||
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
|
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
|
||||||
Core\ErrorHandler\NotFoundRedirectHandler::class,
|
Core\ErrorHandler\NotFoundRedirectHandler::class,
|
||||||
Core\ErrorHandler\NotFoundTemplateHandler::class,
|
Core\ErrorHandler\NotFoundTemplateHandler::class,
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ return [
|
|||||||
'validate_url' => false, // Deprecated
|
'validate_url' => false, // Deprecated
|
||||||
'visits_webhooks' => [],
|
'visits_webhooks' => [],
|
||||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||||
|
'auto_resolve_titles' => false,
|
||||||
|
'append_extra_path' => false,
|
||||||
|
|
||||||
|
// TODO Move these two options to their own config namespace. Maybe "redirects".
|
||||||
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
||||||
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||||
'auto_resolve_titles' => false,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$isSwoole = extension_loaded('swoole');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'url_shortener' => [
|
'url_shortener' => [
|
||||||
'domain' => [
|
'domain' => [
|
||||||
'schema' => 'http',
|
'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\EntityManager;
|
||||||
use Doctrine\ORM\Tools\Console\ConsoleRunner;
|
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);
|
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 Mezzio\Application;
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
use Symfony\Component\Console\Application as CliApp;
|
|
||||||
|
|
||||||
return function (bool $isCli = false): void {
|
return static function (): void {
|
||||||
/** @var ContainerInterface $container */
|
/** @var ContainerInterface $container */
|
||||||
$container = include __DIR__ . '/container.php';
|
$container = include __DIR__ . '/container.php';
|
||||||
$app = $container->get($isCli ? CliApp::class : Application::class);
|
$app = $container->get(Application::class);
|
||||||
|
|
||||||
$app->run();
|
$app->run();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,26 +35,17 @@ if ($isApiTest) {
|
|||||||
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
|
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
$buildDbConnection = function (): array {
|
$buildDbConnection = static function (): array {
|
||||||
$driver = env('DB_DRIVER', 'sqlite');
|
$driver = env('DB_DRIVER', 'sqlite');
|
||||||
$isCi = env('CI', false);
|
$isCi = env('CI', false);
|
||||||
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
$getMysqlHost = static fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
||||||
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
$getCiMysqlPort = static fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
||||||
|
|
||||||
$driverConfigMap = [
|
return match ($driver) {
|
||||||
'sqlite' => [
|
'sqlite' => [
|
||||||
'driver' => 'pdo_sqlite',
|
'driver' => 'pdo_sqlite',
|
||||||
'path' => sys_get_temp_dir() . '/shlink-tests.db',
|
'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' => [
|
'postgres' => [
|
||||||
'driver' => 'pdo_pgsql',
|
'driver' => 'pdo_pgsql',
|
||||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
|
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
|
||||||
@@ -71,10 +62,16 @@ $buildDbConnection = function (): array {
|
|||||||
'password' => 'Passw0rd!',
|
'password' => 'Passw0rd!',
|
||||||
'dbname' => 'shlink_test',
|
'dbname' => 'shlink_test',
|
||||||
],
|
],
|
||||||
];
|
default => [ // mysql and maria
|
||||||
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
|
'driver' => 'pdo_mysql',
|
||||||
|
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
|
||||||
return $driverConfigMap[$driver] ?? [];
|
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
|
||||||
|
'user' => 'root',
|
||||||
|
'password' => 'root',
|
||||||
|
'dbname' => 'shlink_test',
|
||||||
|
'charset' => 'utf8',
|
||||||
|
],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [
|
$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [
|
||||||
@@ -120,7 +117,7 @@ return [
|
|||||||
'name' => 'start_collecting_coverage',
|
'name' => 'start_collecting_coverage',
|
||||||
'path' => '/api-tests/start-coverage',
|
'path' => '/api-tests/start-coverage',
|
||||||
'middleware' => middleware(static function () use (&$coverage) {
|
'middleware' => middleware(static function () use (&$coverage) {
|
||||||
if ($coverage) {
|
if ($coverage) { // @phpstan-ignore-line
|
||||||
$coverage->start('API tests');
|
$coverage->start('API tests');
|
||||||
}
|
}
|
||||||
return new EmptyResponse();
|
return new EmptyResponse();
|
||||||
@@ -131,7 +128,7 @@ return [
|
|||||||
'name' => 'dump_coverage',
|
'name' => 'dump_coverage',
|
||||||
'path' => '/api-tests/stop-coverage',
|
'path' => '/api-tests/stop-coverage',
|
||||||
'middleware' => middleware(static function () use (&$coverage) {
|
'middleware' => middleware(static function () use (&$coverage) {
|
||||||
if ($coverage) {
|
if ($coverage) { // @phpstan-ignore-line
|
||||||
$basePath = __DIR__ . '/../../build/coverage-api';
|
$basePath = __DIR__ . '/../../build/coverage-api';
|
||||||
$coverage->stop();
|
$coverage->stop();
|
||||||
(new PHP())->process($coverage, $basePath . '.cov');
|
(new PHP())->process($coverage, $basePath . '.cov');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ server {
|
|||||||
|
|
||||||
location ~ \.php$ {
|
location ~ \.php$ {
|
||||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||||
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
|
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
|
||||||
fastcgi_index index.php;
|
fastcgi_index index.php;
|
||||||
include fastcgi.conf;
|
include fastcgi.conf;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM php:8.0.6-fpm-alpine3.13
|
FROM php:8.0.9-fpm-alpine3.14
|
||||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
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 PDO_SQLSRV_VERSION 5.9.0
|
||||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
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>
|
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 PDO_SQLSRV_VERSION 5.9.0
|
||||||
ENV INOTIFY_VERSION 3.0.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
|
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||||
|
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ final class Version20180913205455 extends AbstractMigration
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
|
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
data/migrations/Version20210720143824.php
Normal file
41
data/migrations/Version20210720143824.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\DBAL\Schema\Table;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20210720143824 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$domainsTable = $schema->getTable('domains');
|
||||||
|
$this->skipIf($domainsTable->hasColumn('base_url_redirect'));
|
||||||
|
|
||||||
|
$this->createRedirectColumn($domainsTable, 'base_url_redirect');
|
||||||
|
$this->createRedirectColumn($domainsTable, 'regular_not_found_redirect');
|
||||||
|
$this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createRedirectColumn(Table $table, string $columnName): void
|
||||||
|
{
|
||||||
|
$table->addColumn($columnName, Types::STRING, [
|
||||||
|
'notnull' => false,
|
||||||
|
'default' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$domainsTable = $schema->getTable('domains');
|
||||||
|
$this->skipIf(! $domainsTable->hasColumn('base_url_redirect'));
|
||||||
|
|
||||||
|
$domainsTable->dropColumn('base_url_redirect');
|
||||||
|
$domainsTable->dropColumn('regular_not_found_redirect');
|
||||||
|
$domainsTable->dropColumn('invalid_short_url_redirect');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
log_errors_max_len=0
|
log_errors_max_len=0
|
||||||
zend.assertions=1
|
zend.assertions=1
|
||||||
assert.exception=1
|
assert.exception=1
|
||||||
|
memory_limit=256M
|
||||||
|
|||||||
@@ -111,9 +111,10 @@ return [
|
|||||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
||||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||||
|
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
|
||||||
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
||||||
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
||||||
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
|
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
|
||||||
],
|
],
|
||||||
|
|
||||||
'tracking' => [
|
'tracking' => [
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
|
|||||||
php bin/cli visit:download-db -n -q
|
php bin/cli visit:download-db -n -q
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Periodicaly run visit:locate every hour
|
||||||
|
# https://shlink.io/documentation/long-running-tasks/#locate-visits
|
||||||
|
# set env var "ENABLE_PERIODIC_VISIT_LOCATE=true" to enable
|
||||||
|
if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
|
||||||
|
echo "Configuring periodic visit locate..."
|
||||||
|
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
|
||||||
|
/usr/sbin/crond &
|
||||||
|
fi
|
||||||
|
|
||||||
# When restarting the container, swoole might think it is already in execution
|
# When restarting the container, swoole might think it is already in execution
|
||||||
# This forces the app to be started every second until the exit code is 0
|
# This forces the app to be started every second until the exit code is 0
|
||||||
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
|
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
|
||||||
|
|||||||
20
docs/swagger/definitions/NotFoundRedirects.json
Normal file
20
docs/swagger/definitions/NotFoundRedirects.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"baseUrlRedirect": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "URL to redirect to when a user hits the domain's base URL"
|
||||||
|
},
|
||||||
|
"regular404Redirect": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "URL to redirect to when a user hits a not found URL other than an invalid short URL"
|
||||||
|
},
|
||||||
|
"invalidShortUrlRedirect": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "URL to redirect to when a user hits an invalid short URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "The list of tags",
|
"description": "The list of domains",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -33,13 +33,16 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["domain", "isDefault"],
|
"required": ["domain", "isDefault", "redirects"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"domain": {
|
"domain": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"isDefault": {
|
"isDefault": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"redirects": {
|
||||||
|
"$ref": "../definitions/NotFoundRedirects.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,15 +59,30 @@
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"domain": "example.com",
|
"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",
|
"domain": "aaa.com",
|
||||||
"isDefault": false
|
"isDefault": false,
|
||||||
|
"redirects": {
|
||||||
|
"baseUrlRedirect": null,
|
||||||
|
"regular404Redirect": null,
|
||||||
|
"invalidShortUrlRedirect": null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"domain": "bbb.com",
|
"domain": "bbb.com",
|
||||||
"isDefault": false
|
"isDefault": false,
|
||||||
|
"redirects": {
|
||||||
|
"baseUrlRedirect": null,
|
||||||
|
"regular404Redirect": null,
|
||||||
|
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
124
docs/swagger/paths/v2_domains_redirects.json
Normal file
124
docs/swagger/paths/v2_domains_redirects.json
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"patch": {
|
||||||
|
"operationId": "setDomainRedirects",
|
||||||
|
"tags": [
|
||||||
|
"Domains"
|
||||||
|
],
|
||||||
|
"summary": "Sets domain \"not found\" redirects",
|
||||||
|
"description": "Sets the URLs that you want a visitor to get redirected to for \not found\" URLs for a specific domain",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKey": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"description": "Request body.",
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"required": ["domain"],
|
||||||
|
"properties": {
|
||||||
|
"domain": {
|
||||||
|
"description": "The domain's authority for which you want to set redirects",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "../definitions/NotFoundRedirects.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The domain's redirects after the update, when existing redirects have been merged with provided ones.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"required": ["baseUrlRedirect", "regular404Redirect", "invalidShortUrlRedirect"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "../definitions/NotFoundRedirects.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"application/json": {
|
||||||
|
"baseUrlRedirect": "https://example.com/my-landing-page",
|
||||||
|
"regular404Redirect": null,
|
||||||
|
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Provided data is invalid.",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "../definitions/Error.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["invalidElements"],
|
||||||
|
"properties": {
|
||||||
|
"invalidElements": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"domain",
|
||||||
|
"baseUrlRedirect",
|
||||||
|
"regular404Redirect",
|
||||||
|
"invalidShortUrlRedirect"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Default domain was provided, and it cannot be edited this way.",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "../definitions/Error.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Unexpected error.",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "../definitions/Error.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"URL Shortener"
|
"URL Shortener"
|
||||||
],
|
],
|
||||||
"summary": "Short URL QR code",
|
"summary": "Short URL QR code",
|
||||||
"description": "Generates a QR code image pointing to a short URL",
|
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "shortCode",
|
"name": "shortCode",
|
||||||
@@ -35,10 +35,8 @@
|
|||||||
"required": false,
|
"required": false,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": ["png", "svg"],
|
||||||
"png",
|
"default": "png"
|
||||||
"svg"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,6 +49,17 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"default": 0
|
"default": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "errorCorrection",
|
||||||
|
"in": "query",
|
||||||
|
"description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["L", "M", "Q", "H"],
|
||||||
|
"default": "L"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Shlink",
|
"title": "Shlink",
|
||||||
"description": "Shlink, the self-hosted URL shortener",
|
"description": "Shlink, the self-hosted URL shortener",
|
||||||
@@ -102,6 +102,9 @@
|
|||||||
"/rest/v{version}/domains": {
|
"/rest/v{version}/domains": {
|
||||||
"$ref": "paths/v2_domains.json"
|
"$ref": "paths/v2_domains.json"
|
||||||
},
|
},
|
||||||
|
"/rest/v{version}/domains/redirects": {
|
||||||
|
"$ref": "paths/v2_domains_redirects.json"
|
||||||
|
},
|
||||||
|
|
||||||
"/rest/v{version}/mercure-info": {
|
"/rest/v{version}/mercure-info": {
|
||||||
"$ref": "paths/v2_mercure-info.json"
|
"$ref": "paths/v2_mercure-info.json"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ return [
|
|||||||
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
|
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
|
||||||
|
|
||||||
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::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\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
|
||||||
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
|
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ return [
|
|||||||
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
|
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Command\Domain\ListDomainsCommand::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\Tag\DeleteTagsCommand::class => [TagService::class],
|
||||||
|
|
||||||
Command\Domain\ListDomainsCommand::class => [DomainService::class],
|
Command\Domain\ListDomainsCommand::class => [DomainService::class],
|
||||||
|
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
|
||||||
|
|
||||||
Command\Db\CreateDatabaseCommand::class => [
|
Command\Db\CreateDatabaseCommand::class => [
|
||||||
LockFactory::class,
|
LockFactory::class,
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
|||||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
class RoleResolver implements RoleResolverInterface
|
class RoleResolver implements RoleResolverInterface
|
||||||
{
|
{
|
||||||
private DomainServiceInterface $domainService;
|
public function __construct(private DomainServiceInterface $domainService)
|
||||||
|
|
||||||
public function __construct(DomainServiceInterface $domainService)
|
|
||||||
{
|
{
|
||||||
$this->domainService = $domainService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function determineRoles(InputInterface $input): array
|
public function determineRoles(InputInterface $input): array
|
||||||
@@ -26,7 +25,7 @@ class RoleResolver implements RoleResolverInterface
|
|||||||
if ($author) {
|
if ($author) {
|
||||||
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
|
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
|
||||||
}
|
}
|
||||||
if ($domainAuthority !== null) {
|
if (is_string($domainAuthority)) {
|
||||||
$domain = $this->domainService->getOrCreate($domainAuthority);
|
$domain = $this->domainService->getOrCreate($domainAuthority);
|
||||||
$roleDefinitions[] = RoleDefinition::forDomain($domain);
|
$roleDefinitions[] = RoleDefinition::forDomain($domain);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,9 @@ class DisableKeyCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'api-key:disable';
|
public const NAME = 'api-key:disable';
|
||||||
|
|
||||||
private ApiKeyServiceInterface $apiKeyService;
|
public function __construct(private ApiKeyServiceInterface $apiKeyService)
|
||||||
|
|
||||||
public function __construct(ApiKeyServiceInterface $apiKeyService)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->apiKeyService = $apiKeyService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
|
|||||||
@@ -23,14 +23,11 @@ class GenerateKeyCommand extends BaseCommand
|
|||||||
{
|
{
|
||||||
public const NAME = 'api-key:generate';
|
public const NAME = 'api-key:generate';
|
||||||
|
|
||||||
private ApiKeyServiceInterface $apiKeyService;
|
public function __construct(
|
||||||
private RoleResolverInterface $roleResolver;
|
private ApiKeyServiceInterface $apiKeyService,
|
||||||
|
private RoleResolverInterface $roleResolver
|
||||||
public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
|
) {
|
||||||
{
|
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->apiKeyService = $apiKeyService;
|
|
||||||
$this->roleResolver = $roleResolver;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
@@ -100,7 +97,7 @@ class GenerateKeyCommand extends BaseCommand
|
|||||||
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
|
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
|
||||||
|
|
||||||
if (! $apiKey->isAdmin()) {
|
if (! $apiKey->isAdmin()) {
|
||||||
ShlinkTable::fromOutput($io)->render(
|
ShlinkTable::default($io)->render(
|
||||||
['Role name', 'Role metadata'],
|
['Role name', 'Role metadata'],
|
||||||
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
|
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -27,12 +27,9 @@ class ListKeysCommand extends BaseCommand
|
|||||||
|
|
||||||
public const NAME = 'api-key:list';
|
public const NAME = 'api-key:list';
|
||||||
|
|
||||||
private ApiKeyServiceInterface $apiKeyService;
|
public function __construct(private ApiKeyServiceInterface $apiKeyService)
|
||||||
|
|
||||||
public function __construct(ApiKeyServiceInterface $apiKeyService)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->apiKeyService = $apiKeyService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
@@ -61,7 +58,7 @@ class ListKeysCommand extends BaseCommand
|
|||||||
if (! $enabledOnly) {
|
if (! $enabledOnly) {
|
||||||
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
||||||
}
|
}
|
||||||
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
|
$rowData[] = $expiration?->toAtomString() ?? '-';
|
||||||
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
|
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
|
||||||
fn (string $roleName, array $meta) =>
|
fn (string $roleName, array $meta) =>
|
||||||
empty($meta)
|
empty($meta)
|
||||||
@@ -72,7 +69,7 @@ class ListKeysCommand extends BaseCommand
|
|||||||
return $rowData;
|
return $rowData;
|
||||||
});
|
});
|
||||||
|
|
||||||
ShlinkTable::fromOutput($output)->render(array_filter([
|
ShlinkTable::withRowSeparators($output)->render(array_filter([
|
||||||
'Key',
|
'Key',
|
||||||
'Name',
|
'Name',
|
||||||
! $enabledOnly ? 'Is enabled' : null,
|
! $enabledOnly ? 'Is enabled' : null,
|
||||||
|
|||||||
@@ -12,40 +12,36 @@ use function Shlinkio\Shlink\Core\kebabCaseToCamelCase;
|
|||||||
use function sprintf;
|
use function sprintf;
|
||||||
use function str_contains;
|
use function str_contains;
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
abstract class BaseCommand extends Command
|
abstract class BaseCommand extends Command
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param mixed|null $default
|
* @param string|string[]|bool|null $default
|
||||||
*/
|
*/
|
||||||
protected function addOptionWithDeprecatedFallback(
|
protected function addOptionWithDeprecatedFallback(
|
||||||
string $name,
|
string $name,
|
||||||
?string $shortcut = null,
|
?string $shortcut = null,
|
||||||
?int $mode = null,
|
?int $mode = null,
|
||||||
string $description = '',
|
string $description = '',
|
||||||
$default = null
|
bool|string|array|null $default = null,
|
||||||
): self {
|
): self {
|
||||||
$this->addOption($name, $shortcut, $mode, $description, $default);
|
$this->addOption($name, $shortcut, $mode, $description, $default);
|
||||||
|
|
||||||
if (str_contains($name, '-')) {
|
if (str_contains($name, '-')) {
|
||||||
$camelCaseName = kebabCaseToCamelCase($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 $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// @phpstan-ignore-next-line
|
||||||
* @return bool|string|string[]|null
|
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name) // phpcs:ignore
|
||||||
*/
|
|
||||||
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name)
|
|
||||||
{
|
{
|
||||||
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
|
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
|
||||||
$camelCaseName = kebabCaseToCamelCase($name);
|
$camelCaseName = kebabCaseToCamelCase($name);
|
||||||
|
$resolvedOptionName = str_contains($rawInput, $camelCaseName) ? $camelCaseName : $name;
|
||||||
|
|
||||||
if (str_contains($rawInput, $camelCaseName)) {
|
return $input->getOption($resolvedOptionName);
|
||||||
return $input->getOption($camelCaseName);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $input->getOption($name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,16 +13,14 @@ use Symfony\Component\Process\PhpExecutableFinder;
|
|||||||
|
|
||||||
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||||
{
|
{
|
||||||
private ProcessRunnerInterface $processRunner;
|
|
||||||
private string $phpBinary;
|
private string $phpBinary;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
LockFactory $locker,
|
LockFactory $locker,
|
||||||
ProcessRunnerInterface $processRunner,
|
private ProcessRunnerInterface $processRunner,
|
||||||
PhpExecutableFinder $phpFinder
|
PhpExecutableFinder $phpFinder
|
||||||
) {
|
) {
|
||||||
parent::__construct($locker);
|
parent::__construct($locker);
|
||||||
$this->processRunner = $processRunner;
|
|
||||||
$this->phpBinary = $phpFinder->find(false) ?: 'php';
|
$this->phpBinary = $phpFinder->find(false) ?: 'php';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +32,6 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
|||||||
|
|
||||||
protected function getLockConfig(): LockedCommandConfig
|
protected function getLockConfig(): LockedCommandConfig
|
||||||
{
|
{
|
||||||
return LockedCommandConfig::blocking($this->getName());
|
return LockedCommandConfig::blocking($this->getName() ?? static::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,19 +21,14 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
|||||||
public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
|
public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
|
||||||
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
|
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
|
||||||
|
|
||||||
private Connection $regularConn;
|
|
||||||
private Connection $noDbNameConn;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
LockFactory $locker,
|
LockFactory $locker,
|
||||||
ProcessRunnerInterface $processRunner,
|
ProcessRunnerInterface $processRunner,
|
||||||
PhpExecutableFinder $phpFinder,
|
PhpExecutableFinder $phpFinder,
|
||||||
Connection $conn,
|
private Connection $regularConn,
|
||||||
Connection $noDbNameConn
|
private Connection $noDbNameConn
|
||||||
) {
|
) {
|
||||||
parent::__construct($locker, $processRunner, $phpFinder);
|
parent::__construct($locker, $processRunner, $phpFinder);
|
||||||
$this->regularConn = $conn;
|
|
||||||
$this->noDbNameConn = $noDbNameConn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
|
|||||||
114
module/CLI/src/Command/Domain/DomainRedirectsCommand.php
Normal file
114
module/CLI/src/Command/Domain/DomainRedirectsCommand.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||||
|
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
use function Functional\filter;
|
||||||
|
use function Functional\invoke;
|
||||||
|
use function sprintf;
|
||||||
|
use function str_contains;
|
||||||
|
|
||||||
|
class DomainRedirectsCommand extends Command
|
||||||
|
{
|
||||||
|
public const NAME = 'domain:redirects';
|
||||||
|
|
||||||
|
public function __construct(private DomainServiceInterface $domainService)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->setName(self::NAME)
|
||||||
|
->setDescription('Set specific "not found" redirects for individual domains.')
|
||||||
|
->addArgument(
|
||||||
|
'domain',
|
||||||
|
InputArgument::REQUIRED,
|
||||||
|
'The domain authority to which you want to set the specific redirects',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||||
|
{
|
||||||
|
/** @var string|null $domain */
|
||||||
|
$domain = $input->getArgument('domain');
|
||||||
|
if ($domain !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
|
||||||
|
|
||||||
|
/** @var string[] $availableDomains */
|
||||||
|
$availableDomains = invoke(
|
||||||
|
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()),
|
||||||
|
'toString',
|
||||||
|
);
|
||||||
|
if (empty($availableDomains)) {
|
||||||
|
$input->setArgument('domain', $askNewDomain());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedOption = $io->choice(
|
||||||
|
'Select the domain to configure',
|
||||||
|
[...$availableDomains, '<options=bold>New domain</>'],
|
||||||
|
);
|
||||||
|
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$domainAuthority = $input->getArgument('domain');
|
||||||
|
$domain = $this->domainService->findByAuthority($domainAuthority);
|
||||||
|
|
||||||
|
$ask = static function (string $message, ?string $current) use ($io): ?string {
|
||||||
|
if ($current === null) {
|
||||||
|
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
|
||||||
|
}
|
||||||
|
|
||||||
|
$choice = $io->choice($message, [
|
||||||
|
sprintf('Keep current one: [%s]', $current),
|
||||||
|
'Set new redirect URL',
|
||||||
|
'Remove redirect',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return match ($choice) {
|
||||||
|
'Set new redirect URL' => $io->ask('New redirect URL'),
|
||||||
|
'Remove redirect' => null,
|
||||||
|
default => $current,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
|
||||||
|
$ask(
|
||||||
|
'URL to redirect to when a user hits this domain\'s base URL',
|
||||||
|
$domain?->baseUrlRedirect(),
|
||||||
|
),
|
||||||
|
$ask(
|
||||||
|
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
|
||||||
|
$domain?->regular404Redirect(),
|
||||||
|
),
|
||||||
|
$ask(
|
||||||
|
'URL to redirect to when a user hits an invalid short URL',
|
||||||
|
$domain?->invalidShortUrlRedirect(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
|
||||||
|
|
||||||
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
|
|||||||
|
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||||
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
use function Functional\map;
|
use function Functional\map;
|
||||||
@@ -18,30 +20,58 @@ class ListDomainsCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'domain:list';
|
public const NAME = 'domain:list';
|
||||||
|
|
||||||
private DomainServiceInterface $domainService;
|
public function __construct(private DomainServiceInterface $domainService)
|
||||||
|
|
||||||
public function __construct(DomainServiceInterface $domainService)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->domainService = $domainService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->setName(self::NAME)
|
->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
|
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
{
|
{
|
||||||
$domains = $this->domainService->listDomains();
|
$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(
|
$table->render(
|
||||||
['Domain', 'Is default'],
|
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
|
||||||
map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
|
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;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
|
||||||
|
{
|
||||||
|
$baseUrl = $config->baseUrlRedirect() ?? 'N/A';
|
||||||
|
$regular404 = $config->regular404Redirect() ?? 'N/A';
|
||||||
|
$invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
|
||||||
|
|
||||||
|
return <<<EOL
|
||||||
|
* Base URL: {$baseUrl}
|
||||||
|
* Regular 404: {$regular404}
|
||||||
|
* Invalid short URL: {$invalidShortUrl}
|
||||||
|
EOL;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,9 @@ class DeleteShortUrlCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'short-url:delete';
|
public const NAME = 'short-url:delete';
|
||||||
|
|
||||||
private DeleteShortUrlServiceInterface $deleteShortUrlService;
|
public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||||
|
|
||||||
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->deleteShortUrlService = $deleteShortUrlService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
|
|||||||
@@ -30,19 +30,12 @@ class GenerateShortUrlCommand extends BaseCommand
|
|||||||
{
|
{
|
||||||
public const NAME = 'short-url:generate';
|
public const NAME = 'short-url:generate';
|
||||||
|
|
||||||
private UrlShortenerInterface $urlShortener;
|
|
||||||
private ShortUrlStringifierInterface $stringifier;
|
|
||||||
private int $defaultShortCodeLength;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
UrlShortenerInterface $urlShortener,
|
private UrlShortenerInterface $urlShortener,
|
||||||
ShortUrlStringifierInterface $stringifier,
|
private ShortUrlStringifierInterface $stringifier,
|
||||||
int $defaultShortCodeLength
|
private int $defaultShortCodeLength
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->urlShortener = $urlShortener;
|
|
||||||
$this->stringifier = $stringifier;
|
|
||||||
$this->defaultShortCodeLength = $defaultShortCodeLength;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
|||||||
{
|
{
|
||||||
public const NAME = 'short-url:visits';
|
public const NAME = 'short-url:visits';
|
||||||
|
|
||||||
private VisitsStatsHelperInterface $visitsHelper;
|
public function __construct(private VisitsStatsHelperInterface $visitsHelper)
|
||||||
|
|
||||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
|
||||||
{
|
{
|
||||||
$this->visitsHelper = $visitsHelper;
|
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +81,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
|||||||
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
|
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
|
||||||
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
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;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
|||||||
|
|
||||||
public const NAME = 'short-url:list';
|
public const NAME = 'short-url:list';
|
||||||
|
|
||||||
private ShortUrlServiceInterface $shortUrlService;
|
public function __construct(
|
||||||
private DataTransformerInterface $transformer;
|
private ShortUrlServiceInterface $shortUrlService,
|
||||||
|
private DataTransformerInterface $transformer
|
||||||
public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer)
|
) {
|
||||||
{
|
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->shortUrlService = $shortUrlService;
|
|
||||||
$this->transformer = $transformer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function doConfigure(): void
|
protected function doConfigure(): void
|
||||||
@@ -129,8 +126,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
|||||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||||
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
||||||
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
|
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
|
||||||
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
|
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($all) {
|
if ($all) {
|
||||||
@@ -158,7 +155,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
|||||||
OutputInterface $output,
|
OutputInterface $output,
|
||||||
array $columnsMap,
|
array $columnsMap,
|
||||||
ShortUrlsParams $params,
|
ShortUrlsParams $params,
|
||||||
bool $all
|
bool $all,
|
||||||
): Paginator {
|
): Paginator {
|
||||||
$shortUrls = $this->shortUrlService->listShortUrls($params);
|
$shortUrls = $this->shortUrlService->listShortUrls($params);
|
||||||
|
|
||||||
@@ -167,7 +164,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
|||||||
return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl));
|
return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
ShlinkTable::fromOutput($output)->render(
|
ShlinkTable::default($output)->render(
|
||||||
array_keys($columnsMap),
|
array_keys($columnsMap),
|
||||||
$rows,
|
$rows,
|
||||||
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||||
@@ -203,14 +200,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
|||||||
}
|
}
|
||||||
if ($input->getOption('show-api-key')) {
|
if ($input->getOption('show-api-key')) {
|
||||||
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||||
(string) $shortUrl->authorApiKey();
|
(string) $shortUrl->authorApiKey();
|
||||||
}
|
}
|
||||||
if ($input->getOption('show-api-key-name')) {
|
if ($input->getOption('show-api-key-name')) {
|
||||||
$columnsMap['API Key Name'] = static function (array $_, ShortUrl $shortUrl): ?string {
|
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>
|
||||||
$apiKey = $shortUrl->authorApiKey();
|
$shortUrl->authorApiKey()?->name();
|
||||||
|
|
||||||
return $apiKey !== null ? $apiKey->name() : null;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $columnsMap;
|
return $columnsMap;
|
||||||
|
|||||||
@@ -21,12 +21,9 @@ class ResolveUrlCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'short-url:parse';
|
public const NAME = 'short-url:parse';
|
||||||
|
|
||||||
private ShortUrlResolverInterface $urlResolver;
|
public function __construct(private ShortUrlResolverInterface $urlResolver)
|
||||||
|
|
||||||
public function __construct(ShortUrlResolverInterface $urlResolver)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->urlResolver = $urlResolver;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
|
|||||||
@@ -17,12 +17,9 @@ class CreateTagCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'tag:create';
|
public const NAME = 'tag:create';
|
||||||
|
|
||||||
private TagServiceInterface $tagService;
|
public function __construct(private TagServiceInterface $tagService)
|
||||||
|
|
||||||
public function __construct(TagServiceInterface $tagService)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->tagService = $tagService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ class DeleteTagsCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'tag:delete';
|
public const NAME = 'tag:delete';
|
||||||
|
|
||||||
private TagServiceInterface $tagService;
|
public function __construct(private TagServiceInterface $tagService)
|
||||||
|
|
||||||
public function __construct(TagServiceInterface $tagService)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->tagService = $tagService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
|
|||||||
@@ -18,12 +18,9 @@ class ListTagsCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'tag:list';
|
public const NAME = 'tag:list';
|
||||||
|
|
||||||
private TagServiceInterface $tagService;
|
public function __construct(private TagServiceInterface $tagService)
|
||||||
|
|
||||||
public function __construct(TagServiceInterface $tagService)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->tagService = $tagService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
@@ -35,7 +32,7 @@ class ListTagsCommand extends Command
|
|||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
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;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,9 @@ class RenameTagCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'tag:rename';
|
public const NAME = 'tag:rename';
|
||||||
|
|
||||||
private TagServiceInterface $tagService;
|
public function __construct(private TagServiceInterface $tagService)
|
||||||
|
|
||||||
public function __construct(TagServiceInterface $tagService)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->tagService = $tagService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
|
|||||||
@@ -14,12 +14,9 @@ use function sprintf;
|
|||||||
|
|
||||||
abstract class AbstractLockedCommand extends Command
|
abstract class AbstractLockedCommand extends Command
|
||||||
{
|
{
|
||||||
private LockFactory $locker;
|
public function __construct(private LockFactory $locker)
|
||||||
|
|
||||||
public function __construct(LockFactory $locker)
|
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->locker = $locker;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final protected function execute(InputInterface $input, OutputInterface $output): ?int
|
final protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputOption;
|
|||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
abstract class AbstractWithDateRangeCommand extends BaseCommand
|
abstract class AbstractWithDateRangeCommand extends BaseCommand
|
||||||
@@ -49,7 +50,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
|
|||||||
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
|
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
|
||||||
{
|
{
|
||||||
$value = $this->getOptionWithDeprecatedFallback($input, $key);
|
$value = $this->getOptionWithDeprecatedFallback($input, $key);
|
||||||
if (empty($value)) {
|
if (empty($value) || ! is_string($value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
|
|||||||
));
|
));
|
||||||
|
|
||||||
if ($output->isVeryVerbose()) {
|
if ($output->isVeryVerbose()) {
|
||||||
$this->getApplication()->renderThrowable($e, $output);
|
$this->getApplication()?->renderThrowable($e, $output);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ final class LockedCommandConfig
|
|||||||
{
|
{
|
||||||
public const DEFAULT_TTL = 600.0; // 10 minutes
|
public const DEFAULT_TTL = 600.0; // 10 minutes
|
||||||
|
|
||||||
private string $lockName;
|
private function __construct(
|
||||||
private bool $isBlocking;
|
private string $lockName,
|
||||||
private float $ttl;
|
private bool $isBlocking,
|
||||||
|
private float $ttl = self::DEFAULT_TTL
|
||||||
private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL)
|
) {
|
||||||
{
|
|
||||||
$this->lockName = $lockName;
|
|
||||||
$this->isBlocking = $isBlocking;
|
|
||||||
$this->ttl = $ttl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function blocking(string $lockName): self
|
public static function blocking(string $lockName): self
|
||||||
|
|||||||
@@ -19,13 +19,11 @@ class DownloadGeoLiteDbCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'visit:download-db';
|
public const NAME = 'visit:download-db';
|
||||||
|
|
||||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
|
||||||
private ?ProgressBar $progressBar = null;
|
private ?ProgressBar $progressBar = null;
|
||||||
|
|
||||||
public function __construct(GeolocationDbUpdaterInterface $dbUpdater)
|
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->dbUpdater = $dbUpdater;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
@@ -47,8 +45,8 @@ class DownloadGeoLiteDbCommand extends Command
|
|||||||
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||||
$this->progressBar = new ProgressBar($io);
|
$this->progressBar = new ProgressBar($io);
|
||||||
}, function (int $total, int $downloaded): void {
|
}, function (int $total, int $downloaded): void {
|
||||||
$this->progressBar->setMaxSteps($total);
|
$this->progressBar?->setMaxSteps($total);
|
||||||
$this->progressBar->setProgress($downloaded);
|
$this->progressBar?->setProgress($downloaded);
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($this->progressBar === null) {
|
if ($this->progressBar === null) {
|
||||||
@@ -71,7 +69,7 @@ class DownloadGeoLiteDbCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($io->isVerbose()) {
|
if ($io->isVerbose()) {
|
||||||
$this->getApplication()->renderThrowable($e, $io);
|
$this->getApplication()?->renderThrowable($e, $io);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
|
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
|
||||||
|
|||||||
@@ -30,19 +30,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||||||
{
|
{
|
||||||
public const NAME = 'visit:locate';
|
public const NAME = 'visit:locate';
|
||||||
|
|
||||||
private VisitLocatorInterface $visitLocator;
|
|
||||||
private IpLocationResolverInterface $ipLocationResolver;
|
|
||||||
|
|
||||||
private SymfonyStyle $io;
|
private SymfonyStyle $io;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
VisitLocatorInterface $visitLocator,
|
private VisitLocatorInterface $visitLocator,
|
||||||
IpLocationResolverInterface $ipLocationResolver,
|
private IpLocationResolverInterface $ipLocationResolver,
|
||||||
LockFactory $locker
|
LockFactory $locker
|
||||||
) {
|
) {
|
||||||
parent::__construct($locker);
|
parent::__construct($locker);
|
||||||
$this->visitLocator = $visitLocator;
|
|
||||||
$this->ipLocationResolver = $ipLocationResolver;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
@@ -124,7 +119,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->io->error($e->getMessage());
|
$this->io->error($e->getMessage());
|
||||||
if ($this->io->isVerbose()) {
|
if ($this->io->isVerbose()) {
|
||||||
$this->getApplication()->renderThrowable($e, $this->io);
|
$this->getApplication()?->renderThrowable($e, $this->io);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ExitCodes::EXIT_FAILURE;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
@@ -144,7 +139,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||||||
throw IpCannotBeLocatedException::forEmptyAddress();
|
throw IpCannotBeLocatedException::forEmptyAddress();
|
||||||
}
|
}
|
||||||
|
|
||||||
$ipAddr = $visit->getRemoteAddr();
|
$ipAddr = $visit->getRemoteAddr() ?? '';
|
||||||
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
|
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
|
||||||
if ($ipAddr === IpAddress::LOCALHOST) {
|
if ($ipAddr === IpAddress::LOCALHOST) {
|
||||||
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
|
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
|
||||||
@@ -156,7 +151,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||||||
} catch (WrongIpException $e) {
|
} catch (WrongIpException $e) {
|
||||||
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
|
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
|
||||||
if ($this->io->isVerbose()) {
|
if ($this->io->isVerbose()) {
|
||||||
$this->getApplication()->renderThrowable($e, $this->io);
|
$this->getApplication()?->renderThrowable($e, $this->io);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw IpCannotBeLocatedException::forError($e);
|
throw IpCannotBeLocatedException::forError($e);
|
||||||
@@ -173,7 +168,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||||||
|
|
||||||
private function checkDbUpdate(InputInterface $input): void
|
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);
|
$exitCode = $downloadDbCommand->run($input, $this->io);
|
||||||
|
|
||||||
if ($exitCode === ExitCodes::EXIT_FAILURE) {
|
if ($exitCode === ExitCodes::EXIT_FAILURE) {
|
||||||
@@ -183,6 +183,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
|||||||
|
|
||||||
protected function getLockConfig(): LockedCommandConfig
|
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;
|
return $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static function withInvalidEpochInOldDb(mixed $buildEpoch): self
|
||||||
* @param mixed $buildEpoch
|
|
||||||
*/
|
|
||||||
public static function withInvalidEpochInOldDb($buildEpoch): self
|
|
||||||
{
|
{
|
||||||
$e = new self(sprintf(
|
$e = new self(sprintf(
|
||||||
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
|
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
|
||||||
|
|||||||
@@ -19,21 +19,12 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
|||||||
{
|
{
|
||||||
private const LOCK_NAME = 'geolocation-db-update';
|
private const LOCK_NAME = 'geolocation-db-update';
|
||||||
|
|
||||||
private DbUpdaterInterface $dbUpdater;
|
|
||||||
private Reader $geoLiteDbReader;
|
|
||||||
private LockFactory $locker;
|
|
||||||
private TrackingOptions $trackingOptions;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
DbUpdaterInterface $dbUpdater,
|
private DbUpdaterInterface $dbUpdater,
|
||||||
Reader $geoLiteDbReader,
|
private Reader $geoLiteDbReader,
|
||||||
LockFactory $locker,
|
private LockFactory $locker,
|
||||||
TrackingOptions $trackingOptions
|
private TrackingOptions $trackingOptions
|
||||||
) {
|
) {
|
||||||
$this->dbUpdater = $dbUpdater;
|
|
||||||
$this->geoLiteDbReader = $geoLiteDbReader;
|
|
||||||
$this->locker = $locker;
|
|
||||||
$this->trackingOptions = $trackingOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ use function str_replace;
|
|||||||
|
|
||||||
class ProcessRunner implements ProcessRunnerInterface
|
class ProcessRunner implements ProcessRunnerInterface
|
||||||
{
|
{
|
||||||
private ProcessHelper $helper;
|
|
||||||
private Closure $createProcess;
|
private Closure $createProcess;
|
||||||
|
|
||||||
public function __construct(ProcessHelper $helper, ?callable $createProcess = null)
|
public function __construct(private ProcessHelper $helper, ?callable $createProcess = null)
|
||||||
{
|
{
|
||||||
$this->helper = $helper;
|
|
||||||
$this->createProcess = $createProcess !== null
|
$this->createProcess = $createProcess !== null
|
||||||
? Closure::fromCallable($createProcess)
|
? Closure::fromCallable($createProcess)
|
||||||
: static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);
|
: static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);
|
||||||
|
|||||||
@@ -5,23 +5,33 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\CLI\Util;
|
namespace Shlinkio\Shlink\CLI\Util;
|
||||||
|
|
||||||
use Symfony\Component\Console\Helper\Table;
|
use Symfony\Component\Console\Helper\Table;
|
||||||
|
use Symfony\Component\Console\Helper\TableSeparator;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
use function Functional\intersperse;
|
||||||
|
|
||||||
final class ShlinkTable
|
final class ShlinkTable
|
||||||
{
|
{
|
||||||
private const DEFAULT_STYLE_NAME = 'default';
|
private const DEFAULT_STYLE_NAME = 'default';
|
||||||
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
|
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
|
||||||
|
|
||||||
private ?Table $baseTable;
|
private function __construct(private Table $baseTable, private bool $withRowSeparators)
|
||||||
|
|
||||||
public function __construct(Table $baseTable)
|
|
||||||
{
|
{
|
||||||
$this->baseTable = $baseTable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromOutput(OutputInterface $output): self
|
public static function default(OutputInterface $output): self
|
||||||
{
|
{
|
||||||
return new self(new Table($output));
|
return new self(new Table($output), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withRowSeparators(OutputInterface $output): self
|
||||||
|
{
|
||||||
|
return new self(new Table($output), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromBaseTable(Table $baseTable): self
|
||||||
|
{
|
||||||
|
return new self($baseTable, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
|
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
|
||||||
@@ -29,11 +39,12 @@ final class ShlinkTable
|
|||||||
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
|
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
|
||||||
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
|
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
|
||||||
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
|
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
|
||||||
|
$tableRows = $this->withRowSeparators ? intersperse($rows, new TableSeparator()) : $rows;
|
||||||
|
|
||||||
$table = clone $this->baseTable;
|
$table = clone $this->baseTable;
|
||||||
$table->setStyle($style)
|
$table->setStyle($style)
|
||||||
->setHeaders($headers)
|
->setHeaders($headers)
|
||||||
->setRows($rows)
|
->setRows($tableRows)
|
||||||
->setFooterTitle($footerTitle)
|
->setFooterTitle($footerTitle)
|
||||||
->setHeaderTitle($headerTitle)
|
->setHeaderTitle($headerTitle)
|
||||||
->render();
|
->render();
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ class RoleResolverTest extends TestCase
|
|||||||
public function properRolesAreResolvedBasedOnInput(
|
public function properRolesAreResolvedBasedOnInput(
|
||||||
InputInterface $input,
|
InputInterface $input,
|
||||||
array $expectedRoles,
|
array $expectedRoles,
|
||||||
int $expectedDomainCalls
|
int $expectedDomainCalls,
|
||||||
): void {
|
): void {
|
||||||
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
|
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
|
||||||
(new Domain('example.com'))->setId('1'),
|
Domain::withAuthority('example.com')->setId('1'),
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $this->resolver->determineRoles($input);
|
$result = $this->resolver->determineRoles($input);
|
||||||
@@ -47,7 +47,7 @@ class RoleResolverTest extends TestCase
|
|||||||
|
|
||||||
public function provideRoles(): iterable
|
public function provideRoles(): iterable
|
||||||
{
|
{
|
||||||
$domain = (new Domain('example.com'))->setId('1');
|
$domain = Domain::withAuthority('example.com')->setId('1');
|
||||||
$buildInput = function (array $definition): InputInterface {
|
$buildInput = function (array $definition): InputInterface {
|
||||||
$input = $this->prophesize(InputInterface::class);
|
$input = $this->prophesize(InputInterface::class);
|
||||||
|
|
||||||
@@ -68,6 +68,21 @@ class RoleResolverTest extends TestCase
|
|||||||
[RoleDefinition::forDomain($domain)],
|
[RoleDefinition::forDomain($domain)],
|
||||||
1,
|
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' => [
|
yield 'author role only' => [
|
||||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
|
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
|
||||||
[RoleDefinition::forAuthoredShortUrls()],
|
[RoleDefinition::forAuthoredShortUrls()],
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ class ListKeysCommandTest extends TestCase
|
|||||||
| Key | Name | Is enabled | Expiration date | Roles |
|
| Key | Name | Is enabled | Expiration date | Roles |
|
||||||
+--------------------------------------+------+------------+-----------------+-------+
|
+--------------------------------------+------+------------+-----------------+-------+
|
||||||
| {$apiKey1} | - | +++ | - | Admin |
|
| {$apiKey1} | - | +++ | - | Admin |
|
||||||
|
+--------------------------------------+------+------------+-----------------+-------+
|
||||||
| {$apiKey2} | - | +++ | - | Admin |
|
| {$apiKey2} | - | +++ | - | Admin |
|
||||||
|
+--------------------------------------+------+------------+-----------------+-------+
|
||||||
| {$apiKey3} | - | +++ | - | Admin |
|
| {$apiKey3} | - | +++ | - | Admin |
|
||||||
+--------------------------------------+------+------------+-----------------+-------+
|
+--------------------------------------+------+------------+-----------------+-------+
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ class ListKeysCommandTest extends TestCase
|
|||||||
| Key | Name | Expiration date | Roles |
|
| Key | Name | Expiration date | Roles |
|
||||||
+--------------------------------------+------+-----------------+-------+
|
+--------------------------------------+------+-----------------+-------+
|
||||||
| {$apiKey1} | - | - | Admin |
|
| {$apiKey1} | - | - | Admin |
|
||||||
|
+--------------------------------------+------+-----------------+-------+
|
||||||
| {$apiKey2} | - | - | Admin |
|
| {$apiKey2} | - | - | Admin |
|
||||||
+--------------------------------------+------+-----------------+-------+
|
+--------------------------------------+------+-----------------+-------+
|
||||||
|
|
||||||
@@ -76,11 +79,13 @@ class ListKeysCommandTest extends TestCase
|
|||||||
[
|
[
|
||||||
$apiKey1 = ApiKey::create(),
|
$apiKey1 = ApiKey::create(),
|
||||||
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
|
$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(),
|
$apiKey4 = ApiKey::create(),
|
||||||
$apiKey5 = $this->apiKeyWithRoles([
|
$apiKey5 = $this->apiKeyWithRoles([
|
||||||
RoleDefinition::forAuthoredShortUrls(),
|
RoleDefinition::forAuthoredShortUrls(),
|
||||||
RoleDefinition::forDomain((new Domain('example.com'))->setId('1')),
|
RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1')),
|
||||||
]),
|
]),
|
||||||
$apiKey6 = ApiKey::create(),
|
$apiKey6 = ApiKey::create(),
|
||||||
],
|
],
|
||||||
@@ -90,11 +95,16 @@ class ListKeysCommandTest extends TestCase
|
|||||||
| Key | Name | Expiration date | Roles |
|
| Key | Name | Expiration date | Roles |
|
||||||
+--------------------------------------+------+-----------------+--------------------------+
|
+--------------------------------------+------+-----------------+--------------------------+
|
||||||
| {$apiKey1} | - | - | Admin |
|
| {$apiKey1} | - | - | Admin |
|
||||||
|
+--------------------------------------+------+-----------------+--------------------------+
|
||||||
| {$apiKey2} | - | - | Author only |
|
| {$apiKey2} | - | - | Author only |
|
||||||
|
+--------------------------------------+------+-----------------+--------------------------+
|
||||||
| {$apiKey3} | - | - | Domain only: example.com |
|
| {$apiKey3} | - | - | Domain only: example.com |
|
||||||
|
+--------------------------------------+------+-----------------+--------------------------+
|
||||||
| {$apiKey4} | - | - | Admin |
|
| {$apiKey4} | - | - | Admin |
|
||||||
|
+--------------------------------------+------+-----------------+--------------------------+
|
||||||
| {$apiKey5} | - | - | Author only |
|
| {$apiKey5} | - | - | Author only |
|
||||||
| | | | Domain only: example.com |
|
| | | | Domain only: example.com |
|
||||||
|
+--------------------------------------+------+-----------------+--------------------------+
|
||||||
| {$apiKey6} | - | - | Admin |
|
| {$apiKey6} | - | - | Admin |
|
||||||
+--------------------------------------+------+-----------------+--------------------------+
|
+--------------------------------------+------+-----------------+--------------------------+
|
||||||
|
|
||||||
@@ -113,8 +123,11 @@ class ListKeysCommandTest extends TestCase
|
|||||||
| Key | Name | Expiration date | Roles |
|
| Key | Name | Expiration date | Roles |
|
||||||
+--------------------------------------+---------------+-----------------+-------+
|
+--------------------------------------+---------------+-----------------+-------+
|
||||||
| {$apiKey1} | Alice | - | Admin |
|
| {$apiKey1} | Alice | - | Admin |
|
||||||
|
+--------------------------------------+---------------+-----------------+-------+
|
||||||
| {$apiKey2} | Alice and Bob | - | Admin |
|
| {$apiKey2} | Alice and Bob | - | Admin |
|
||||||
|
+--------------------------------------+---------------+-----------------+-------+
|
||||||
| {$apiKey3} | | - | Admin |
|
| {$apiKey3} | | - | Admin |
|
||||||
|
+--------------------------------------+---------------+-----------------+-------+
|
||||||
| {$apiKey4} | - | - | 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 Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
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 ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
@@ -26,10 +29,38 @@ class ListDomainsCommandTest extends TestCase
|
|||||||
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
|
$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 |
|
| Domain | Is default |
|
||||||
+---------+------------+
|
+---------+------------+
|
||||||
@@ -39,16 +70,27 @@ class ListDomainsCommandTest extends TestCase
|
|||||||
+---------+------------+
|
+---------+------------+
|
||||||
|
|
||||||
OUTPUT;
|
OUTPUT;
|
||||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
$withRedirectsOutput = <<<OUTPUT
|
||||||
new DomainItem('foo.com', true),
|
+---------+------------+---------------------------------------------------------+
|
||||||
new DomainItem('bar.com', false),
|
| Domain | Is default | "Not found" redirects |
|
||||||
new DomainItem('baz.com', false),
|
+---------+------------+---------------------------------------------------------+
|
||||||
]);
|
| 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());
|
yield 'no args' => [[], $withoutRedirectsOutput];
|
||||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
yield 'no show redirects' => [['--show-redirects' => false], $withoutRedirectsOutput];
|
||||||
$listDomains->shouldHaveBeenCalledOnce();
|
yield 'show redirects' => [['--show-redirects' => true], $withRedirectsOutput];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
|||||||
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
|
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
|
||||||
array $retryAnswer,
|
array $retryAnswer,
|
||||||
int $expectedDeleteCalls,
|
int $expectedDeleteCalls,
|
||||||
string $expectedMessage
|
string $expectedMessage,
|
||||||
): void {
|
): void {
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$identifier = new ShortUrlIdentifier($shortCode);
|
$identifier = new ShortUrlIdentifier($shortCode);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class ListShortUrlsCommandTest extends TestCase
|
|||||||
array $input,
|
array $input,
|
||||||
array $expectedContents,
|
array $expectedContents,
|
||||||
array $notExpectedContents,
|
array $notExpectedContents,
|
||||||
ApiKey $apiKey
|
ApiKey $apiKey,
|
||||||
): void {
|
): void {
|
||||||
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
||||||
->willReturn(new Paginator(new ArrayAdapter([
|
->willReturn(new Paginator(new ArrayAdapter([
|
||||||
@@ -185,7 +185,7 @@ class ListShortUrlsCommandTest extends TestCase
|
|||||||
?string $searchTerm,
|
?string $searchTerm,
|
||||||
array $tags,
|
array $tags,
|
||||||
?string $startDate = null,
|
?string $startDate = null,
|
||||||
?string $endDate = null
|
?string $endDate = null,
|
||||||
): void {
|
): void {
|
||||||
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
|
|||||||
public function showsProperMessageWhenGeoLiteUpdateFails(
|
public function showsProperMessageWhenGeoLiteUpdateFails(
|
||||||
bool $olderDbExists,
|
bool $olderDbExists,
|
||||||
string $expectedMessage,
|
string $expectedMessage,
|
||||||
int $expectedExitCode
|
int $expectedExitCode,
|
||||||
): void {
|
): void {
|
||||||
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
|
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
|
||||||
function (array $args) use ($olderDbExists): void {
|
function (array $args) use ($olderDbExists): void {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||||||
int $expectedEmptyCalls,
|
int $expectedEmptyCalls,
|
||||||
int $expectedAllCalls,
|
int $expectedAllCalls,
|
||||||
bool $expectWarningPrint,
|
bool $expectWarningPrint,
|
||||||
array $args
|
array $args,
|
||||||
): void {
|
): void {
|
||||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class ShlinkTableTest extends TestCase
|
|||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->baseTable = $this->prophesize(Table::class);
|
$this->baseTable = $this->prophesize(Table::class);
|
||||||
$this->shlinkTable = new ShlinkTable($this->baseTable->reveal());
|
$this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable->reveal());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
@@ -57,7 +57,7 @@ class ShlinkTableTest extends TestCase
|
|||||||
/** @test */
|
/** @test */
|
||||||
public function newTableIsCreatedForFactoryMethod(): void
|
public function newTableIsCreatedForFactoryMethod(): void
|
||||||
{
|
{
|
||||||
$instance = ShlinkTable::fromOutput($this->prophesize(OutputInterface::class)->reveal());
|
$instance = ShlinkTable::default($this->prophesize(OutputInterface::class)->reveal());
|
||||||
|
|
||||||
$ref = new ReflectionObject($instance);
|
$ref = new ReflectionObject($instance);
|
||||||
$baseTable = $ref->getProperty('baseTable');
|
$baseTable = $ref->getProperty('baseTable');
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ return [
|
|||||||
Domain\DomainService::class => ConfigAbstractFactory::class,
|
Domain\DomainService::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
|
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||||
|
Visit\RequestTracker::class => ConfigAbstractFactory::class,
|
||||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||||
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
||||||
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
|
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
|
||||||
@@ -45,6 +46,8 @@ return [
|
|||||||
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
|
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
|
||||||
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
|
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
|
Config\NotFoundRedirectResolver::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Action\RedirectAction::class => ConfigAbstractFactory::class,
|
Action\RedirectAction::class => ConfigAbstractFactory::class,
|
||||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||||
@@ -53,7 +56,9 @@ return [
|
|||||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
|
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
|
||||||
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
|
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
|
||||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
|
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
|
||||||
|
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => ConfigAbstractFactory::class,
|
||||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
|
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
|
||||||
|
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
|
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
@@ -69,10 +74,11 @@ return [
|
|||||||
|
|
||||||
ConfigAbstractFactory::class => [
|
ConfigAbstractFactory::class => [
|
||||||
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
|
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
|
||||||
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
|
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class],
|
||||||
ErrorHandler\NotFoundRedirectHandler::class => [
|
ErrorHandler\NotFoundRedirectHandler::class => [
|
||||||
NotFoundRedirectOptions::class,
|
NotFoundRedirectOptions::class,
|
||||||
Util\RedirectResponseHelper::class,
|
Config\NotFoundRedirectResolver::class,
|
||||||
|
Domain\DomainService::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
Options\AppOptions::class => ['config.app_options'],
|
Options\AppOptions::class => ['config.app_options'],
|
||||||
@@ -92,6 +98,7 @@ return [
|
|||||||
EventDispatcherInterface::class,
|
EventDispatcherInterface::class,
|
||||||
Options\TrackingOptions::class,
|
Options\TrackingOptions::class,
|
||||||
],
|
],
|
||||||
|
Visit\RequestTracker::class => [Visit\VisitsTracker::class, Options\TrackingOptions::class],
|
||||||
Service\ShortUrlService::class => [
|
Service\ShortUrlService::class => [
|
||||||
'em',
|
'em',
|
||||||
Service\ShortUrl\ShortUrlResolver::class,
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
@@ -108,25 +115,25 @@ return [
|
|||||||
],
|
],
|
||||||
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
||||||
Service\ShortUrl\ShortCodeHelper::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\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||||
Util\DoctrineBatchHelper::class => ['em'],
|
Util\DoctrineBatchHelper::class => ['em'],
|
||||||
Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
|
Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
|
||||||
|
|
||||||
|
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class],
|
||||||
|
|
||||||
Action\RedirectAction::class => [
|
Action\RedirectAction::class => [
|
||||||
Service\ShortUrl\ShortUrlResolver::class,
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
Visit\VisitsTracker::class,
|
Visit\RequestTracker::class,
|
||||||
Options\TrackingOptions::class,
|
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
|
||||||
Util\RedirectResponseHelper::class,
|
Util\RedirectResponseHelper::class,
|
||||||
'Logger_Shlink',
|
|
||||||
],
|
|
||||||
Action\PixelAction::class => [
|
|
||||||
Service\ShortUrl\ShortUrlResolver::class,
|
|
||||||
Visit\VisitsTracker::class,
|
|
||||||
Options\TrackingOptions::class,
|
|
||||||
'Logger_Shlink',
|
|
||||||
],
|
],
|
||||||
|
Action\PixelAction::class => [Service\ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class],
|
||||||
Action\QrCodeAction::class => [
|
Action\QrCodeAction::class => [
|
||||||
Service\ShortUrl\ShortUrlResolver::class,
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
ShortUrl\Helper\ShortUrlStringifier::class,
|
ShortUrl\Helper\ShortUrlStringifier::class,
|
||||||
@@ -137,7 +144,15 @@ return [
|
|||||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
|
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
|
||||||
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
||||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
||||||
|
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
|
||||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
|
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
|
||||||
|
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [
|
||||||
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
|
Visit\RequestTracker::class,
|
||||||
|
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
|
||||||
|
Util\RedirectResponseHelper::class,
|
||||||
|
Options\UrlShortenerOptions::class,
|
||||||
|
],
|
||||||
|
|
||||||
Mercure\MercureUpdatesGenerator::class => [
|
Mercure\MercureUpdatesGenerator::class => [
|
||||||
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
||||||
|
|||||||
@@ -24,4 +24,19 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
|||||||
$builder->createField('authority', Types::STRING)
|
$builder->createField('authority', Types::STRING)
|
||||||
->unique()
|
->unique()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
|
$builder->createField('baseUrlRedirect', Types::STRING)
|
||||||
|
->columnName('base_url_redirect')
|
||||||
|
->nullable()
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$builder->createField('regular404Redirect', Types::STRING)
|
||||||
|
->columnName('regular_not_found_redirect')
|
||||||
|
->nullable()
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$builder->createField('invalidShortUrlRedirect', Types::STRING)
|
||||||
|
->columnName('invalid_short_url_redirect')
|
||||||
|
->nullable()
|
||||||
|
->build();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,20 +51,12 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
|
|||||||
$startDate = parseDateFromQuery($query, $startDateName);
|
$startDate = parseDateFromQuery($query, $startDateName);
|
||||||
$endDate = parseDateFromQuery($query, $endDateName);
|
$endDate = parseDateFromQuery($query, $endDateName);
|
||||||
|
|
||||||
// TODO Use match expression when migrating to PHP8
|
return match (true) {
|
||||||
if ($startDate === null && $endDate === null) {
|
$startDate === null && $endDate === null => DateRange::emptyInstance(),
|
||||||
return DateRange::emptyInstance();
|
$startDate !== null && $endDate !== null => DateRange::withStartAndEndDate($startDate, $endDate),
|
||||||
}
|
$startDate !== null => DateRange::withStartDate($startDate),
|
||||||
|
default => DateRange::withEndDate($endDate),
|
||||||
if ($startDate !== null && $endDate !== null) {
|
};
|
||||||
return DateRange::withStartAndEndDate($startDate, $endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($startDate !== null) {
|
|
||||||
return DateRange::withStartDate($startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DateRange::withEndDate($endDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,91 +5,46 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Action;
|
namespace Shlinkio\Shlink\Core\Action;
|
||||||
|
|
||||||
use Fig\Http\Message\RequestMethodInterface;
|
use Fig\Http\Message\RequestMethodInterface;
|
||||||
use GuzzleHttp\Psr7\Query;
|
|
||||||
use League\Uri\Uri;
|
|
||||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
use Psr\Log\NullLogger;
|
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
|
||||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
|
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
|
||||||
|
|
||||||
use function array_key_exists;
|
|
||||||
use function array_merge;
|
|
||||||
|
|
||||||
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
|
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
|
||||||
{
|
{
|
||||||
private ShortUrlResolverInterface $urlResolver;
|
|
||||||
private VisitsTrackerInterface $visitTracker;
|
|
||||||
private TrackingOptions $trackingOptions;
|
|
||||||
private LoggerInterface $logger;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ShortUrlResolverInterface $urlResolver,
|
private ShortUrlResolverInterface $urlResolver,
|
||||||
VisitsTrackerInterface $visitTracker,
|
private RequestTrackerInterface $requestTracker,
|
||||||
TrackingOptions $trackingOptions,
|
|
||||||
?LoggerInterface $logger = null
|
|
||||||
) {
|
) {
|
||||||
$this->urlResolver = $urlResolver;
|
|
||||||
$this->visitTracker = $visitTracker;
|
|
||||||
$this->trackingOptions = $trackingOptions;
|
|
||||||
$this->logger = $logger ?? new NullLogger();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
{
|
{
|
||||||
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
||||||
$query = $request->getQueryParams();
|
|
||||||
$disableTrackParam = $this->trackingOptions->getDisableTrackParam();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||||
|
$this->requestTracker->trackIfApplicable($shortUrl, $request);
|
||||||
|
|
||||||
if ($this->shouldTrackRequest($request, $query, $disableTrackParam)) {
|
return $this->createSuccessResp($shortUrl, $request);
|
||||||
$this->visitTracker->track($shortUrl, Visitor::fromRequest($request));
|
} catch (ShortUrlNotFoundException) {
|
||||||
}
|
|
||||||
|
|
||||||
return $this->createSuccessResp($this->buildUrlToRedirectTo($shortUrl, $query, $disableTrackParam));
|
|
||||||
} catch (ShortUrlNotFoundException $e) {
|
|
||||||
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
|
|
||||||
return $this->createErrorResp($request, $handler);
|
return $this->createErrorResp($request, $handler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string
|
abstract protected function createSuccessResp(
|
||||||
{
|
ShortUrl $shortUrl,
|
||||||
$uri = Uri::createFromString($shortUrl->getLongUrl());
|
|
||||||
$hardcodedQuery = Query::parse($uri->getQuery() ?? '');
|
|
||||||
if ($disableTrackParam !== null) {
|
|
||||||
unset($currentQuery[$disableTrackParam]);
|
|
||||||
}
|
|
||||||
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
|
|
||||||
|
|
||||||
return (string) (empty($mergedQuery) ? $uri : $uri->withQuery(Query::build($mergedQuery)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool
|
|
||||||
{
|
|
||||||
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
|
|
||||||
if ($forwardedMethod === self::METHOD_HEAD) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query);
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract protected function createSuccessResp(string $longUrl): ResponseInterface;
|
|
||||||
|
|
||||||
abstract protected function createErrorResp(
|
|
||||||
ServerRequestInterface $request,
|
ServerRequestInterface $request,
|
||||||
RequestHandlerInterface $handler
|
|
||||||
): ResponseInterface;
|
): ResponseInterface;
|
||||||
|
|
||||||
|
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
|
||||||
|
{
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
module/Core/src/Action/Model/QrCodeParams.php
Normal file
112
module/Core/src/Action/Model/QrCodeParams.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Action\Model;
|
||||||
|
|
||||||
|
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
|
||||||
|
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface;
|
||||||
|
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow;
|
||||||
|
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium;
|
||||||
|
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile;
|
||||||
|
use Endroid\QrCode\Writer\PngWriter;
|
||||||
|
use Endroid\QrCode\Writer\SvgWriter;
|
||||||
|
use Endroid\QrCode\Writer\WriterInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
use function strtolower;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
final class QrCodeParams
|
||||||
|
{
|
||||||
|
private const DEFAULT_SIZE = 300;
|
||||||
|
private const MIN_SIZE = 50;
|
||||||
|
private const MAX_SIZE = 1000;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
private int $size,
|
||||||
|
private int $margin,
|
||||||
|
private WriterInterface $writer,
|
||||||
|
private ErrorCorrectionLevelInterface $errorCorrectionLevel
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromRequest(ServerRequestInterface $request): self
|
||||||
|
{
|
||||||
|
$query = $request->getQueryParams();
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
self::resolveSize($request, $query),
|
||||||
|
self::resolveMargin($query),
|
||||||
|
self::resolveWriter($query),
|
||||||
|
self::resolveErrorCorrection($query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveSize(Request $request, array $query): int
|
||||||
|
{
|
||||||
|
// FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
|
||||||
|
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
|
||||||
|
if ($size < self::MIN_SIZE) {
|
||||||
|
return self::MIN_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveMargin(array $query): int
|
||||||
|
{
|
||||||
|
$margin = $query['margin'] ?? null;
|
||||||
|
if ($margin === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$intMargin = (int) $margin;
|
||||||
|
if ($margin !== (string) $intMargin) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $intMargin < 0 ? 0 : $intMargin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveWriter(array $query): WriterInterface
|
||||||
|
{
|
||||||
|
$format = strtolower(trim($query['format'] ?? 'png'));
|
||||||
|
return match ($format) {
|
||||||
|
'svg' => new SvgWriter(),
|
||||||
|
default => new PngWriter(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface
|
||||||
|
{
|
||||||
|
$errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l'));
|
||||||
|
return match ($errorCorrectionLevel) {
|
||||||
|
'h' => new ErrorCorrectionLevelHigh(),
|
||||||
|
'q' => new ErrorCorrectionLevelQuartile(),
|
||||||
|
'm' => new ErrorCorrectionLevelMedium(),
|
||||||
|
default => new ErrorCorrectionLevelLow(), // 'l'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function size(): int
|
||||||
|
{
|
||||||
|
return $this->size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function margin(): int
|
||||||
|
{
|
||||||
|
return $this->margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function writer(): WriterInterface
|
||||||
|
{
|
||||||
|
return $this->writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function errorCorrectionLevel(): ErrorCorrectionLevelInterface
|
||||||
|
{
|
||||||
|
return $this->errorCorrectionLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,17 +8,18 @@ use Psr\Http\Message\ResponseInterface;
|
|||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Shlinkio\Shlink\Common\Response\PixelResponse;
|
use Shlinkio\Shlink\Common\Response\PixelResponse;
|
||||||
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
|
|
||||||
class PixelAction extends AbstractTrackingAction
|
class PixelAction extends AbstractTrackingAction
|
||||||
{
|
{
|
||||||
protected function createSuccessResp(string $longUrl): ResponseInterface
|
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): ResponseInterface
|
||||||
{
|
{
|
||||||
return new PixelResponse();
|
return new PixelResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createErrorResp(
|
protected function createErrorResp(
|
||||||
ServerRequestInterface $request,
|
ServerRequestInterface $request,
|
||||||
RequestHandlerInterface $handler
|
RequestHandlerInterface $handler,
|
||||||
): ResponseInterface {
|
): ResponseInterface {
|
||||||
return new PixelResponse();
|
return new PixelResponse();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Action;
|
namespace Shlinkio\Shlink\Core\Action;
|
||||||
|
|
||||||
use Endroid\QrCode\Builder\Builder;
|
use Endroid\QrCode\Builder\Builder;
|
||||||
use Endroid\QrCode\Writer\SvgWriter;
|
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
|
||||||
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
||||||
|
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
@@ -20,22 +19,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
|||||||
|
|
||||||
class QrCodeAction implements MiddlewareInterface
|
class QrCodeAction implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
private const DEFAULT_SIZE = 300;
|
|
||||||
private const MIN_SIZE = 50;
|
|
||||||
private const MAX_SIZE = 1000;
|
|
||||||
|
|
||||||
private ShortUrlResolverInterface $urlResolver;
|
|
||||||
private ShortUrlStringifierInterface $stringifier;
|
|
||||||
private LoggerInterface $logger;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ShortUrlResolverInterface $urlResolver,
|
private ShortUrlResolverInterface $urlResolver,
|
||||||
ShortUrlStringifierInterface $stringifier,
|
private ShortUrlStringifierInterface $stringifier,
|
||||||
?LoggerInterface $logger = null
|
private LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
$this->urlResolver = $urlResolver;
|
|
||||||
$this->logger = $logger ?? new NullLogger();
|
|
||||||
$this->stringifier = $stringifier;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
@@ -49,43 +37,14 @@ class QrCodeAction implements MiddlewareInterface
|
|||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = $request->getQueryParams();
|
$params = QrCodeParams::fromRequest($request);
|
||||||
$qrCode = Builder::create()
|
$qrCodeBuilder = Builder::create()
|
||||||
->data($this->stringifier->stringify($shortUrl))
|
->data($this->stringifier->stringify($shortUrl))
|
||||||
->size($this->resolveSize($request, $query))
|
->size($params->size())
|
||||||
->margin($this->resolveMargin($query));
|
->margin($params->margin())
|
||||||
|
->writer($params->writer())
|
||||||
|
->errorCorrectionLevel($params->errorCorrectionLevel());
|
||||||
|
|
||||||
$format = $query['format'] ?? 'png';
|
return new QrCodeResponse($qrCodeBuilder->build());
|
||||||
if ($format === 'svg') {
|
|
||||||
$qrCode->writer(new SvgWriter());
|
|
||||||
}
|
|
||||||
|
|
||||||
return new QrCodeResponse($qrCode->build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveSize(Request $request, array $query): int
|
|
||||||
{
|
|
||||||
// Size attribute is deprecated. After v3.0.0, always use the query param instead
|
|
||||||
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
|
|
||||||
if ($size < self::MIN_SIZE) {
|
|
||||||
return self::MIN_SIZE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveMargin(array $query): int
|
|
||||||
{
|
|
||||||
if (! isset($query['margin'])) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$margin = $query['margin'];
|
|
||||||
$intMargin = (int) $margin;
|
|
||||||
if ($margin !== (string) $intMargin) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $intMargin < 0 ? 0 : $intMargin;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,35 +7,26 @@ namespace Shlinkio\Shlink\Core\Action;
|
|||||||
use Fig\Http\Message\StatusCodeInterface;
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
use Shlinkio\Shlink\Core\Options;
|
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface;
|
||||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||||
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
|
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
|
||||||
|
|
||||||
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
|
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
|
||||||
{
|
{
|
||||||
private RedirectResponseHelperInterface $redirectResponseHelper;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ShortUrlResolverInterface $urlResolver,
|
ShortUrlResolverInterface $urlResolver,
|
||||||
VisitsTrackerInterface $visitTracker,
|
RequestTrackerInterface $requestTracker,
|
||||||
Options\TrackingOptions $trackingOptions,
|
private ShortUrlRedirectionBuilderInterface $redirectionBuilder,
|
||||||
RedirectResponseHelperInterface $redirectResponseHelper,
|
private RedirectResponseHelperInterface $redirectResponseHelper,
|
||||||
?LoggerInterface $logger = null
|
|
||||||
) {
|
) {
|
||||||
parent::__construct($urlResolver, $visitTracker, $trackingOptions, $logger);
|
parent::__construct($urlResolver, $requestTracker);
|
||||||
$this->redirectResponseHelper = $redirectResponseHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createSuccessResp(string $longUrl): Response
|
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response
|
||||||
{
|
{
|
||||||
|
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request->getQueryParams());
|
||||||
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
|
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
|
|
||||||
{
|
|
||||||
return $handler->handle($request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,13 @@ use const PHP_EOL;
|
|||||||
|
|
||||||
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
|
class RobotsAction implements RequestHandlerInterface, StatusCodeInterface
|
||||||
{
|
{
|
||||||
private CrawlingHelperInterface $crawlingHelper;
|
public function __construct(private CrawlingHelperInterface $crawlingHelper)
|
||||||
|
|
||||||
public function __construct(CrawlingHelperInterface $crawlingHelper)
|
|
||||||
{
|
{
|
||||||
$this->crawlingHelper = $crawlingHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
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());
|
return new Response(self::STATUS_OK, ['Content-type' => 'text/plain'], $this->buildRobots());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
module/Core/src/Config/NotFoundRedirectConfigInterface.php
Normal file
20
module/Core/src/Config/NotFoundRedirectConfigInterface.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Config;
|
||||||
|
|
||||||
|
interface NotFoundRedirectConfigInterface
|
||||||
|
{
|
||||||
|
public function invalidShortUrlRedirect(): ?string;
|
||||||
|
|
||||||
|
public function hasInvalidShortUrlRedirect(): bool;
|
||||||
|
|
||||||
|
public function regular404Redirect(): ?string;
|
||||||
|
|
||||||
|
public function hasRegular404Redirect(): bool;
|
||||||
|
|
||||||
|
public function baseUrlRedirect(): ?string;
|
||||||
|
|
||||||
|
public function hasBaseUrlRedirect(): bool;
|
||||||
|
}
|
||||||
34
module/Core/src/Config/NotFoundRedirectResolver.php
Normal file
34
module/Core/src/Config/NotFoundRedirectResolver.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Config;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||||
|
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||||
|
|
||||||
|
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
|
||||||
|
{
|
||||||
|
public function __construct(private RedirectResponseHelperInterface $redirectResponseHelper)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveRedirectResponse(
|
||||||
|
NotFoundType $notFoundType,
|
||||||
|
NotFoundRedirectConfigInterface $config
|
||||||
|
): ?ResponseInterface {
|
||||||
|
return match (true) {
|
||||||
|
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() =>
|
||||||
|
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||||
|
$this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()),
|
||||||
|
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() =>
|
||||||
|
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||||
|
$this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()),
|
||||||
|
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
|
||||||
|
// @phpstan-ignore-next-line Create custom PHPStan rule
|
||||||
|
$this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
16
module/Core/src/Config/NotFoundRedirectResolverInterface.php
Normal file
16
module/Core/src/Config/NotFoundRedirectResolverInterface.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Config;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||||
|
|
||||||
|
interface NotFoundRedirectResolverInterface
|
||||||
|
{
|
||||||
|
public function resolveRedirectResponse(
|
||||||
|
NotFoundType $notFoundType,
|
||||||
|
NotFoundRedirectConfigInterface $config
|
||||||
|
): ?ResponseInterface;
|
||||||
|
}
|
||||||
59
module/Core/src/Config/NotFoundRedirects.php
Normal file
59
module/Core/src/Config/NotFoundRedirects.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Config;
|
||||||
|
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
final class NotFoundRedirects implements JsonSerializable
|
||||||
|
{
|
||||||
|
private function __construct(
|
||||||
|
private ?string $baseUrlRedirect,
|
||||||
|
private ?string $regular404Redirect,
|
||||||
|
private ?string $invalidShortUrlRedirect,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withRedirects(
|
||||||
|
?string $baseUrlRedirect,
|
||||||
|
?string $regular404Redirect = null,
|
||||||
|
?string $invalidShortUrlRedirect = null,
|
||||||
|
): self {
|
||||||
|
return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withoutRedirects(): self
|
||||||
|
{
|
||||||
|
return new self(null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromConfig(NotFoundRedirectConfigInterface $config): self
|
||||||
|
{
|
||||||
|
return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function baseUrlRedirect(): ?string
|
||||||
|
{
|
||||||
|
return $this->baseUrlRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regular404Redirect(): ?string
|
||||||
|
{
|
||||||
|
return $this->regular404Redirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidShortUrlRedirect(): ?string
|
||||||
|
{
|
||||||
|
return $this->invalidShortUrlRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'baseUrlRedirect' => $this->baseUrlRedirect,
|
||||||
|
'regular404Redirect' => $this->regular404Redirect,
|
||||||
|
'invalidShortUrlRedirect' => $this->invalidShortUrlRedirect,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
|||||||
|
|
||||||
class CrawlingHelper implements CrawlingHelperInterface
|
class CrawlingHelper implements CrawlingHelperInterface
|
||||||
{
|
{
|
||||||
private EntityManagerInterface $em;
|
public function __construct(private EntityManagerInterface $em)
|
||||||
|
|
||||||
public function __construct(EntityManagerInterface $em)
|
|
||||||
{
|
{
|
||||||
$this->em = $em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listCrawlableShortCodes(): iterable
|
public function listCrawlableShortCodes(): iterable
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Domain;
|
namespace Shlinkio\Shlink\Core\Domain;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||||
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
|
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
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\ApiKey\Role;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
@@ -16,13 +19,11 @@ use function Functional\map;
|
|||||||
|
|
||||||
class DomainService implements DomainServiceInterface
|
class DomainService implements DomainServiceInterface
|
||||||
{
|
{
|
||||||
private EntityManagerInterface $em;
|
public function __construct(
|
||||||
private string $defaultDomain;
|
private EntityManagerInterface $em,
|
||||||
|
private string $defaultDomain,
|
||||||
public function __construct(EntityManagerInterface $em, string $defaultDomain)
|
private NotFoundRedirectOptions $redirectOptions,
|
||||||
{
|
) {
|
||||||
$this->em = $em;
|
|
||||||
$this->defaultDomain = $defaultDomain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,14 +34,14 @@ class DomainService implements DomainServiceInterface
|
|||||||
/** @var DomainRepositoryInterface $repo */
|
/** @var DomainRepositoryInterface $repo */
|
||||||
$repo = $this->em->getRepository(Domain::class);
|
$repo = $this->em->getRepository(Domain::class);
|
||||||
$domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey);
|
$domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey);
|
||||||
$mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false));
|
$mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forExistingDomain($domain));
|
||||||
|
|
||||||
if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) {
|
if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
|
||||||
return $mappedDomains;
|
return $mappedDomains;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new DomainItem($this->defaultDomain, true),
|
DomainItem::forDefaultDomain($this->defaultDomain, $this->redirectOptions),
|
||||||
...$mappedDomains,
|
...$mappedDomains,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -59,15 +60,58 @@ class DomainService implements DomainServiceInterface
|
|||||||
return $domain;
|
return $domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOrCreate(string $authority): Domain
|
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
|
||||||
{
|
{
|
||||||
$repo = $this->em->getRepository(Domain::class);
|
$repo = $this->em->getRepository(Domain::class);
|
||||||
/** @var Domain|null $domain */
|
return $repo->findOneByAuthority($authority, $apiKey);
|
||||||
$domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority);
|
}
|
||||||
|
|
||||||
$this->em->persist($domain);
|
/**
|
||||||
|
* @throws DomainNotFoundException
|
||||||
|
*/
|
||||||
|
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain
|
||||||
|
{
|
||||||
|
$domain = $this->getPersistedDomain($authority, $apiKey);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return $domain;
|
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;
|
namespace Shlinkio\Shlink\Core\Domain;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||||
|
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
interface DomainServiceInterface
|
interface DomainServiceInterface
|
||||||
@@ -21,5 +23,20 @@ interface DomainServiceInterface
|
|||||||
*/
|
*/
|
||||||
public function getDomain(string $domainId): Domain;
|
public function getDomain(string $domainId): Domain;
|
||||||
|
|
||||||
public function getOrCreate(string $authority): Domain;
|
/**
|
||||||
|
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
|
||||||
|
*/
|
||||||
|
public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain;
|
||||||
|
|
||||||
|
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
|
||||||
|
* @throws InvalidDomainException If default domain is provided
|
||||||
|
*/
|
||||||
|
public function configureNotFoundRedirects(
|
||||||
|
string $authority,
|
||||||
|
NotFoundRedirects $notFoundRedirects,
|
||||||
|
?ApiKey $apiKey = null,
|
||||||
|
): Domain;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,33 +5,50 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Domain\Model;
|
namespace Shlinkio\Shlink\Core\Domain\Model;
|
||||||
|
|
||||||
use JsonSerializable;
|
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
|
final class DomainItem implements JsonSerializable
|
||||||
{
|
{
|
||||||
private string $domain;
|
private function __construct(
|
||||||
private bool $isDefault;
|
private string $authority,
|
||||||
|
private NotFoundRedirectConfigInterface $notFoundRedirectConfig,
|
||||||
|
private bool $isDefault
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function __construct(string $domain, bool $isDefault)
|
public static function forExistingDomain(Domain $domain): self
|
||||||
{
|
{
|
||||||
$this->domain = $domain;
|
return new self($domain->getAuthority(), $domain, false);
|
||||||
$this->isDefault = $isDefault;
|
}
|
||||||
|
|
||||||
|
public static function forDefaultDomain(string $authority, NotFoundRedirectConfigInterface $config): self
|
||||||
|
{
|
||||||
|
return new self($authority, $config, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'domain' => $this->domain,
|
'domain' => $this->authority,
|
||||||
'isDefault' => $this->isDefault,
|
'isDefault' => $this->isDefault,
|
||||||
|
'redirects' => NotFoundRedirects::fromConfig($this->notFoundRedirectConfig),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toString(): string
|
public function toString(): string
|
||||||
{
|
{
|
||||||
return $this->domain;
|
return $this->authority;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDefault(): bool
|
public function isDefault(): bool
|
||||||
{
|
{
|
||||||
return $this->isDefault;
|
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 Doctrine\ORM\Query\Expr\Join;
|
||||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
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\Domain;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
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;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
|
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
|
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('d');
|
$qb = $this->createQueryBuilder('d');
|
||||||
$qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d')
|
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
|
||||||
->orderBy('d.authority', 'ASC');
|
->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) {
|
$specs = $this->determineExtraSpecs($excludedAuthority, $apiKey);
|
||||||
$qb->where($qb->expr()->neq('d.authority', ':excludedAuthority'))
|
foreach ($specs as [$alias, $spec]) {
|
||||||
->setParameter('excludedAuthority', $excludedAuthority);
|
$this->applySpecification($qb, $spec, $alias);
|
||||||
}
|
|
||||||
|
|
||||||
if ($apiKey !== null) {
|
|
||||||
$this->applySpecification($qb, $apiKey->spec(), 's');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
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[]
|
* @return Domain[]
|
||||||
*/
|
*/
|
||||||
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array;
|
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array;
|
||||||
|
|
||||||
|
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
|
||||||
}
|
}
|
||||||
|
|||||||
22
module/Core/src/Domain/Spec/IsDomain.php
Normal file
22
module/Core/src/Domain/Spec/IsDomain.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Domain\Spec;
|
||||||
|
|
||||||
|
use Happyr\DoctrineSpecification\Filter\Filter;
|
||||||
|
use Happyr\DoctrineSpecification\Spec;
|
||||||
|
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||||
|
|
||||||
|
class IsDomain extends BaseSpecification
|
||||||
|
{
|
||||||
|
public function __construct(private string $domainId, ?string $context = null)
|
||||||
|
{
|
||||||
|
parent::__construct($context);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSpec(): Filter
|
||||||
|
{
|
||||||
|
return Spec::eq('id', $this->domainId);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
module/Core/src/Domain/Spec/IsNotAuthority.php
Normal file
22
module/Core/src/Domain/Spec/IsNotAuthority.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Domain\Spec;
|
||||||
|
|
||||||
|
use Happyr\DoctrineSpecification\Filter\Filter;
|
||||||
|
use Happyr\DoctrineSpecification\Spec;
|
||||||
|
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||||
|
|
||||||
|
class IsNotAuthority extends BaseSpecification
|
||||||
|
{
|
||||||
|
public function __construct(private string $authority, ?string $context = null)
|
||||||
|
{
|
||||||
|
parent::__construct($context);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSpec(): Filter
|
||||||
|
{
|
||||||
|
return Spec::not(Spec::eq('authority', $this->authority));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Domain\Validation;
|
||||||
|
|
||||||
|
use Laminas\InputFilter\InputFilter;
|
||||||
|
use Shlinkio\Shlink\Common\Validation;
|
||||||
|
|
||||||
|
class DomainRedirectsInputFilter extends InputFilter
|
||||||
|
{
|
||||||
|
use Validation\InputFactoryTrait;
|
||||||
|
|
||||||
|
public const DOMAIN = 'domain';
|
||||||
|
public const BASE_URL_REDIRECT = 'baseUrlRedirect';
|
||||||
|
public const REGULAR_404_REDIRECT = 'regular404Redirect';
|
||||||
|
public const INVALID_SHORT_URL_REDIRECT = 'invalidShortUrlRedirect';
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withData(array $data): self
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
|
||||||
|
$instance->initializeInputs();
|
||||||
|
$instance->setData($data);
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function initializeInputs(): void
|
||||||
|
{
|
||||||
|
$domain = $this->createInput(self::DOMAIN);
|
||||||
|
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
||||||
|
$this->add($domain);
|
||||||
|
|
||||||
|
$this->add($this->createInput(self::BASE_URL_REDIRECT, false));
|
||||||
|
$this->add($this->createInput(self::REGULAR_404_REDIRECT, false));
|
||||||
|
$this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,22 @@ namespace Shlinkio\Shlink\Core\Entity;
|
|||||||
|
|
||||||
use JsonSerializable;
|
use JsonSerializable;
|
||||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||||
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||||
|
|
||||||
class Domain extends AbstractEntity implements JsonSerializable
|
class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface
|
||||||
{
|
{
|
||||||
private string $authority;
|
private ?string $baseUrlRedirect = null;
|
||||||
|
private ?string $regular404Redirect = null;
|
||||||
|
private ?string $invalidShortUrlRedirect = null;
|
||||||
|
|
||||||
public function __construct(string $authority)
|
private function __construct(private string $authority)
|
||||||
{
|
{
|
||||||
$this->authority = $authority;
|
}
|
||||||
|
|
||||||
|
public static function withAuthority(string $authority): self
|
||||||
|
{
|
||||||
|
return new self($authority);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAuthority(): string
|
public function getAuthority(): string
|
||||||
@@ -25,4 +33,41 @@ class Domain extends AbstractEntity implements JsonSerializable
|
|||||||
{
|
{
|
||||||
return $this->getAuthority();
|
return $this->getAuthority();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function invalidShortUrlRedirect(): ?string
|
||||||
|
{
|
||||||
|
return $this->invalidShortUrlRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasInvalidShortUrlRedirect(): bool
|
||||||
|
{
|
||||||
|
return $this->invalidShortUrlRedirect !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regular404Redirect(): ?string
|
||||||
|
{
|
||||||
|
return $this->regular404Redirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasRegular404Redirect(): bool
|
||||||
|
{
|
||||||
|
return $this->regular404Redirect !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function baseUrlRedirect(): ?string
|
||||||
|
{
|
||||||
|
return $this->baseUrlRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasBaseUrlRedirect(): bool
|
||||||
|
{
|
||||||
|
return $this->baseUrlRedirect !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureNotFoundRedirects(NotFoundRedirects $redirects): void
|
||||||
|
{
|
||||||
|
$this->baseUrlRedirect = $redirects->baseUrlRedirect();
|
||||||
|
$this->regular404Redirect = $redirects->regular404Redirect();
|
||||||
|
$this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class ShortUrl extends AbstractEntity
|
|||||||
|
|
||||||
public static function fromMeta(
|
public static function fromMeta(
|
||||||
ShortUrlMeta $meta,
|
ShortUrlMeta $meta,
|
||||||
?ShortUrlRelationResolverInterface $relationResolver = null
|
?ShortUrlRelationResolverInterface $relationResolver = null,
|
||||||
): self {
|
): self {
|
||||||
$instance = new self();
|
$instance = new self();
|
||||||
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
|
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
|
||||||
@@ -87,7 +87,7 @@ class ShortUrl extends AbstractEntity
|
|||||||
public static function fromImport(
|
public static function fromImport(
|
||||||
ImportedShlinkUrl $url,
|
ImportedShlinkUrl $url,
|
||||||
bool $importShortCode,
|
bool $importShortCode,
|
||||||
?ShortUrlRelationResolverInterface $relationResolver = null
|
?ShortUrlRelationResolverInterface $relationResolver = null,
|
||||||
): self {
|
): self {
|
||||||
$meta = [
|
$meta = [
|
||||||
ShortUrlInputFilter::VALIDATE_URL => false,
|
ShortUrlInputFilter::VALIDATE_URL => false,
|
||||||
@@ -209,7 +209,7 @@ class ShortUrl extends AbstractEntity
|
|||||||
|
|
||||||
public function update(
|
public function update(
|
||||||
ShortUrlEdit $shortUrlEdit,
|
ShortUrlEdit $shortUrlEdit,
|
||||||
?ShortUrlRelationResolverInterface $relationResolver = null
|
?ShortUrlRelationResolverInterface $relationResolver = null,
|
||||||
): void {
|
): void {
|
||||||
if ($shortUrlEdit->validSinceWasProvided()) {
|
if ($shortUrlEdit->validSinceWasProvided()) {
|
||||||
$this->validSince = $shortUrlEdit->validSince();
|
$this->validSince = $shortUrlEdit->validSince();
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
|||||||
|
|
||||||
class Tag extends AbstractEntity implements JsonSerializable
|
class Tag extends AbstractEntity implements JsonSerializable
|
||||||
{
|
{
|
||||||
private string $name;
|
|
||||||
private Collections\Collection $shortUrls;
|
private Collections\Collection $shortUrls;
|
||||||
|
|
||||||
public function __construct(string $name)
|
public function __construct(private string $name)
|
||||||
{
|
{
|
||||||
$this->name = $name;
|
|
||||||
$this->shortUrls = new Collections\ArrayCollection();
|
$this->shortUrls = new Collections\ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
|
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,31 +13,24 @@ use function rtrim;
|
|||||||
|
|
||||||
class NotFoundType
|
class NotFoundType
|
||||||
{
|
{
|
||||||
private string $type;
|
private function __construct(private string $type)
|
||||||
|
|
||||||
private function __construct(string $type)
|
|
||||||
{
|
{
|
||||||
$this->type = $type;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromRequest(ServerRequestInterface $request, string $basePath): self
|
public static function fromRequest(ServerRequestInterface $request, string $basePath): self
|
||||||
{
|
{
|
||||||
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
|
|
||||||
if ($isBaseUrl) {
|
|
||||||
return new self(Visit::TYPE_BASE_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var RouteResult $routeResult */
|
/** @var RouteResult $routeResult */
|
||||||
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
|
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
|
||||||
if ($routeResult->isFailure()) {
|
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
|
||||||
return new self(Visit::TYPE_REGULAR_404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($routeResult->getMatchedRouteName() === RedirectAction::class) {
|
$type = match (true) {
|
||||||
return new self(Visit::TYPE_INVALID_SHORT_URL);
|
$isBaseUrl => Visit::TYPE_BASE_URL,
|
||||||
}
|
$routeResult->isFailure() => Visit::TYPE_REGULAR_404,
|
||||||
|
$routeResult->getMatchedRouteName() === RedirectAction::class => Visit::TYPE_INVALID_SHORT_URL,
|
||||||
|
default => self::class,
|
||||||
|
};
|
||||||
|
|
||||||
return new self(self::class);
|
return new self($type);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isBaseUrl(): bool
|
public function isBaseUrl(): bool
|
||||||
|
|||||||
@@ -8,44 +8,35 @@ use Psr\Http\Message\ResponseInterface;
|
|||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
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\ErrorHandler\Model\NotFoundType;
|
||||||
use Shlinkio\Shlink\Core\Options;
|
use Shlinkio\Shlink\Core\Options;
|
||||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
|
||||||
|
|
||||||
class NotFoundRedirectHandler implements MiddlewareInterface
|
class NotFoundRedirectHandler implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
private Options\NotFoundRedirectOptions $redirectOptions;
|
|
||||||
private RedirectResponseHelperInterface $redirectResponseHelper;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Options\NotFoundRedirectOptions $redirectOptions,
|
private Options\NotFoundRedirectOptions $redirectOptions,
|
||||||
RedirectResponseHelperInterface $redirectResponseHelper
|
private NotFoundRedirectResolverInterface $redirectResolver,
|
||||||
|
private DomainServiceInterface $domainService,
|
||||||
) {
|
) {
|
||||||
$this->redirectOptions = $redirectOptions;
|
|
||||||
$this->redirectResponseHelper = $redirectResponseHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
{
|
{
|
||||||
/** @var NotFoundType $notFoundType */
|
/** @var NotFoundType $notFoundType */
|
||||||
$notFoundType = $request->getAttribute(NotFoundType::class);
|
$notFoundType = $request->getAttribute(NotFoundType::class);
|
||||||
|
$authority = $request->getUri()->getAuthority();
|
||||||
|
$domainSpecificRedirect = $this->resolveDomainSpecificRedirect($authority, $notFoundType);
|
||||||
|
|
||||||
if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) {
|
return $domainSpecificRedirect
|
||||||
return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect());
|
?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions)
|
||||||
}
|
?? $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) {
|
private function resolveDomainSpecificRedirect(string $authority, NotFoundType $notFoundType): ?ResponseInterface
|
||||||
return $this->redirectResponseHelper->buildRedirectResponse(
|
{
|
||||||
$this->redirectOptions->getRegular404Redirect(),
|
$domain = $this->domainService->findByAuthority($authority);
|
||||||
);
|
return $domain === null ? null : $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain);
|
||||||
}
|
|
||||||
|
|
||||||
if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) {
|
|
||||||
return $this->redirectResponseHelper->buildRedirectResponse(
|
|
||||||
$this->redirectOptions->getInvalidShortUrlRedirect(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $handler->handle($request);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class NotFoundTemplateHandler implements RequestHandlerInterface
|
|||||||
private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates';
|
private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates';
|
||||||
public const NOT_FOUND_TEMPLATE = '404.html';
|
public const NOT_FOUND_TEMPLATE = '404.html';
|
||||||
public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
|
public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
|
||||||
|
|
||||||
private Closure $readFile;
|
private Closure $readFile;
|
||||||
|
|
||||||
public function __construct(?callable $readFile = null)
|
public function __construct(?callable $readFile = null)
|
||||||
|
|||||||
@@ -8,33 +8,17 @@ use Psr\Http\Message\ResponseInterface;
|
|||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
|
||||||
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
|
|
||||||
|
|
||||||
class NotFoundTrackerMiddleware implements MiddlewareInterface
|
class NotFoundTrackerMiddleware implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
private VisitsTrackerInterface $visitsTracker;
|
public function __construct(private RequestTrackerInterface $requestTracker)
|
||||||
|
|
||||||
public function __construct(VisitsTrackerInterface $visitsTracker)
|
|
||||||
{
|
{
|
||||||
$this->visitsTracker = $visitsTracker;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
{
|
{
|
||||||
/** @var NotFoundType $notFoundType */
|
$this->requestTracker->trackNotFoundIfApplicable($request);
|
||||||
$notFoundType = $request->getAttribute(NotFoundType::class);
|
|
||||||
$visitor = Visitor::fromRequest($request);
|
|
||||||
|
|
||||||
if ($notFoundType->isBaseUrl()) {
|
|
||||||
$this->visitsTracker->trackBaseUrlVisit($visitor);
|
|
||||||
} elseif ($notFoundType->isRegularNotFound()) {
|
|
||||||
$this->visitsTracker->trackRegularNotFoundVisit($visitor);
|
|
||||||
} elseif ($notFoundType->isInvalidShortUrl()) {
|
|
||||||
$this->visitsTracker->trackInvalidShortUrlVisit($visitor);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
|||||||
|
|
||||||
class NotFoundTypeResolverMiddleware implements MiddlewareInterface
|
class NotFoundTypeResolverMiddleware implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
private string $shlinkBasePath;
|
public function __construct(private string $shlinkBasePath)
|
||||||
|
|
||||||
public function __construct(string $shlinkBasePath)
|
|
||||||
{
|
{
|
||||||
$this->shlinkBasePath = $shlinkBasePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
|||||||
|
|
||||||
class CloseDbConnectionEventListener
|
class CloseDbConnectionEventListener
|
||||||
{
|
{
|
||||||
private ReopeningEntityManagerInterface $em;
|
|
||||||
/** @var callable */
|
/** @var callable */
|
||||||
private $wrapped;
|
private $wrapped;
|
||||||
|
|
||||||
public function __construct(ReopeningEntityManagerInterface $em, callable $wrapped)
|
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
|
||||||
{
|
{
|
||||||
$this->em = $em;
|
|
||||||
$this->wrapped = $wrapped;
|
$this->wrapped = $wrapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user