Compare commits

...

37 Commits

Author SHA1 Message Date
Alejandro Celaya
8efda2ef56 Merge pull request #1108 from kanadaj/develop
Change the Docker user to non-root
2021-07-15 20:19:42 +02:00
Alejandro Celaya
f86cda6730 Removed deprecated env var for publish release 2021-07-15 19:53:42 +02:00
Alejandro Celaya
43f59a19fb Merge pull request #1120 from acelaya-forks/feature/redirect-with-extra-path
Feature/redirect with extra path
2021-07-15 19:48:16 +02:00
Alejandro Celaya
eabaa94e06 Created ExtraPathRedirectMiddleware test 2021-07-15 19:37:09 +02:00
Alejandro Celaya
20575a2b0f Added support to provide append_extra_path config from installer or env vars for docker 2021-07-15 18:57:32 +02:00
Alejandro Celaya
0096a778ac Created RequestTracker test 2021-07-15 17:43:29 +02:00
Alejandro Celaya
050f83e3bb Wrapped logic to track requests to a new RequestTracker service 2021-07-15 17:23:09 +02:00
Alejandro Celaya
32f7b4fbf6 Created new middleware that redirects to short URLs with an extra path 2021-07-15 16:54:54 +02:00
Alejandro Celaya
265e8cdeaf Refactored tracking actions 2021-07-15 13:28:31 +02:00
Alejandro Celaya
fe5460e0c5 Created ShortUrlRedirectBuilder test 2021-07-14 16:44:21 +02:00
Alejandro Celaya
d4cad337fc Created component wrapping the logic to determine what's the URL to redirect to for a ShortUrl 2021-07-14 16:36:03 +02:00
Alejandro Celaya
0af6ecbd34 Merge pull request #1115 from acelaya-forks/feature/qr-code-correction
Feature/qr code correction
2021-07-13 14:13:34 +02:00
Alejandro Celaya
6466045363 Updated changelog 2021-07-13 14:00:54 +02:00
Alejandro Celaya
67c7e503d9 Used lowercase values when trying to match the QR code error level 2021-07-13 13:55:00 +02:00
Alejandro Celaya
01e06f0503 Improved swagger docs for QR code endpoint 2021-07-13 13:53:10 +02:00
Alejandro Celaya
d6e155d874 Extracted logic to determine QR code params to its own data object 2021-07-13 13:46:01 +02:00
Alejandro Celaya
5a2350bac1 Added suport for error correction level to QR codes 2021-07-13 13:22:50 +02:00
kanadaj
2b97f9ac9e Update Dockerfile
Security update
2021-06-13 23:54:35 +01:00
kanadaj
090b215179 Update Dockerfile 2021-06-13 23:51:16 +01:00
Alejandro Celaya
32f483c333 Merge pull request #1107 from PxSonny/patch-1
Update CONTRIBUTING.md
2021-06-13 21:21:44 +02:00
Sonny Alves Dias
655652f94f Update CONTRIBUTING.md
Fixing a typo
2021-06-13 22:24:20 +08:00
Alejandro Celaya
53b84c147c Merge branch 'develop' of github.com:shlinkio/shlink into develop 2021-05-30 17:55:37 +02:00
Alejandro Celaya
d8b4827601 Updated changelog 2021-05-30 17:55:30 +02:00
Alejandro Celaya
5737acf759 Merge pull request #1099 from mikafouenski/develop
Run periodic `visit:locate` as opt-in
2021-05-30 17:55:13 +02:00
Alejandro Celaya
58262e8604 Update docker/docker-entrypoint.sh 2021-05-30 17:41:40 +02:00
Alejandro Celaya
b9e5eaf689 Update docker/docker-entrypoint.sh 2021-05-30 17:41:00 +02:00
Alejandro Celaya
6d78cd59e9 Fixed merge conflicts 2021-05-30 13:31:37 +02:00
Alejandro Celaya
aa00e33b6d Added v2.7.1 to changelog 2021-05-30 13:25:37 +02:00
Alejandro Celaya
4ef04c641e Merge pull request #1101 from acelaya-forks/feature/disable-geolite-download
Feature/disable geolite download
2021-05-30 13:02:30 +02:00
Alejandro Celaya
bfcccd8c33 Added test to check for GeoLite db update disabling based on tracking options 2021-05-30 12:36:58 +02:00
Alejandro Celaya
f7d3c73c4a Skip downloading GeoLite db if global tracking or IP tracking are disabled 2021-05-30 12:30:03 +02:00
Mickaël Bernardini
bfdece1c23 add ENABLE_PERIODIC_VISIT_LOCATE opt-in
This will trigger `visit:locate` every hour
2021-05-26 15:45:24 +02:00
Alejandro Celaya
a68f450d36 Merge pull request #1097 from acelaya-forks/feature/php8
Feature/php8
2021-05-23 12:54:12 +02:00
Alejandro Celaya
d1df225e47 Moved changelog line 2021-05-23 12:39:00 +02:00
Alejandro Celaya
9c6ba4bc61 More PHP 8 syntactic sugar 2021-05-23 12:37:53 +02:00
Alejandro Celaya
c01121d61a Added nullsafe operator to simplify conditions 2021-05-23 12:31:10 +02:00
Alejandro Celaya
e0f0bb5523 Migrated all constructor props to property promotion when possible 2021-05-23 11:57:31 +02:00
196 changed files with 1226 additions and 1101 deletions

View File

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

View File

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

View File

@@ -4,6 +4,49 @@ 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).
## [Unreleased]
### 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.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
### Fixed
* *Nothing*
## [2.7.1] - 2021-05-30
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1100](https://github.com/shlinkio/shlink/issues/1100) Fixed Shlink trying to download GeoLite2 db files even when tracking has been disabled.
## [2.7.0] - 2021-05-23 ## [2.7.0] - 2021-05-23
### Added ### Added
* [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows. * [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows.

View File

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

View File

@@ -78,4 +78,13 @@ 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
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"]

View File

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

View File

@@ -12,7 +12,7 @@
} }
], ],
"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",
@@ -51,8 +51,8 @@
"shlinkio/shlink-config": "^1.0", "shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3", "shlinkio/shlink-importer": "^2.3",
"shlinkio/shlink-installer": "^6.0", "shlinkio/shlink-installer": "dev-develop#fa6a4ca as 6.1",
"shlinkio/shlink-ip-geolocation": "^1.5", "shlinkio/shlink-ip-geolocation": "^2.0",
"symfony/console": "^5.1", "symfony/console": "^5.1",
"symfony/filesystem": "^5.1", "symfony/filesystem": "^5.1",
"symfony/lock": "^5.1", "symfony/lock": "^5.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' => [
@@ -167,7 +168,7 @@ return [
], ],
'geolite2' => [ 'geolite2' => [
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove the default value 'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove hardcoded license on v3
], ],
'mercure' => $helper->getMercureConfig(), 'mercure' => $helper->getMercureConfig(),

View File

@@ -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 "Starting periodic visite 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

View File

@@ -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": {

View File

@@ -10,6 +10,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory; use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
@@ -64,7 +65,12 @@ return [
], ],
ConfigAbstractFactory::class => [ ConfigAbstractFactory::class => [
Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY], Util\GeolocationDbUpdater::class => [
DbUpdater::class,
Reader::class,
LOCAL_LOCK_FACTORY,
TrackingOptions::class,
],
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class], Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
ApiKey\RoleResolver::class => [DomainService::class], ApiKey\RoleResolver::class => [DomainService::class],

View File

@@ -10,11 +10,8 @@ use Symfony\Component\Console\Input\InputInterface;
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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ abstract class BaseCommand extends Command
?string $shortcut = null, ?string $shortcut = null,
?int $mode = null, ?int $mode = null,
string $description = '', string $description = '',
$default = null $default = null,
): self { ): self {
$this->addOption($name, $shortcut, $mode, $description, $default); $this->addOption($name, $shortcut, $mode, $description, $default);

View File

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

View File

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

View File

@@ -18,12 +18,9 @@ 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,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;

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader; use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata; use MaxMind\Db\Reader\Metadata;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException; use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockFactory;
@@ -18,15 +19,12 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{ {
private const LOCK_NAME = 'geolocation-db-update'; private const LOCK_NAME = 'geolocation-db-update';
private DbUpdaterInterface $dbUpdater; public function __construct(
private Reader $geoLiteDbReader; private DbUpdaterInterface $dbUpdater,
private LockFactory $locker; private Reader $geoLiteDbReader,
private LockFactory $locker,
public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, LockFactory $locker) private TrackingOptions $trackingOptions
{ ) {
$this->dbUpdater = $dbUpdater;
$this->geoLiteDbReader = $geoLiteDbReader;
$this->locker = $locker;
} }
/** /**
@@ -34,6 +32,10 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
*/ */
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void
{ {
if ($this->trackingOptions->disableTracking() || $this->trackingOptions->disableIpTracking()) {
return;
}
$lock = $this->locker->createLock(self::LOCK_NAME); $lock = $this->locker->createLock(self::LOCK_NAME);
$lock->acquire(true); // Block until lock is released $lock->acquire(true); // Block until lock is released

View File

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

View File

@@ -12,11 +12,8 @@ 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; public function __construct(private Table $baseTable)
public function __construct(Table $baseTable)
{ {
$this->baseTable = $baseTable;
} }
public static function fromOutput(OutputInterface $output): self public static function fromOutput(OutputInterface $output): self

View File

@@ -33,7 +33,7 @@ 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'), (new Domain('example.com'))->setId('1'),

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException; use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock; use Symfony\Component\Lock;
@@ -28,11 +29,13 @@ class GeolocationDbUpdaterTest extends TestCase
private GeolocationDbUpdater $geolocationDbUpdater; private GeolocationDbUpdater $geolocationDbUpdater;
private ObjectProphecy $dbUpdater; private ObjectProphecy $dbUpdater;
private ObjectProphecy $geoLiteDbReader; private ObjectProphecy $geoLiteDbReader;
private TrackingOptions $trackingOptions;
public function setUp(): void public function setUp(): void
{ {
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class); $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$this->geoLiteDbReader = $this->prophesize(Reader::class); $this->geoLiteDbReader = $this->prophesize(Reader::class);
$this->trackingOptions = new TrackingOptions();
$locker = $this->prophesize(Lock\LockFactory::class); $locker = $this->prophesize(Lock\LockFactory::class);
$lock = $this->prophesize(Lock\LockInterface::class); $lock = $this->prophesize(Lock\LockInterface::class);
@@ -45,6 +48,7 @@ class GeolocationDbUpdaterTest extends TestCase
$this->dbUpdater->reveal(), $this->dbUpdater->reveal(),
$this->geoLiteDbReader->reveal(), $this->geoLiteDbReader->reveal(),
$locker->reveal(), $locker->reveal(),
$this->trackingOptions,
); );
} }
@@ -174,4 +178,27 @@ class GeolocationDbUpdaterTest extends TestCase
'record_size' => 4, 'record_size' => 4,
]); ]);
} }
/**
* @test
* @dataProvider provideTrackingOptions
*/
public function downloadDbIsSkippedIfTrackingIsDisabled(array $props): void
{
foreach ($props as $prop) {
$this->trackingOptions->{$prop} = true;
}
$this->geolocationDbUpdater->checkDbUpdate();
$this->dbUpdater->databaseFileExists(Argument::cetera())->shouldNotHaveBeenCalled();
$this->geoLiteDbReader->metadata(Argument::cetera())->shouldNotHaveBeenCalled();
}
public function provideTrackingOptions(): iterable
{
yield 'disableTracking' => [['disableTracking']];
yield 'disableIpTracking' => [['disableIpTracking']];
yield 'both' => [['disableTracking', 'disableIpTracking']];
}
} }

View File

@@ -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,
@@ -53,7 +54,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,7 +72,7 @@ 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, Util\RedirectResponseHelper::class,
@@ -92,6 +95,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,
@@ -116,17 +120,11 @@ return [
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 +135,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,

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -17,11 +17,8 @@ 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

View File

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

View File

@@ -16,13 +16,8 @@ use function Functional\map;
class DomainService implements DomainServiceInterface class DomainService implements DomainServiceInterface
{ {
private EntityManagerInterface $em; public function __construct(private EntityManagerInterface $em, private string $defaultDomain)
private string $defaultDomain;
public function __construct(EntityManagerInterface $em, string $defaultDomain)
{ {
$this->em = $em;
$this->defaultDomain = $defaultDomain;
} }
/** /**
@@ -35,7 +30,7 @@ class DomainService implements DomainServiceInterface
$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) => new DomainItem($domain->getAuthority(), false));
if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) { if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
return $mappedDomains; return $mappedDomains;
} }

View File

@@ -8,13 +8,8 @@ use JsonSerializable;
final class DomainItem implements JsonSerializable final class DomainItem implements JsonSerializable
{ {
private string $domain; public function __construct(private string $domain, private bool $isDefault)
private bool $isDefault;
public function __construct(string $domain, bool $isDefault)
{ {
$this->domain = $domain;
$this->isDefault = $isDefault;
} }
public function jsonSerialize(): array public function jsonSerialize(): array

View File

@@ -9,11 +9,8 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Domain extends AbstractEntity implements JsonSerializable class Domain extends AbstractEntity implements JsonSerializable
{ {
private string $authority; public function __construct(private string $authority)
public function __construct(string $authority)
{ {
$this->authority = $authority;
} }
public function getAuthority(): string public function getAuthority(): string

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,15 +14,10 @@ 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 RedirectResponseHelperInterface $redirectResponseHelper
) { ) {
$this->redirectOptions = $redirectOptions;
$this->redirectResponseHelper = $redirectResponseHelper;
} }
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ class CloseDbConnectionEventListenerDelegator
public function __invoke( public function __invoke(
ContainerInterface $container, ContainerInterface $container,
string $name, string $name,
callable $callback callable $callback,
): CloseDbConnectionEventListener { ): CloseDbConnectionEventListener {
/** @var callable $wrapped */ /** @var callable $wrapped */
$wrapped = $callback(); $wrapped = $callback();

View File

@@ -8,11 +8,8 @@ use JsonSerializable;
abstract class AbstractVisitEvent implements JsonSerializable abstract class AbstractVisitEvent implements JsonSerializable
{ {
protected string $visitId; public function __construct(protected string $visitId)
public function __construct(string $visitId)
{ {
$this->visitId = $visitId;
} }
public function visitId(): string public function visitId(): string

View File

@@ -6,12 +6,9 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
final class UrlVisited extends AbstractVisitEvent final class UrlVisited extends AbstractVisitEvent
{ {
private ?string $originalIpAddress; public function __construct(string $visitId, private ?string $originalIpAddress = null)
public function __construct(string $visitId, ?string $originalIpAddress = null)
{ {
parent::__construct($visitId); parent::__construct($visitId);
$this->originalIpAddress = $originalIpAddress;
} }
public function originalIpAddress(): ?string public function originalIpAddress(): ?string

View File

@@ -19,24 +19,13 @@ use Throwable;
class LocateVisit class LocateVisit
{ {
private IpLocationResolverInterface $ipLocationResolver;
private EntityManagerInterface $em;
private LoggerInterface $logger;
private DbUpdaterInterface $dbUpdater;
private EventDispatcherInterface $eventDispatcher;
public function __construct( public function __construct(
IpLocationResolverInterface $ipLocationResolver, private IpLocationResolverInterface $ipLocationResolver,
EntityManagerInterface $em, private EntityManagerInterface $em,
LoggerInterface $logger, private LoggerInterface $logger,
DbUpdaterInterface $dbUpdater, private DbUpdaterInterface $dbUpdater,
EventDispatcherInterface $eventDispatcher private EventDispatcherInterface $eventDispatcher
) { ) {
$this->ipLocationResolver = $ipLocationResolver;
$this->em = $em;
$this->logger = $logger;
$this->dbUpdater = $dbUpdater;
$this->eventDispatcher = $eventDispatcher;
} }
public function __invoke(UrlVisited $shortUrlVisited): void public function __invoke(UrlVisited $shortUrlVisited): void

View File

@@ -17,21 +17,12 @@ use function Functional\each;
class NotifyVisitToMercure class NotifyVisitToMercure
{ {
private HubInterface $hub;
private MercureUpdatesGeneratorInterface $updatesGenerator;
private EntityManagerInterface $em;
private LoggerInterface $logger;
public function __construct( public function __construct(
HubInterface $hub, private HubInterface $hub,
MercureUpdatesGeneratorInterface $updatesGenerator, private MercureUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em, private EntityManagerInterface $em,
LoggerInterface $logger private LoggerInterface $logger
) { ) {
$this->hub = $hub;
$this->em = $em;
$this->logger = $logger;
$this->updatesGenerator = $updatesGenerator;
} }
public function __invoke(VisitLocated $shortUrlLocated): void public function __invoke(VisitLocated $shortUrlLocated): void

View File

@@ -24,28 +24,15 @@ use function Functional\partial_left;
class NotifyVisitToWebHooks class NotifyVisitToWebHooks
{ {
private ClientInterface $httpClient;
private EntityManagerInterface $em;
private LoggerInterface $logger;
/** @var string[] */
private array $webhooks;
private DataTransformerInterface $transformer;
private AppOptions $appOptions;
public function __construct( public function __construct(
ClientInterface $httpClient, private ClientInterface $httpClient,
EntityManagerInterface $em, private EntityManagerInterface $em,
LoggerInterface $logger, private LoggerInterface $logger,
array $webhooks, /** @var string[] */
DataTransformerInterface $transformer, private array $webhooks,
AppOptions $appOptions private DataTransformerInterface $transformer,
private AppOptions $appOptions
) { ) {
$this->httpClient = $httpClient;
$this->em = $em;
$this->logger = $logger;
$this->webhooks = $webhooks;
$this->transformer = $transformer;
$this->appOptions = $appOptions;
} }
public function __invoke(VisitLocated $shortUrlLocated): void public function __invoke(VisitLocated $shortUrlLocated): void

View File

@@ -12,13 +12,8 @@ use function sprintf;
class UpdateGeoLiteDb class UpdateGeoLiteDb
{ {
private GeolocationDbUpdaterInterface $dbUpdater; public function __construct(private GeolocationDbUpdaterInterface $dbUpdater, private LoggerInterface $logger)
private LoggerInterface $logger;
public function __construct(GeolocationDbUpdaterInterface $dbUpdater, LoggerInterface $logger)
{ {
$this->dbUpdater = $dbUpdater;
$this->logger = $logger;
} }
public function __invoke(): void public function __invoke(): void

View File

@@ -20,22 +20,14 @@ use function sprintf;
class ImportedLinksProcessor implements ImportedLinksProcessorInterface class ImportedLinksProcessor implements ImportedLinksProcessorInterface
{ {
private EntityManagerInterface $em;
private ShortUrlRelationResolverInterface $relationResolver;
private ShortCodeHelperInterface $shortCodeHelper;
private DoctrineBatchHelperInterface $batchHelper;
private ShortUrlRepositoryInterface $shortUrlRepo; private ShortUrlRepositoryInterface $shortUrlRepo;
public function __construct( public function __construct(
EntityManagerInterface $em, private EntityManagerInterface $em,
ShortUrlRelationResolverInterface $relationResolver, private ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper, private ShortCodeHelperInterface $shortCodeHelper,
DoctrineBatchHelperInterface $batchHelper private DoctrineBatchHelperInterface $batchHelper
) { ) {
$this->em = $em;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
$this->batchHelper = $batchHelper;
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line
} }
@@ -64,7 +56,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
try { try {
$shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict); $shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict);
} catch (NonUniqueSlugException $e) { } catch (NonUniqueSlugException) {
$io->text(sprintf('%s: <fg=red>Error</>', $longUrl)); $io->text(sprintf('%s: <fg=red>Error</>', $longUrl));
continue; continue;
} }
@@ -77,7 +69,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private function resolveShortUrl( private function resolveShortUrl(
ImportedShlinkUrl $importedUrl, ImportedShlinkUrl $importedUrl,
bool $importShortCodes, bool $importShortCodes,
callable $skipOnShortCodeConflict callable $skipOnShortCodeConflict,
): ShortUrlImporting { ): ShortUrlImporting {
$alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl); $alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl);
if ($alreadyImportedShortUrl !== null) { if ($alreadyImportedShortUrl !== null) {
@@ -96,7 +88,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private function handleShortCodeUniqueness( private function handleShortCodeUniqueness(
ShortUrl $shortUrl, ShortUrl $shortUrl,
bool $importShortCodes, bool $importShortCodes,
callable $skipOnShortCodeConflict callable $skipOnShortCodeConflict,
): bool { ): bool {
if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) { if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) {
return true; return true;

View File

@@ -14,13 +14,8 @@ use function sprintf;
final class ShortUrlImporting final class ShortUrlImporting
{ {
private ShortUrl $shortUrl; private function __construct(private ShortUrl $shortUrl, private bool $isNew)
private bool $isNew;
private function __construct(ShortUrl $shortUrl, bool $isNew)
{ {
$this->shortUrl = $shortUrl;
$this->isNew = $isNew;
} }
public static function fromExistingShortUrl(ShortUrl $shortUrl): self public static function fromExistingShortUrl(ShortUrl $shortUrl): self
@@ -43,10 +38,7 @@ final class ShortUrlImporting
$importedVisits = 0; $importedVisits = 0;
foreach ($visits as $importedVisit) { foreach ($visits as $importedVisit) {
// Skip visits which are older than the most recent already imported visit's date // Skip visits which are older than the most recent already imported visit's date
if ( if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date()))) {
$mostRecentImportedDate !== null
&& $mostRecentImportedDate->gte(Chronos::instance($importedVisit->date()))
) {
continue; continue;
} }

View File

@@ -18,15 +18,10 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit'; private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit';
private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit'; private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit';
private DataTransformerInterface $shortUrlTransformer;
private DataTransformerInterface $orphanVisitTransformer;
public function __construct( public function __construct(
DataTransformerInterface $shortUrlTransformer, private DataTransformerInterface $shortUrlTransformer,
DataTransformerInterface $orphanVisitTransformer private DataTransformerInterface $orphanVisitTransformer
) { ) {
$this->shortUrlTransformer = $shortUrlTransformer;
$this->orphanVisitTransformer = $orphanVisitTransformer;
} }
public function newVisitUpdate(Visit $visit): Update public function newVisitUpdate(Visit $visit): Update

View File

@@ -10,13 +10,8 @@ use Symfony\Component\Console\Input\InputInterface;
final class ShortUrlIdentifier final class ShortUrlIdentifier
{ {
private string $shortCode; public function __construct(private string $shortCode, private ?string $domain = null)
private ?string $domain;
public function __construct(string $shortCode, ?string $domain = null)
{ {
$this->shortCode = $shortCode;
$this->domain = $domain;
} }
public static function fromApiRequest(ServerRequestInterface $request): self public static function fromApiRequest(ServerRequestInterface $request): self
@@ -46,7 +41,7 @@ final class ShortUrlIdentifier
public static function fromShortUrl(ShortUrl $shortUrl): self public static function fromShortUrl(ShortUrl $shortUrl): self
{ {
$domain = $shortUrl->getDomain(); $domain = $shortUrl->getDomain();
$domainAuthority = $domain !== null ? $domain->getAuthority() : null; $domainAuthority = $domain?->getAuthority();
return new self($shortUrl->getShortCode(), $domainAuthority); return new self($shortUrl->getShortCode(), $domainAuthority);
} }

View File

@@ -14,20 +14,16 @@ final class VisitsParams
private const ALL_ITEMS = -1; private const ALL_ITEMS = -1;
private ?DateRange $dateRange; private ?DateRange $dateRange;
private int $page;
private int $itemsPerPage; private int $itemsPerPage;
private bool $excludeBots;
public function __construct( public function __construct(
?DateRange $dateRange = null, ?DateRange $dateRange = null,
int $page = self::FIRST_PAGE, private int $page = self::FIRST_PAGE,
?int $itemsPerPage = null, ?int $itemsPerPage = null,
bool $excludeBots = false private bool $excludeBots = false
) { ) {
$this->dateRange = $dateRange ?? new DateRange(); $this->dateRange = $dateRange ?? new DateRange();
$this->page = $page;
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage); $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
$this->excludeBots = $excludeBots;
} }
private function determineItemsPerPage(?int $itemsPerPage): int private function determineItemsPerPage(?int $itemsPerPage): int

View File

@@ -19,6 +19,7 @@ class UrlShortenerOptions extends AbstractOptions
private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
private bool $autoResolveTitles = false; private bool $autoResolveTitles = false;
private bool $appendExtraPath = false;
public function isUrlValidationEnabled(): bool public function isUrlValidationEnabled(): bool
{ {
@@ -67,6 +68,16 @@ class UrlShortenerOptions extends AbstractOptions
$this->autoResolveTitles = $autoResolveTitles; $this->autoResolveTitles = $autoResolveTitles;
} }
public function appendExtraPath(): bool
{
return $this->appendExtraPath;
}
protected function setAppendExtraPath(bool $appendExtraPath): void
{
$this->appendExtraPath = $appendExtraPath;
}
/** @deprecated */ /** @deprecated */
protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void
{ {

View File

@@ -11,13 +11,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{ {
private VisitRepositoryInterface $repo; public function __construct(private VisitRepositoryInterface $repo, private VisitsParams $params)
private VisitsParams $params;
public function __construct(VisitRepositoryInterface $repo, VisitsParams $params)
{ {
$this->repo = $repo;
$this->params = $params;
} }
protected function doCount(): int protected function doCount(): int

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter; namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Happyr\DoctrineSpecification\Specification\Specification;
use Pagerfanta\Adapter\AdapterInterface; use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
@@ -12,15 +11,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface class ShortUrlRepositoryAdapter implements AdapterInterface
{ {
private ShortUrlRepositoryInterface $repository; public function __construct(
private ShortUrlsParams $params; private ShortUrlRepositoryInterface $repository,
private ?ApiKey $apiKey; private ShortUrlsParams $params,
private ?ApiKey $apiKey
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params, ?ApiKey $apiKey) ) {
{
$this->repository = $repository;
$this->params = $params;
$this->apiKey = $apiKey;
} }
public function getSlice($offset, $length): array // phpcs:ignore public function getSlice($offset, $length): array // phpcs:ignore
@@ -32,7 +27,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->tags(), $this->params->tags(),
$this->params->orderBy(), $this->params->orderBy(),
$this->params->dateRange(), $this->params->dateRange(),
$this->resolveSpec(), $this->apiKey?->spec(),
); );
} }
@@ -42,12 +37,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->searchTerm(), $this->params->searchTerm(),
$this->params->tags(), $this->params->tags(),
$this->params->dateRange(), $this->params->dateRange(),
$this->resolveSpec(), $this->apiKey?->spec(),
); );
} }
private function resolveSpec(): ?Specification
{
return $this->apiKey !== null ? $this->apiKey->spec() : null;
}
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter; namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
@@ -13,21 +12,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{ {
private VisitRepositoryInterface $visitRepository;
private string $tag;
private VisitsParams $params;
private ?ApiKey $apiKey;
public function __construct( public function __construct(
VisitRepositoryInterface $visitRepository, private VisitRepositoryInterface $visitRepository,
string $tag, private string $tag,
VisitsParams $params, private VisitsParams $params,
?ApiKey $apiKey private ?ApiKey $apiKey
) { ) {
$this->visitRepository = $visitRepository;
$this->params = $params;
$this->tag = $tag;
$this->apiKey = $apiKey;
} }
public function getSlice($offset, $length): array // phpcs:ignore public function getSlice($offset, $length): array // phpcs:ignore
@@ -37,7 +27,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
new VisitsListFiltering( new VisitsListFiltering(
$this->params->getDateRange(), $this->params->getDateRange(),
$this->params->excludeBots(), $this->params->excludeBots(),
$this->resolveSpec(), $this->apiKey?->spec(true),
$length, $length,
$offset, $offset,
), ),
@@ -51,13 +41,8 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
new VisitsCountFiltering( new VisitsCountFiltering(
$this->params->getDateRange(), $this->params->getDateRange(),
$this->params->excludeBots(), $this->params->excludeBots(),
$this->resolveSpec(), $this->apiKey?->spec(true),
), ),
); );
} }
private function resolveSpec(): ?Specification
{
return $this->apiKey !== null ? $this->apiKey->spec(true) : null;
}
} }

View File

@@ -13,21 +13,12 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{ {
private VisitRepositoryInterface $visitRepository;
private ShortUrlIdentifier $identifier;
private VisitsParams $params;
private ?Specification $spec;
public function __construct( public function __construct(
VisitRepositoryInterface $visitRepository, private VisitRepositoryInterface $visitRepository,
ShortUrlIdentifier $identifier, private ShortUrlIdentifier $identifier,
VisitsParams $params, private VisitsParams $params,
?Specification $spec private ?Specification $spec
) { ) {
$this->visitRepository = $visitRepository;
$this->params = $params;
$this->identifier = $identifier;
$this->spec = $spec;
} }
public function getSlice($offset, $length): array // phpcs:ignore public function getSlice($offset, $length): array // phpcs:ignore

View File

@@ -35,7 +35,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
array $tags = [], array $tags = [],
?ShortUrlsOrdering $orderBy = null, ?ShortUrlsOrdering $orderBy = null,
?DateRange $dateRange = null, ?DateRange $dateRange = null,
?Specification $spec = null ?Specification $spec = null,
): array { ): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
$qb->select('DISTINCT s') $qb->select('DISTINCT s')
@@ -43,7 +43,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
->setFirstResult($offset); ->setFirstResult($offset);
// In case the ordering has been specified, the query could be more complex. Process it // In case the ordering has been specified, the query could be more complex. Process it
if ($orderBy !== null && $orderBy->hasOrderField()) { if ($orderBy?->hasOrderField()) {
return $this->processOrderByForList($qb, $orderBy); return $this->processOrderByForList($qb, $orderBy);
} }
@@ -85,7 +85,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
?string $searchTerm = null, ?string $searchTerm = null,
array $tags = [], array $tags = [],
?DateRange $dateRange = null, ?DateRange $dateRange = null,
?Specification $spec = null ?Specification $spec = null,
): int { ): int {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
$qb->select('COUNT(DISTINCT s)'); $qb->select('COUNT(DISTINCT s)');
@@ -97,17 +97,17 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
?string $searchTerm, ?string $searchTerm,
array $tags, array $tags,
?DateRange $dateRange, ?DateRange $dateRange,
?Specification $spec ?Specification $spec,
): QueryBuilder { ): QueryBuilder {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's') $qb->from(ShortUrl::class, 's')
->where('1=1'); ->where('1=1');
if ($dateRange !== null && $dateRange->getStartDate() !== null) { if ($dateRange?->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME); $qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
} }
if ($dateRange !== null && $dateRange->getEndDate() !== null) { if ($dateRange?->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
$qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME); $qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME);
} }

View File

@@ -23,14 +23,14 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
array $tags = [], array $tags = [],
?ShortUrlsOrdering $orderBy = null, ?ShortUrlsOrdering $orderBy = null,
?DateRange $dateRange = null, ?DateRange $dateRange = null,
?Specification $spec = null ?Specification $spec = null,
): array; ): array;
public function countList( public function countList(
?string $searchTerm = null, ?string $searchTerm = null,
array $tags = [], array $tags = [],
?DateRange $dateRange = null, ?DateRange $dateRange = null,
?Specification $spec = null ?Specification $spec = null,
): int; ): int;
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl; public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;

View File

@@ -71,14 +71,14 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
$iterator = $qb->getQuery()->toIterable(); $iterator = $qb->getQuery()->toIterable();
$resultsFound = false; $resultsFound = false;
/** @var Visit $visit */
foreach ($iterator as $key => $visit) { foreach ($iterator as $key => $visit) {
$resultsFound = true; $resultsFound = true;
yield $key => $visit; yield $key => $visit;
} }
// As the query is ordered by ID, we can take the last one every time in order to exclude the whole list // As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
$lastId = isset($visit) ? $visit->getId() : $lastId; /** @var Visit|null $visit */
$lastId = $visit?->getId() ?? $lastId;
} while ($resultsFound); } while ($resultsFound);
} }
@@ -101,12 +101,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
private function createVisitsByShortCodeQueryBuilder( private function createVisitsByShortCodeQueryBuilder(
ShortUrlIdentifier $identifier, ShortUrlIdentifier $identifier,
VisitsCountFiltering $filtering VisitsCountFiltering $filtering,
): QueryBuilder { ): QueryBuilder {
/** @var ShortUrlRepositoryInterface $shortUrlRepo */ /** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec()); $shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec());
$shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1; $shortUrlId = $shortUrl?->getId() ?? '-1';
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
// Since they are not strictly provided by the caller, it's reasonably safe // Since they are not strictly provided by the caller, it's reasonably safe
@@ -187,10 +187,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
{ {
if ($dateRange !== null && $dateRange->getStartDate() !== null) { if ($dateRange?->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\'')); $qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
} }
if ($dateRange !== null && $dateRange->getEndDate() !== null) { if ($dateRange?->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\'')); $qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
} }
} }

View File

@@ -13,18 +13,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DeleteShortUrlService implements DeleteShortUrlServiceInterface class DeleteShortUrlService implements DeleteShortUrlServiceInterface
{ {
private EntityManagerInterface $em;
private DeleteShortUrlsOptions $deleteShortUrlsOptions;
private ShortUrlResolverInterface $urlResolver;
public function __construct( public function __construct(
EntityManagerInterface $em, private EntityManagerInterface $em,
DeleteShortUrlsOptions $deleteShortUrlsOptions, private DeleteShortUrlsOptions $deleteShortUrlsOptions,
ShortUrlResolverInterface $urlResolver private ShortUrlResolverInterface $urlResolver
) { ) {
$this->em = $em;
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
$this->urlResolver = $urlResolver;
} }
/** /**
@@ -34,7 +27,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
public function deleteByShortCode( public function deleteByShortCode(
ShortUrlIdentifier $identifier, ShortUrlIdentifier $identifier,
bool $ignoreThreshold = false, bool $ignoreThreshold = false,
?ApiKey $apiKey = null ?ApiKey $apiKey = null,
): void { ): void {
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {

View File

@@ -17,6 +17,6 @@ interface DeleteShortUrlServiceInterface
public function deleteByShortCode( public function deleteByShortCode(
ShortUrlIdentifier $identifier, ShortUrlIdentifier $identifier,
bool $ignoreThreshold = false, bool $ignoreThreshold = false,
?ApiKey $apiKey = null ?ApiKey $apiKey = null,
): void; ): void;
} }

View File

@@ -11,11 +11,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper
{ {
private EntityManagerInterface $em; public function __construct(private EntityManagerInterface $em)
public function __construct(EntityManagerInterface $em)
{ {
$this->em = $em;
} }
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool

View File

@@ -13,11 +13,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlResolver implements ShortUrlResolverInterface class ShortUrlResolver implements ShortUrlResolverInterface
{ {
private EntityManagerInterface $em; public function __construct(private EntityManagerInterface $em)
public function __construct(EntityManagerInterface $em)
{ {
$this->em = $em;
} }
/** /**
@@ -27,7 +24,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface
{ {
/** @var ShortUrlRepository $shortUrlRepo */ /** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class); $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null); $shortUrl = $shortUrlRepo->findOne($identifier, $apiKey?->spec());
if ($shortUrl === null) { if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFound($identifier); throw ShortUrlNotFoundException::fromNotFound($identifier);
} }
@@ -43,7 +40,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface
/** @var ShortUrlRepository $shortUrlRepo */ /** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class); $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier->shortCode(), $identifier->domain()); $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier->shortCode(), $identifier->domain());
if ($shortUrl === null || ! $shortUrl->isEnabled()) { if (! $shortUrl?->isEnabled()) {
throw ShortUrlNotFoundException::fromNotFound($identifier); throw ShortUrlNotFoundException::fromNotFound($identifier);
} }

View File

@@ -21,21 +21,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlService implements ShortUrlServiceInterface class ShortUrlService implements ShortUrlServiceInterface
{ {
private ORM\EntityManagerInterface $em;
private ShortUrlResolverInterface $urlResolver;
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
private ShortUrlRelationResolverInterface $relationResolver;
public function __construct( public function __construct(
ORM\EntityManagerInterface $em, private ORM\EntityManagerInterface $em,
ShortUrlResolverInterface $urlResolver, private ShortUrlResolverInterface $urlResolver,
ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
ShortUrlRelationResolverInterface $relationResolver private ShortUrlRelationResolverInterface $relationResolver
) { ) {
$this->em = $em;
$this->urlResolver = $urlResolver;
$this->titleResolutionHelper = $titleResolutionHelper;
$this->relationResolver = $relationResolver;
} }
/** /**
@@ -59,7 +50,7 @@ class ShortUrlService implements ShortUrlServiceInterface
public function updateShortUrl( public function updateShortUrl(
ShortUrlIdentifier $identifier, ShortUrlIdentifier $identifier,
ShortUrlEdit $shortUrlEdit, ShortUrlEdit $shortUrlEdit,
?ApiKey $apiKey = null ?ApiKey $apiKey = null,
): ShortUrl { ): ShortUrl {
if ($shortUrlEdit->longUrlWasProvided()) { if ($shortUrlEdit->longUrlWasProvided()) {
/** @var ShortUrlEdit $shortUrlEdit */ /** @var ShortUrlEdit $shortUrlEdit */

View File

@@ -27,6 +27,6 @@ interface ShortUrlServiceInterface
public function updateShortUrl( public function updateShortUrl(
ShortUrlIdentifier $identifier, ShortUrlIdentifier $identifier,
ShortUrlEdit $shortUrlEdit, ShortUrlEdit $shortUrlEdit,
?ApiKey $apiKey = null ?ApiKey $apiKey = null,
): ShortUrl; ): ShortUrl;
} }

View File

@@ -16,21 +16,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
class UrlShortener implements UrlShortenerInterface class UrlShortener implements UrlShortenerInterface
{ {
private EntityManagerInterface $em;
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
private ShortUrlRelationResolverInterface $relationResolver;
private ShortCodeHelperInterface $shortCodeHelper;
public function __construct( public function __construct(
ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
EntityManagerInterface $em, private EntityManagerInterface $em,
ShortUrlRelationResolverInterface $relationResolver, private ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper private ShortCodeHelperInterface $shortCodeHelper
) { ) {
$this->titleResolutionHelper = $titleResolutionHelper;
$this->em = $em;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
} }
/** /**
@@ -78,7 +69,7 @@ class UrlShortener implements UrlShortenerInterface
if (! $couldBeMadeUnique) { if (! $couldBeMadeUnique) {
$domain = $shortUrlToBeCreated->getDomain(); $domain = $shortUrlToBeCreated->getDomain();
$domainAuthority = $domain !== null ? $domain->getAuthority() : null; $domainAuthority = $domain?->getAuthority();
throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority);
} }

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use GuzzleHttp\Psr7\Query;
use League\Uri\Uri;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use function array_merge;
use function sprintf;
class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
{
public function __construct(private TrackingOptions $trackingOptions)
{
}
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string
{
$uri = Uri::createFromString($shortUrl->getLongUrl());
return $uri
->withQuery($this->resolveQuery($uri, $currentQuery))
->withPath($this->resolvePath($uri, $extraPath))
->__toString();
}
private function resolveQuery(Uri $uri, array $currentQuery): ?string
{
$hardcodedQuery = Query::parse($uri->getQuery() ?? '');
$disableTrackParam = $this->trackingOptions->getDisableTrackParam();
if ($disableTrackParam !== null) {
unset($currentQuery[$disableTrackParam]);
}
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
return empty($mergedQuery) ? null : Query::build($mergedQuery);
}
private function resolvePath(Uri $uri, ?string $extraPath): string
{
$hardcodedPath = $uri->getPath();
return $extraPath === null ? $hardcodedPath : sprintf('%s%s', $hardcodedPath, $extraPath);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
interface ShortUrlRedirectionBuilderInterface
{
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string;
}

View File

@@ -11,13 +11,8 @@ use function sprintf;
class ShortUrlStringifier implements ShortUrlStringifierInterface class ShortUrlStringifier implements ShortUrlStringifierInterface
{ {
private array $domainConfig; public function __construct(private array $domainConfig, private string $basePath = '')
private string $basePath;
public function __construct(array $domainConfig, string $basePath = '')
{ {
$this->domainConfig = $domainConfig;
$this->basePath = $basePath;
} }
public function stringify(ShortUrl $shortUrl): string public function stringify(ShortUrl $shortUrl): string

View File

@@ -8,11 +8,8 @@ use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
{ {
private UrlValidatorInterface $urlValidator; public function __construct(private UrlValidatorInterface $urlValidator)
public function __construct(UrlValidatorInterface $urlValidator)
{ {
$this->urlValidator = $urlValidator;
} }
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
use function array_pad;
use function explode;
use function sprintf;
use function trim;
class ExtraPathRedirectMiddleware implements MiddlewareInterface
{
public function __construct(
private ShortUrlResolverInterface $resolver,
private RequestTrackerInterface $requestTracker,
private ShortUrlRedirectionBuilderInterface $redirectionBuilder,
private RedirectResponseHelperInterface $redirectResponseHelper,
private UrlShortenerOptions $urlShortenerOptions,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var NotFoundType|null $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
// We'll apply this logic only if actively opted in and current URL is potentially /{shortCode}/[...]
if (! $notFoundType?->isRegularNotFound() || ! $this->urlShortenerOptions->appendExtraPath()) {
return $handler->handle($request);
}
$uri = $request->getUri();
$query = $request->getQueryParams();
[$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri);
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority());
try {
$shortUrl = $this->resolver->resolveEnabledShortUrl($identifier);
$this->requestTracker->trackIfApplicable($shortUrl, $request);
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath);
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
} catch (ShortUrlNotFoundException) {
return $handler->handle($request);
}
}
/**
* @return array{0: string, 1: string|null}
*/
private function resolvePotentialShortCodeAndExtraPath(UriInterface $uri): array
{
$pathParts = explode('/', trim($uri->getPath(), '/'), 2);
[$potentialShortCode, $extraPath] = array_pad($pathParts, 2, null);
return [$potentialShortCode, $extraPath === null ? null : sprintf('/%s', $extraPath)];
}
}

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