diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml
index 639481b8..a8655a4d 100644
--- a/.github/workflows/ci-db-tests.yml
+++ b/.github/workflows/ci-db-tests.yml
@@ -13,11 +13,11 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
- php-version: ['8.3', '8.4', '8.5']
+ php-version: ['8.4', '8.5']
env:
LC_ALL: C
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Install MSSQL ODBC
if: ${{ inputs.platform == 'ms' }}
run: sudo ./data/infra/ci/install-ms-odbc.sh
@@ -35,8 +35,8 @@ jobs:
- name: Run tests
run: composer test:db:${{ inputs.platform }}
- name: Upload code coverage
- uses: actions/upload-artifact@v4
- if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
+ uses: actions/upload-artifact@v5
+ if: ${{ matrix.php-version == '8.4' && inputs.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 1ee23377..5f9f2bdc 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -13,11 +13,11 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
- php-version: ['8.3', '8.4', '8.5']
+ php-version: ['8.4', '8.5']
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Start postgres database server
if: ${{ inputs.test-group == 'api' }}
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
@@ -32,8 +32,8 @@ jobs:
if: ${{ inputs.test-group == 'api' }}
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:${{ inputs.test-group }}:ci
- - uses: actions/upload-artifact@v4
- if: ${{ matrix.php-version == '8.3' }}
+ - uses: actions/upload-artifact@v5
+ if: ${{ matrix.php-version == '8.4' }}
with:
name: coverage-${{ inputs.test-group }}
path: |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 51803a4f..acd25100 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,10 +27,10 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
- php-version: ['8.3']
+ php-version: ['8.4']
command: ['cs', 'stan', 'openapi:validate']
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
@@ -69,16 +69,15 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
- php-version: ['8.3']
+ php-version: ['8.4']
steps:
- - name: Checkout code
- uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use PHP
uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: tests-extensions-${{ matrix.php-version }}
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v6
with:
path: build
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
@@ -87,9 +86,9 @@ jobs:
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
- run: vendor/bin/phpcov merge build --clover build/clover.xml
- name: Publish coverage
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
- file: ./build/clover.xml
+ files: ./build/clover.xml
delete-artifacts:
needs:
diff --git a/.github/workflows/publish-openapi-spec.yml b/.github/workflows/publish-openapi-spec.yml
index 6195ce90..7a48e115 100644
--- a/.github/workflows/publish-openapi-spec.yml
+++ b/.github/workflows/publish-openapi-spec.yml
@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
- php-version: ['8.3']
+ php-version: ['8.4']
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Determine version
id: determine_version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 61fc6940..ee0ceb79 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -10,16 +10,16 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
- php-version: ['8.3', '8.4', '8.5']
+ php-version: ['8.4', '8.4', '8.5']
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no'
- run: ./build.sh ${GITHUB_REF#refs/tags/v}
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v5
with:
name: dist-files-${{ matrix.php-version }}
path: build
@@ -28,8 +28,8 @@ jobs:
needs: ['build']
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: actions/download-artifact@v4
+ - uses: actions/checkout@v5
+ - uses: actions/download-artifact@v6
with:
path: build
- name: Publish release with assets
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5a3a245..9120101f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,51 @@ 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).
+## [5.0.0] - 2026-01-09
+### Added
+* [#2431](https://github.com/shlinkio/shlink/issues/2431) Add new date-based conditions for the dynamic rules redirections system, that allow to perform redirections based on an ISO-8601 date value.
+
+ * `before-date`: matches when current date and time is earlier than the defined threshold.
+ * `after-date`: matches when current date and time is later than the defined threshold.
+
+* [#2513](https://github.com/shlinkio/shlink/issues/2513) Add support for redis connections via unix socket (e.g. `REDIS_SERVERS=unix:/path/to/redis.sock`).
+* Visits generated in the command line can now be formatted in CSV, via `--format=csv`.
+
+### Changed
+* [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue.
+
+ Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
+
+* [#2311](https://github.com/shlinkio/shlink/issues/2311) All visits-related commands now return more information, and columns are arranged slightly differently.
+
+ Among other things, they now always return the type of the visit, region, visited URL, redirected URL and whether the visit comes from a potential bot or not.
+
+* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
+* [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable.
+
+### Deprecated
+* *Nothing*
+
+### Removed
+* [#2507](https://github.com/shlinkio/shlink/issues/2507) Drop support for PHP 8.3.
+* [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard.
+* [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead.
+* [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name.
+* [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead.
+* [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag.
+* [#2543](https://github.com/shlinkio/shlink/issues/2543) Remove support for `--order-by=field,dir` option `short-url:list` command. Use `--order-by=field-dir` instead.
+* Remove support to provide redis database index via URI path. Use `?database=3` query instead.
+* [#2565](https://github.com/shlinkio/shlink/issues/2565) Remove explicit dependency in ext-json, since it's part of PHP since v8.0
+
+### Fixed
+* [#2564](https://github.com/shlinkio/shlink/issues/2564) Fix error when trying to persist non-utf-8 title without being able to determine its original charset for parsing.
+
+ Now, when resolving a website's charset, two improvements have been introduced:
+
+ 1. If the `Content-Type` header does not define the charset, we fall back to `` or ``.
+ 2. If it's still not possible to determine the charset, we ignore the auto-resolved title, to avoid other encoding errors further down the line.
+
+
## [4.6.0] - 2025-11-01
### Added
* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URL lists by those not including certain tags.
@@ -29,6 +74,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
This is done via the `domain` query parameter in API endpoints, and via the `--domain` option in console commands.
* [#2472](https://github.com/shlinkio/shlink/issues/2472) Add support for PHP 8.5
+* [#2291](https://github.com/shlinkio/shlink/issues/2291) Add `api-key:delete` console command to delete API keys.
### Changed
* [#2424](https://github.com/shlinkio/shlink/issues/2424) Make simple console commands invokable.
diff --git a/Dockerfile b/Dockerfile
index 1b69a441..b75ec607 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,13 +15,12 @@ WORKDIR /etc/shlink
# Install required PHP extensions
RUN \
- # Temp install dev dependencies needed to compile the extensions
- # FIXME Deprecated image-related extensions. They can be removed with QR-code support
- apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev linux-headers && \
- docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \
+ # Temp install dev dependencies needed to compile the extensions \
+ apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev linux-headers && \
+ docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip && \
apk add --no-cache sqlite-libs && \
docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
- # Remove temp dev extensions, and install prod equivalents that are required at runtime
+ # Remove temp dev extensions, and install prod equivalents that are required at runtime \
apk del .dev-deps && \
apk add --no-cache postgresql icu libzip libpng
diff --git a/README.md b/README.md
index dc23d7f6..b922144e 100644
--- a/README.md
+++ b/README.md
@@ -36,10 +36,9 @@ 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:
-* PHP 8.3 or 8.4
+* PHP 8.4 or 8.5
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
* apcu extension is recommended if you don't plan to use RoadRunner.
- * xml extension is required if you want to generate QR codes in svg format.
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite.
* You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`.
diff --git a/UPGRADE.md b/UPGRADE.md
index bbb7c3a4..556fcd18 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -1,5 +1,26 @@
# Upgrading
+## From v4.x to v5.x
+
+### General
+
+* Generating QR codes by appending `/qr-code` to a short URL is no longer possible. Use external services to generate QR codes from a short URL, or the logic embedded in Shlink Web Client and Shlink Dashboard.
+* Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address.
+ Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
+* PHP 8.3 is no longer supported. Only 8.4 and 8.5 are officially supported as of Shlink 5.0.0.
+
+### Changes in CLI
+
+* Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name.
+* All visits-related commands (`short-url:visits`, `tag:visits`, `domain:visits`, `visit:orphan` and `visit:non-orphan`) now return more information, and columns are arranged slightly differently.
+* The `short-url:list` command no longer accepts `--including-all-tags` and `--show-api-key-name` options. Use `--tags-all` and `--show-api-key` instead.
+* The `short-url:list` command no longer allows ordering using the `--order-by=field,dir` format. Use `--order-by=field-dir` instead.
+* All commands which used to accept the `--tags` flag, no longer accept it. Pass `--tag` multiple times instead, one per tag.
+
+### Changes in env vars
+
+* The `REDIRECT_APPEND_EXTRA_PATH` env var is no longer supported. Use `REDIRECT_EXTRA_PATH_MODE=append` to enable the same behavior.
+
## From v3.x to v4.x
### General
diff --git a/composer.json b/composer.json
index dc851009..e21a021f 100644
--- a/composer.json
+++ b/composer.json
@@ -12,19 +12,16 @@
}
],
"require": {
- "php": "^8.3",
+ "php": "^8.4",
"ext-curl": "*",
- "ext-gd": "*",
- "ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.6",
"cakephp/chronos": "^3.1",
- "doctrine/dbal": "^4.3",
+ "doctrine/dbal": "^4.4",
"doctrine/migrations": "^3.9",
- "doctrine/orm": "^3.5",
- "donatj/phpuseragentparser": "^1.10",
- "endroid/qr-code": "^6.0.5",
+ "doctrine/orm": "^3.6",
+ "donatj/phpuseragentparser": "^1.11",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.1",
"guzzlehttp/guzzle": "^7.9",
@@ -35,6 +32,7 @@
"laminas/laminas-inputfilter": "^2.31",
"laminas/laminas-servicemanager": "^3.23",
"laminas/laminas-stdlib": "^3.20",
+ "league/csv": "^9.28",
"matomo/matomo-php-tracker": "^3.3",
"mezzio/mezzio": "^3.20",
"mezzio/mezzio-fastroute": "^3.12",
@@ -43,22 +41,22 @@
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
- "shlinkio/shlink-common": "^7.2",
- "shlinkio/shlink-config": "^4.0",
- "shlinkio/shlink-event-dispatcher": "^4.3",
- "shlinkio/shlink-importer": "^5.6",
- "shlinkio/shlink-installer": "^9.7",
- "shlinkio/shlink-ip-geolocation": "^4.4",
- "shlinkio/shlink-json": "^1.2",
+ "shlinkio/shlink-common": "dev-main#d4ae052 as 8.0.0",
+ "shlinkio/shlink-config": "dev-main#fb186e4 as 4.1.0",
+ "shlinkio/shlink-event-dispatcher": "dev-main#54d4701 as 4.4.0",
+ "shlinkio/shlink-importer": "dev-main#63753cf as 5.7.0",
+ "shlinkio/shlink-installer": "dev-develop#a225b16 as 10.0.0",
+ "shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0",
+ "shlinkio/shlink-json": "^1.3",
"spiral/roadrunner": "^2025.1",
"spiral/roadrunner-cli": "^2.7",
- "spiral/roadrunner-http": "^3.5",
- "spiral/roadrunner-jobs": "^4.6",
- "symfony/console": "^7.3",
- "symfony/filesystem": "^7.3",
- "symfony/lock": "^7.3.2",
- "symfony/process": "^7.3",
- "symfony/string": "^7.3"
+ "spiral/roadrunner-http": "^3.6",
+ "spiral/roadrunner-jobs": "^4.7",
+ "symfony/console": "^8.0",
+ "symfony/filesystem": "^8.0",
+ "symfony/lock": "^8.0",
+ "symfony/process": "^8.0",
+ "symfony/string": "^8.0"
},
"require-dev": {
"devizzent/cebe-php-openapi": "^1.1.2",
@@ -70,10 +68,9 @@
"phpunit/php-code-coverage": "^12.0",
"phpunit/phpcov": "^11.0",
"phpunit/phpunit": "^12.0.10",
- "roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.5.0",
- "shlinkio/shlink-test-utils": "^4.3.1",
- "symfony/var-dumper": "^7.3",
+ "shlinkio/shlink-test-utils": "^4.4",
+ "symfony/var-dumper": "^8.0",
"veewee/composer-run-parallel": "^1.4"
},
"conflict": {
@@ -147,12 +144,12 @@
"test:cli:ci": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
- "vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
+ "@php -d memory_limit=-1 vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
],
"test:cli:pretty": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
- "phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
+ "@php -d memory_limit=-1 phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
],
"openapi:validate": "php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php
index a3918aff..c3c63828 100644
--- a/config/autoload/installer.global.php
+++ b/config/autoload/installer.global.php
@@ -60,15 +60,6 @@ return [
Option\Tracking\DisableIpTrackingConfigOption::class,
Option\Tracking\DisableReferrerTrackingConfigOption::class,
Option\Tracking\DisableUaTrackingConfigOption::class,
- Option\QrCode\DefaultSizeConfigOption::class,
- Option\QrCode\DefaultMarginConfigOption::class,
- Option\QrCode\DefaultFormatConfigOption::class,
- Option\QrCode\DefaultErrorCorrectionConfigOption::class,
- Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
- Option\QrCode\DefaultColorConfigOption::class,
- Option\QrCode\DefaultBgColorConfigOption::class,
- Option\QrCode\DefaultLogoUrlConfigOption::class,
- Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class,
Option\RabbitMq\RabbitMqEnabledConfigOption::class,
Option\RabbitMq\RabbitMqHostConfigOption::class,
Option\RabbitMq\RabbitMqUseSslConfigOption::class,
diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php
index 11902091..83766171 100644
--- a/config/autoload/ip-address.global.php
+++ b/config/autoload/ip-address.global.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
use RKA\Middleware\IpAddress;
use RKA\Middleware\Mezzio\IpAddressFactory;
use Shlinkio\Shlink\Core\Config\EnvVars;
-use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator;
use function Shlinkio\Shlink\Core\splitByComma;
@@ -43,18 +42,6 @@ return (static function (): array {
'factories' => [
IpAddress::class => IpAddressFactory::class,
],
- 'delegators' => [
- // Make middleware decoration transparent to other parts of the code
- IpAddress::class => [
- fn ($c, $n, callable $callback) =>
- // If trusted proxies have been provided, use original middleware verbatim, otherwise decorate
- // with workaround
- $trustedProxies !== null
- ? $callback()
- : new ReverseForwardedAddressesMiddlewareDecorator($callback()),
- ],
- ],
-
],
];
diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php
index 1f5425b5..45527697 100644
--- a/config/autoload/routes.config.php
+++ b/config/autoload/routes.config.php
@@ -94,14 +94,6 @@ return (static function (): array {
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
- [
- 'name' => CoreAction\QrCodeAction::class,
- 'path' => '/{shortCode}/qr-code',
- 'middleware' => [
- CoreAction\QrCodeAction::class,
- ],
- 'allowed_methods' => [RequestMethodInterface::METHOD_GET],
- ],
[
'name' => CoreAction\RedirectAction::class,
'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix),
diff --git a/config/config.php b/config/config.php
index 92c69ba0..ed7c5b3c 100644
--- a/config/config.php
+++ b/config/config.php
@@ -10,7 +10,7 @@ use Mezzio;
use Mezzio\ProblemDetails;
use Shlinkio\Shlink\Core\Config\EnvVars;
-return (new ConfigAggregator\ConfigAggregator(
+return new ConfigAggregator\ConfigAggregator(
providers: [
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
@@ -39,4 +39,4 @@ return (new ConfigAggregator\ConfigAggregator(
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
],
-))->getMergedConfig();
+)->getMergedConfig();
diff --git a/config/constants.php b/config/constants.php
index 664c8c9f..6ed765e3 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -38,20 +38,3 @@ const ISO_COUNTRY_CODES = [
'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU',
'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW',
];
-
-/** @deprecated */
-const DEFAULT_QR_CODE_SIZE = 300;
-/** @deprecated */
-const DEFAULT_QR_CODE_MARGIN = 0;
-/** @deprecated */
-const DEFAULT_QR_CODE_FORMAT = 'png';
-/** @deprecated */
-const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
-/** @deprecated */
-const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
-/** @deprecated */
-const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
-/** @deprecated */
-const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
-/** @deprecated */
-const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White
diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf
index b7a5d4fa..353db74e 100644
--- a/data/infra/examples/nginx-vhost.conf
+++ b/data/infra/examples/nginx-vhost.conf
@@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
- fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
+ fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
diff --git a/data/infra/frankenphp.Dockerfile b/data/infra/frankenphp.Dockerfile
index 22e3e580..ac27d1a2 100644
--- a/data/infra/frankenphp.Dockerfile
+++ b/data/infra/frankenphp.Dockerfile
@@ -21,27 +21,25 @@ RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
-RUN apk add --no-cache libzip-dev zlib-dev
-RUN docker-php-ext-install zip
-
-RUN apk add --no-cache libpng-dev
-RUN docker-php-ext-install gd
-
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
-RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
+COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie
+RUN apk add --no-cache libzip-dev zlib-dev && \
+ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
+ pie install xdebug/xdebug && \
+ pie install pecl/zip && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
-# Install xdebug and sqlsrv driver
+# Install sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
- pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
- docker-php-ext-enable pdo_sqlsrv xdebug && \
+ pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
+ docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile
index bc06e876..652e1ace 100644
--- a/data/infra/php.Dockerfile
+++ b/data/infra/php.Dockerfile
@@ -22,37 +22,26 @@ RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
-RUN apk add --no-cache libzip-dev zlib-dev
-RUN docker-php-ext-install zip
-
-RUN apk add --no-cache libpng-dev
-RUN docker-php-ext-install gd
-
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
-RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
+COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie
+RUN apk add --no-cache libzip-dev zlib-dev && \
+ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
+ pie install xdebug/xdebug && \
+ pie install pecl/zip && \
+ pie install apcu/apcu && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
-# Install APCu extension
-ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
-RUN mkdir -p /usr/src/php/ext/apcu \
- && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
- && docker-php-ext-configure apcu \
- && docker-php-ext-install apcu \
- && rm /tmp/apcu.tar.gz \
- && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
- && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
-
-# Install xdebug and sqlsrv driver
+# Install sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
- pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
- docker-php-ext-enable pdo_sqlsrv xdebug && \
+ pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
+ docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile
index 6f5981bf..8ef7e969 100644
--- a/data/infra/roadrunner.Dockerfile
+++ b/data/infra/roadrunner.Dockerfile
@@ -21,27 +21,25 @@ RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
-RUN apk add --no-cache libzip-dev zlib-dev
-RUN docker-php-ext-install zip
-
-RUN apk add --no-cache libpng-dev
-RUN docker-php-ext-install gd
-
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
-RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
+COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie
+RUN apk add --no-cache libzip-dev zlib-dev && \
+ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
+ pie install xdebug/xdebug && \
+ pie install pecl/zip && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
-# Install xdebug and sqlsrv driver
+# Install sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
- pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
- docker-php-ext-enable pdo_sqlsrv xdebug && \
+ pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
+ docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json
index 15380faa..0cbe7b37 100644
--- a/docs/swagger/definitions/SetShortUrlRedirectRule.json
+++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json
@@ -23,7 +23,9 @@
"valueless-query-param",
"ip-address",
"geolocation-country-code",
- "geolocation-city-name"
+ "geolocation-city-name",
+ "before-date",
+ "after-date"
],
"description": "The type of the condition, which will determine the logic used to match it"
},
diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json
deleted file mode 100644
index dc0f2d81..00000000
--- a/docs/swagger/paths/{shortCode}_qr-code.json
+++ /dev/null
@@ -1,121 +0,0 @@
-{
- "get": {
- "deprecated": true,
- "operationId": "shortUrlQrCode",
- "tags": [
- "URL Shortener"
- ],
- "summary": "[Deprecated] Short URL QR code",
- "description": "**[Deprecated]** Use an external mechanism to generate QR codes. Shlink dashboard and shlink-web-client provide their own.",
- "parameters": [
- {
- "$ref": "../parameters/shortCode.json"
- },
- {
- "name": "size",
- "in": "query",
- "description": "The size of the image to be returned.",
- "required": false,
- "schema": {
- "type": "integer",
- "minimum": 50,
- "maximum": 1000,
- "default": 300
- }
- },
- {
- "name": "format",
- "in": "query",
- "description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
- "required": false,
- "schema": {
- "type": "string",
- "enum": ["png", "svg"],
- "default": "png"
- }
- },
- {
- "name": "margin",
- "in": "query",
- "description": "The margin around the QR code image.",
- "required": false,
- "schema": {
- "type": "integer",
- "minimum": 0,
- "default": 0
- }
- },
- {
- "name": "errorCorrection",
- "in": "query",
- "description": "The error correction level to apply to 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"
- }
- },
- {
- "name": "roundBlockSize",
- "in": "query",
- "description": "Allows to disable block size rounding, which might reduce the readability of the QR code, but ensures no extra margin is added.",
- "required": false,
- "schema": {
- "type": "string",
- "enum": ["true", "false"],
- "default": "false"
- }
- },
- {
- "name": "color",
- "in": "query",
- "description": "The QR code foreground color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
- "required": false,
- "schema": {
- "type": "string",
- "default": "#000000"
- }
- },
- {
- "name": "bgColor",
- "in": "query",
- "description": "The QR code background color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
- "required": false,
- "schema": {
- "type": "string",
- "default": "#ffffff"
- }
- },
- {
- "name": "logo",
- "in": "query",
- "description": "Currently used to disable the logo that was set via configuration options. It may be used in future to dynamically choose from multiple logos.",
- "required": false,
- "schema": {
- "type": "string",
- "enum": ["disable"]
- }
- }
- ],
- "responses": {
- "200": {
- "description": "QR code in PNG format",
- "content": {
- "image/png": {
- "schema": {
- "type": "string",
- "format": "binary"
- }
- },
- "image/svg+xml": {
- "schema": {
- "type": "string",
- "format": "binary"
- }
- }
- }
- }
- }
- }
-}
diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json
index 1b34b470..bced7510 100644
--- a/docs/swagger/swagger.json
+++ b/docs/swagger/swagger.json
@@ -133,9 +133,6 @@
},
"/{shortCode}/track": {
"$ref": "paths/{shortCode}_track.json"
- },
- "/{shortCode}/qr-code": {
- "$ref": "paths/{shortCode}_qr-code.json"
}
}
}
diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php
index b4bf15d2..365b094e 100644
--- a/module/CLI/config/dependencies.config.php
+++ b/module/CLI/config/dependencies.config.php
@@ -32,6 +32,7 @@ return [
RedirectRule\RedirectRuleHandler::class => InvokableFactory::class,
Util\ProcessRunner::class => ConfigAbstractFactory::class,
+ Util\PhpProcessRunner::class => ConfigAbstractFactory::class,
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
@@ -79,6 +80,7 @@ return [
ConfigAbstractFactory::class => [
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
+ Util\PhpProcessRunner::class => [Util\ProcessRunner::class, PhpExecutableFinder::class],
ApiKey\RoleResolver::class => [DomainService::class, UrlShortenerOptions::class],
Command\ShortUrl\CreateShortUrlCommand::class => [
@@ -105,7 +107,7 @@ return [
],
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::class],
- Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
+ Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
@@ -117,11 +119,11 @@ return [
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
- Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
+ Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
- Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
+ Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\RedirectRule\ManageRedirectRulesCommand::class => [
ShortUrl\ShortUrlResolver::class,
@@ -136,16 +138,11 @@ return [
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,
- Util\ProcessRunner::class,
- PhpExecutableFinder::class,
+ Util\PhpProcessRunner::class,
'em',
NoDbNameConnectionFactory::SERVICE_NAME,
],
- Command\Db\MigrateDatabaseCommand::class => [
- LockFactory::class,
- Util\ProcessRunner::class,
- PhpExecutableFinder::class,
- ],
+ Command\Db\MigrateDatabaseCommand::class => [LockFactory::class, Util\PhpProcessRunner::class],
],
];
diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php
index ece56c77..00c66e7c 100644
--- a/module/CLI/src/ApiKey/RoleResolver.php
+++ b/module/CLI/src/ApiKey/RoleResolver.php
@@ -4,15 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
+use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
-use Shlinkio\Shlink\Rest\ApiKey\Role;
-use Symfony\Component\Console\Input\InputInterface;
-
-use function is_string;
+/** @deprecated API key roles are deprecated */
readonly class RoleResolver implements RoleResolverInterface
{
public function __construct(
@@ -21,16 +19,16 @@ readonly class RoleResolver implements RoleResolverInterface
) {
}
- public function determineRoles(InputInterface $input): iterable
+ public function determineRoles(ApiKeyInput $input): iterable
{
- $domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName());
- $author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName());
- $noOrphanVisits = $input->getOption(Role::NO_ORPHAN_VISITS->paramName());
+ $domainAuthority = $input->domain;
+ $author = $input->authorOnly;
+ $noOrphanVisits = $input->noOrphanVisits;
if ($author) {
yield RoleDefinition::forAuthoredShortUrls();
}
- if (is_string($domainAuthority)) {
+ if ($domainAuthority !== null) {
yield $this->resolveRoleForAuthority($domainAuthority);
}
if ($noOrphanVisits) {
diff --git a/module/CLI/src/ApiKey/RoleResolverInterface.php b/module/CLI/src/ApiKey/RoleResolverInterface.php
index e849ad13..6f90843b 100644
--- a/module/CLI/src/ApiKey/RoleResolverInterface.php
+++ b/module/CLI/src/ApiKey/RoleResolverInterface.php
@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
+use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
-use Symfony\Component\Console\Input\InputInterface;
+/** @deprecated API key roles are deprecated */
interface RoleResolverInterface
{
/**
* @return iterable
*/
- public function determineRoles(InputInterface $input): iterable;
+ public function determineRoles(ApiKeyInput $input): iterable;
}
diff --git a/module/CLI/src/Command/Api/DeleteKeyCommand.php b/module/CLI/src/Command/Api/DeleteKeyCommand.php
index f57a5e4a..8b9db376 100644
--- a/module/CLI/src/Command/Api/DeleteKeyCommand.php
+++ b/module/CLI/src/Command/Api/DeleteKeyCommand.php
@@ -48,7 +48,7 @@ class DeleteKeyCommand extends Command
if ($apiKeyName === null) {
$apiKeys = $this->apiKeyService->listKeys();
- $name = (new SymfonyStyle($input, $output))->choice(
+ $name = new SymfonyStyle($input, $output)->choice(
'What API key do you want to delete?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php
index 308c432f..46380db8 100644
--- a/module/CLI/src/Command/Api/DisableKeyCommand.php
+++ b/module/CLI/src/Command/Api/DisableKeyCommand.php
@@ -9,7 +9,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
-use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -20,24 +19,17 @@ use function sprintf;
#[AsCommand(
name: DisableKeyCommand::NAME,
- description: 'Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)',
+ description: 'Disables an API key by name',
help: <<%command.name% command allows you to disable an existing API key, via its name or the
- plain-text key.
+ The %command.name% command allows you to disable an existing API key.
If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys.
%command.full_name%
- You can optionally pass the API key name to be disabled. In that case --by-name is also
- required, to indicate the first argument is the API key name and not the plain-text key:
+ You can optionally pass the API key name to be disabled:
- %command.full_name% the_key_name --by-name
-
- You can pass the plain-text key to be disabled, but that is DEPRECATED>. In next major version,
- the argument will always be assumed to be the name:
-
- %command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143
+ %command.full_name% the_key_name
HELP,
)]
@@ -52,41 +44,31 @@ class DisableKeyCommand extends Command
protected function interact(InputInterface $input, OutputInterface $output): void
{
- $keyOrName = $input->getArgument('key-or-name');
+ $name = $input->getArgument('name');
- if ($keyOrName === null) {
+ if ($name === null) {
$apiKeys = $this->apiKeyService->listKeys(enabledOnly: true);
- $name = (new SymfonyStyle($input, $output))->choice(
+ $name = new SymfonyStyle($input, $output)->choice(
'What API key do you want to disable?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
- $input->setArgument('key-or-name', $name);
- $input->setOption('by-name', true);
+ $input->setArgument('name', $name);
}
}
public function __invoke(
SymfonyStyle $io,
- #[Argument(
- description: 'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.',
- )]
- string|null $keyOrName = null,
- #[Option(description: 'Indicates the first argument is the API key name, not the plain-text key.')]
- bool $byName = false,
+ #[Argument('The name of the API key to disable.')] string|null $name = null,
): int {
- if ($keyOrName === null) {
+ if ($name === null) {
$io->warning('An API key name was not provided.');
return Command::INVALID;
}
try {
- if ($byName) {
- $this->apiKeyService->disableByName($keyOrName);
- } else {
- $this->apiKeyService->disableByKey($keyOrName);
- }
- $io->success(sprintf('API key "%s" properly disabled', $keyOrName));
+ $this->apiKeyService->disableByName($name);
+ $io->success(sprintf('API key "%s" properly disabled', $name));
return Command::SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());
diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php
index 0c4afcd0..b9535b94 100644
--- a/module/CLI/src/Command/Api/GenerateKeyCommand.php
+++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php
@@ -4,40 +4,27 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
-use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
+use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\arrayToString;
+use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
-class GenerateKeyCommand extends Command
-{
- public const string NAME = 'api-key:generate';
-
- public function __construct(
- private readonly ApiKeyServiceInterface $apiKeyService,
- private readonly RoleResolverInterface $roleResolver,
- ) {
- parent::__construct();
- }
-
- protected function configure(): void
- {
- $authorOnly = Role::AUTHORED_SHORT_URLS->paramName();
- $domainOnly = Role::DOMAIN_SPECIFIC->paramName();
- $noOrphanVisits = Role::NO_ORPHAN_VISITS->paramName();
-
- $help = <<%command.name% generates a new valid API key.
%command.full_name%
@@ -49,62 +36,26 @@ class GenerateKeyCommand extends Command
You can optionally set its expiration date with --expiration-date or -e:
%command.full_name% --expiration-date 2020-01-01
+ HELP,
+)]
+class GenerateKeyCommand extends Command
+{
+ public const string NAME = 'api-key:generate';
- You can also set roles to the API key:
-
- * Can interact with short URLs created with this API key: %command.full_name% --{$authorOnly}
- * Can interact with short URLs for one domain: %command.full_name% --{$domainOnly}=example.com
- * Cannot see orphan visits: %command.full_name% --{$noOrphanVisits}
- * All: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits}
- HELP;
-
- $this
- ->setName(self::NAME)
- ->setDescription('Generate a new valid API key.')
- ->addOption(
- 'name',
- 'm',
- InputOption::VALUE_REQUIRED,
- 'The name by which this API key will be known.',
- )
- ->addOption(
- 'expiration-date',
- 'e',
- InputOption::VALUE_REQUIRED,
- 'The date in which the API key should expire. Use any valid PHP format.',
- )
- ->addOption(
- $authorOnly,
- 'a',
- InputOption::VALUE_NONE,
- sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS->value),
- )
- ->addOption(
- $domainOnly,
- 'd',
- InputOption::VALUE_REQUIRED,
- sprintf(
- 'Adds the "%s" role to the new API key, with the domain provided.',
- Role::DOMAIN_SPECIFIC->value,
- ),
- )
- ->addOption(
- $noOrphanVisits,
- 'o',
- InputOption::VALUE_NONE,
- sprintf('Adds the "%s" role to the new API key.', Role::NO_ORPHAN_VISITS->value),
- )
- ->setHelp($help);
+ public function __construct(
+ private readonly ApiKeyServiceInterface $apiKeyService,
+ private readonly RoleResolverInterface $roleResolver,
+ ) {
+ parent::__construct();
}
- protected function execute(InputInterface $input, OutputInterface $output): int
+ public function __invoke(SymfonyStyle $io, InputInterface $input, #[MapInput] ApiKeyInput $inputData): int
{
- $io = new SymfonyStyle($input, $output);
- $expirationDate = $input->getOption('expiration-date');
+ $expirationDate = $inputData->expirationDate;
$apiKeyMeta = ApiKeyMeta::fromParams(
- name: $input->getOption('name'),
- expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null,
- roleDefinitions: $this->roleResolver->determineRoles($input),
+ name: $inputData->name,
+ expirationDate: isset($expirationDate) ? normalizeOptionalDate($expirationDate) : null,
+ roleDefinitions: $this->roleResolver->determineRoles($inputData),
);
$apiKey = $this->apiKeyService->create($apiKeyMeta);
diff --git a/module/CLI/src/Command/Api/Input/ApiKeyInput.php b/module/CLI/src/Command/Api/Input/ApiKeyInput.php
new file mode 100644
index 00000000..ae19efc7
--- /dev/null
+++ b/module/CLI/src/Command/Api/Input/ApiKeyInput.php
@@ -0,0 +1,33 @@
+value . '" role to the new API key', shortcut: 'a')]
+ public bool $authorOnly = false;
+
+ /** @deprecated */
+ #[Option(
+ 'Adds the "' . Role::DOMAIN_SPECIFIC->value . '" role to the new API key, with provided domain',
+ name: 'domain-only',
+ shortcut: 'd',
+ )]
+ public string|null $domain = null;
+
+ /** @deprecated */
+ #[Option('Adds the "' . Role::NO_ORPHAN_VISITS->value . '" role to the new API key', shortcut: 'o')]
+ public bool $noOrphanVisits = false;
+}
diff --git a/module/CLI/src/Command/Api/RenameApiKeyCommand.php b/module/CLI/src/Command/Api/RenameApiKeyCommand.php
index fcbca1ce..fc0ec9bb 100644
--- a/module/CLI/src/Command/Api/RenameApiKeyCommand.php
+++ b/module/CLI/src/Command/Api/RenameApiKeyCommand.php
@@ -4,19 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
-use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Model\Renaming;
-use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
-use function Shlinkio\Shlink\Core\ArrayUtils\map;
-
#[AsCommand(
name: RenameApiKeyCommand::NAME,
description: 'Renames an API key by name',
@@ -30,38 +25,12 @@ class RenameApiKeyCommand extends Command
parent::__construct();
}
- protected function interact(InputInterface $input, OutputInterface $output): void
- {
- $io = new SymfonyStyle($input, $output);
- $oldName = $input->getArgument('old-name');
- $newName = $input->getArgument('new-name');
-
- if ($oldName === null) {
- $apiKeys = $this->apiKeyService->listKeys();
- $requestedOldName = $io->choice(
- 'What API key do you want to rename?',
- map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
- );
-
- $input->setArgument('old-name', $requestedOldName);
- }
-
- if ($newName === null) {
- $requestedNewName = $io->ask(
- 'What is the new name you want to set?',
- validator: static fn (string|null $value): string => $value !== null
- ? $value
- : throw new InvalidArgumentException('The new name cannot be empty'),
- );
-
- $input->setArgument('new-name', $requestedNewName);
- }
- }
-
public function __invoke(
SymfonyStyle $io,
- #[Argument(description: 'Current name of the API key to rename')] string $oldName,
- #[Argument(description: 'New name to set to the API key')] string $newName,
+ #[Argument(description: 'Current name of the API key to rename'), Ask('What API key do you want to rename?')]
+ string $oldName,
+ #[Argument(description: 'New name to set to the API key'), Ask('What is the new name you want to set?')]
+ string $newName,
): int {
$this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName));
$io->success('API key properly renamed');
diff --git a/module/CLI/src/Command/Config/ReadEnvVarCommand.php b/module/CLI/src/Command/Config/ReadEnvVarCommand.php
index e3a38be6..527c0d12 100644
--- a/module/CLI/src/Command/Config/ReadEnvVarCommand.php
+++ b/module/CLI/src/Command/Config/ReadEnvVarCommand.php
@@ -8,10 +8,10 @@ use Closure;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Interact;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Config\formatEnvVarValue;
@@ -37,9 +37,10 @@ class ReadEnvVarCommand extends Command
parent::__construct();
}
- protected function interact(InputInterface $input, OutputInterface $output): void
+ #[Interact]
+ public function askMissing(InputInterface $input, SymfonyStyle $io): void
{
- $io = new SymfonyStyle($input, $output);
+ /** @var string|null $envVar */
$envVar = $input->getArgument('env-var');
$validEnvVars = enumValues(EnvVars::class);
diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php
deleted file mode 100644
index a85cb999..00000000
--- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php
+++ /dev/null
@@ -1,37 +0,0 @@
-phpBinary = $phpFinder->find(false) ?: 'php';
- }
-
- protected function runPhpCommand(OutputInterface $output, array $command): void
- {
- $command = [$this->phpBinary, ...$command, '--no-interaction'];
- $this->processRunner->run($output, $command);
- }
-
- protected function getLockConfig(): LockedCommandConfig
- {
- return LockedCommandConfig::blocking($this->getName() ?? static::class);
- }
-}
diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php
index 6dc11dfc..9e6842eb 100644
--- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php
+++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php
@@ -7,51 +7,54 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
+use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
+use Shlinkio\Shlink\CLI\Command\Util\LockConfig;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
-use Symfony\Component\Process\PhpExecutableFinder;
use Throwable;
use function array_map;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
-class CreateDatabaseCommand extends AbstractDatabaseCommand
+#[AsCommand(
+ name: CreateDatabaseCommand::NAME,
+ description: 'Creates the database needed for shlink to work. It will do nothing if the database already exists',
+ hidden: true,
+)]
+class CreateDatabaseCommand extends Command
{
private readonly Connection $regularConn;
public const string NAME = 'db:create';
- public const string DOCTRINE_SCRIPT = 'bin/doctrine';
- public const string DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
+ public const string SCRIPT = 'bin/doctrine';
+ public const string COMMAND = 'orm:schema-tool:create';
public function __construct(
- LockFactory $locker,
- ProcessRunnerInterface $processRunner,
- PhpExecutableFinder $phpFinder,
+ private readonly LockFactory $locker,
+ private readonly ProcessRunnerInterface $processRunner,
private readonly EntityManagerInterface $em,
private readonly Connection $noDbNameConn,
) {
$this->regularConn = $this->em->getConnection();
- parent::__construct($locker, $processRunner, $phpFinder);
+ parent::__construct();
}
- protected function configure(): void
+ public function __invoke(SymfonyStyle $io): int
{
- $this
- ->setName(self::NAME)
- ->setHidden()
- ->setDescription(
- 'Creates the database needed for shlink to work. It will do nothing if the database already exists',
- );
+ return CommandUtils::executeWithLock(
+ $this->locker,
+ LockConfig::blocking(self::NAME),
+ $io,
+ fn () => $this->executeCommand($io),
+ );
}
- protected function lockedExecute(InputInterface $input, OutputInterface $output): int
+ private function executeCommand(SymfonyStyle $io): int
{
- $io = new SymfonyStyle($input, $output);
-
if ($this->databaseTablesExist()) {
$io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.');
return self::SUCCESS;
@@ -59,7 +62,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
// Create database
$io->writeln('Creating database tables...>');
- $this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]);
+ $this->processRunner->run($io, [self::SCRIPT, self::COMMAND, '--no-interaction']);
$io->success('Database properly created!');
return self::SUCCESS;
diff --git a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php
index 669b190b..ae520446 100644
--- a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php
+++ b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php
@@ -4,30 +4,46 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
+use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
+use Shlinkio\Shlink\CLI\Command\Util\LockConfig;
+use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Lock\LockFactory;
-class MigrateDatabaseCommand extends AbstractDatabaseCommand
+#[AsCommand(
+ name: MigrateDatabaseCommand::NAME,
+ description: 'Runs database migrations, which will ensure the shlink database is up to date',
+ hidden: true,
+)]
+class MigrateDatabaseCommand extends Command
{
public const string NAME = 'db:migrate';
- public const string DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
- public const string DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate';
+ public const string SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
+ public const string COMMAND = 'migrations:migrate';
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setHidden()
- ->setDescription('Runs database migrations, which will ensure the shlink database is up to date.');
+ public function __construct(
+ private readonly LockFactory $locker,
+ private readonly ProcessRunnerInterface $processRunner,
+ ) {
+ parent::__construct();
}
- protected function lockedExecute(InputInterface $input, OutputInterface $output): int
+ public function __invoke(SymfonyStyle $io): int
{
- $io = new SymfonyStyle($input, $output);
+ return CommandUtils::executeWithLock(
+ $this->locker,
+ LockConfig::blocking(self::NAME),
+ $io,
+ fn () => $this->executeCommand($io),
+ );
+ }
+ private function executeCommand(SymfonyStyle $io): int
+ {
$io->writeln('Migrating database...>');
- $this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]);
+ $this->processRunner->run($io, [self::SCRIPT, self::COMMAND, '--no-interaction']);
$io->success('Database properly migrated!');
return self::SUCCESS;
diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php
index 1e272c12..59deb35e 100644
--- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php
+++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php
@@ -9,9 +9,9 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Interact;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_filter;
@@ -32,7 +32,8 @@ class DomainRedirectsCommand extends Command
parent::__construct();
}
- protected function interact(InputInterface $input, OutputInterface $output): void
+ #[Interact]
+ public function askDomain(InputInterface $input, SymfonyStyle $io): void
{
/** @var string|null $domain */
$domain = $input->getArgument('domain');
@@ -40,7 +41,6 @@ class DomainRedirectsCommand extends Command
return;
}
- $io = new SymfonyStyle($input, $output);
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
/** @var string[] $availableDomains */
@@ -88,15 +88,15 @@ class DomainRedirectsCommand extends Command
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
$ask(
'URL to redirect to when a user hits this domain\'s base URL',
- $domain?->baseUrlRedirect(),
+ $domain?->baseUrlRedirect,
),
$ask(
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
- $domain?->regular404Redirect(),
+ $domain?->regular404Redirect,
),
$ask(
'URL to redirect to when a user hits an invalid short URL',
- $domain?->invalidShortUrlRedirect(),
+ $domain?->invalidShortUrlRedirect,
),
));
diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php
index 2891c44f..9f4ada0f 100644
--- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php
+++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php
@@ -4,50 +4,36 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
-use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
-use Shlinkio\Shlink\Common\Paginator\Paginator;
-use Shlinkio\Shlink\Common\Util\DateRange;
-use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
-use Shlinkio\Shlink\Core\Visit\Entity\Visit;
+use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
+use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Ask;
+use Symfony\Component\Console\Attribute\MapInput;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Style\SymfonyStyle;
-class GetDomainVisitsCommand extends AbstractVisitsListCommand
+#[AsCommand(GetDomainVisitsCommand::NAME, 'Returns the list of visits for provided domain')]
+class GetDomainVisitsCommand extends Command
{
public const string NAME = 'domain:visits';
- public function __construct(
- VisitsStatsHelperInterface $visitsHelper,
- private readonly ShortUrlStringifierInterface $shortUrlStringifier,
- ) {
- parent::__construct($visitsHelper);
+ public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
+ {
+ parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Returns the list of visits for provided domain.')
- ->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
- }
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('The domain which visits we want to get'), Ask('For what domain do you want to get visits?')]
+ string $domain,
+ #[MapInput] VisitsListInput $input,
+ ): int {
+ $paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange()));
+ VisitsCommandUtils::renderOutput($io, $input, $paginator);
- /**
- * @return Paginator
- */
- protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
- {
- $domain = $input->getArgument('domain');
- return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange));
- }
-
- /**
- * @return array
- */
- protected function mapExtraFields(Visit $visit): array
- {
- $shortUrl = $visit->shortUrl;
- return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
+ return self::SUCCESS;
}
}
diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php
index a66d6d7e..d33d1ed6 100644
--- a/module/CLI/src/Command/Domain/ListDomainsCommand.php
+++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php
@@ -59,9 +59,9 @@ class ListDomainsCommand extends Command
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
{
- $baseUrl = $config->baseUrlRedirect() ?? 'N/A';
- $regular404 = $config->regular404Redirect() ?? 'N/A';
- $invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
+ $baseUrl = $config->baseUrlRedirect ?? 'N/A';
+ $regular404 = $config->regular404Redirect ?? 'N/A';
+ $invalidShortUrl = $config->invalidShortUrlRedirect ?? 'N/A';
return <<setDescription(sprintf(
- '%sSend existing visits to the configured matomo instance',
- $this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ',
- ));
- }
-
public function __invoke(
SymfonyStyle $io,
InputInterface $input,
@@ -81,8 +74,8 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
// TODO Validate provided date formats
$dateRange = buildDateRange(
- startDate: $since !== null ? Chronos::parse($since) : null,
- endDate: $until !== null ? Chronos::parse($until) : null,
+ startDate: normalizeOptionalDate($since),
+ endDate: normalizeOptionalDate($until),
);
if ($input->isInteractive()) {
diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php
index 9a129e9d..81e41497 100644
--- a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php
+++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php
@@ -4,48 +4,41 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\RedirectRule;
-use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
+use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
+#[AsCommand(
+ name: ManageRedirectRulesCommand::NAME,
+ description: 'Set redirect rules for a short URL',
+)]
class ManageRedirectRulesCommand extends Command
{
public const string NAME = 'short-url:manage-rules';
- private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
-
public function __construct(
protected readonly ShortUrlResolverInterface $shortUrlResolver,
protected readonly ShortUrlRedirectRuleServiceInterface $ruleService,
protected readonly RedirectRuleHandlerInterface $ruleHandler,
) {
parent::__construct();
- $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
- $this,
- shortCodeDesc: 'The short code which rules we want to set.',
- domainDesc: 'The domain for the short code.',
- );
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Set redirect rules for a short URL');
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
- $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('The short code which rules we want to set')] string $shortCode,
+ #[Option('The domain of the short code', shortcut: 'd')] string|null $domain = null,
+ ): int {
+ $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier);
diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php
index 2e52571b..a5133d74 100644
--- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php
+++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php
@@ -4,109 +4,42 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
-use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
+use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlCreationInput;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
+#[AsCommand(
+ name: CreateShortUrlCommand::NAME,
+ description: 'Generates a short URL for provided long URL and returns it',
+)]
class CreateShortUrlCommand extends Command
{
public const string NAME = 'short-url:create';
- private SymfonyStyle $io;
- private readonly ShortUrlDataInput $shortUrlDataInput;
-
public function __construct(
private readonly UrlShortenerInterface $urlShortener,
private readonly ShortUrlStringifierInterface $stringifier,
private readonly UrlShortenerOptions $options,
) {
parent::__construct();
- $this->shortUrlDataInput = new ShortUrlDataInput($this);
}
- protected function configure(): void
+ public function __invoke(SymfonyStyle $io, #[MapInput] ShortUrlCreationInput $inputData): int
{
- $this
- ->setName(self::NAME)
- ->setDescription('Generates a short URL for provided long URL and returns it')
- ->addOption(
- 'domain',
- 'd',
- InputOption::VALUE_REQUIRED,
- 'The domain to which this short URL will be attached.',
- )
- ->addOption(
- 'custom-slug',
- 'c',
- InputOption::VALUE_REQUIRED,
- 'If provided, this slug will be used instead of generating a short code',
- )
- ->addOption(
- 'short-code-length',
- 'l',
- InputOption::VALUE_REQUIRED,
- 'The length for generated short code (it will be ignored if --custom-slug was provided).',
- )
- ->addOption(
- 'path-prefix',
- 'p',
- InputOption::VALUE_REQUIRED,
- 'Prefix to prepend before the generated short code or provided custom slug',
- )
- ->addOption(
- 'find-if-exists',
- 'f',
- InputOption::VALUE_NONE,
- 'This will force existing matching URL to be returned if found, instead of creating a new one.',
- );
- }
-
- protected function interact(InputInterface $input, OutputInterface $output): void
- {
- $this->verifyLongUrlArgument($input, $output);
- }
-
- private function verifyLongUrlArgument(InputInterface $input, OutputInterface $output): void
- {
- $longUrl = $input->getArgument('longUrl');
- if (! empty($longUrl)) {
- return;
- }
-
- $io = $this->getIO($input, $output);
- $longUrl = $io->ask('Which URL do you want to shorten?');
- if (! empty($longUrl)) {
- $input->setArgument('longUrl', $longUrl);
- }
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = $this->getIO($input, $output);
-
try {
- $result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation(
- $input,
- $this->options,
- customSlugField: 'custom-slug',
- shortCodeLengthField: 'short-code-length',
- pathPrefixField: 'path-prefix',
- findIfExistsField: 'find-if-exists',
- domainField: 'domain',
- ));
+ $result = $this->urlShortener->shorten($inputData->toShortUrlCreation($this->options));
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
'Short URL properly created, but the real-time updates cannot be notified when generating the '
- . 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.',
+ . 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.',
));
$io->writeln([
@@ -119,9 +52,4 @@ class CreateShortUrlCommand extends Command
return self::FAILURE;
}
}
-
- private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
- {
- return $this->io ??= new SymfonyStyle($input, $output);
- }
}
diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php
index e6a11ea1..c3b4ba0c 100644
--- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php
+++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php
@@ -4,53 +4,40 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
-use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
+#[AsCommand(name: DeleteShortUrlCommand::NAME, description: 'Deletes a short URL')]
class DeleteShortUrlCommand extends Command
{
public const string NAME = 'short-url:delete';
- private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
-
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
{
parent::__construct();
- $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
- $this,
- shortCodeDesc: 'The short code for the short URL to be deleted',
- domainDesc: 'The domain if the short code does not belong to the default one',
- );
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Deletes a short URL')
- ->addOption(
- 'ignore-threshold',
- 'i',
- InputOption::VALUE_NONE,
- 'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
- . 'accidentally deleted',
- );
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
- $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
- $ignoreThreshold = $input->getOption('ignore-threshold');
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('The short code for the short URL to be deleted')] string $shortCode,
+ #[Option('The domain if the short code does not belong to the default one', shortcut: 'd')]
+ string|null $domain = null,
+ #[Option(
+ 'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
+ . 'accidentally deleted',
+ shortcut: 'i',
+ )]
+ bool $ignoreThreshold = false,
+ ): int {
+ $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$this->runDelete($io, $identifier, $ignoreThreshold);
diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php
index 4c238f31..16667eb3 100644
--- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php
+++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php
@@ -4,41 +4,44 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
-use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand;
-use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
+use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
+use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
-use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
-class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
+#[AsCommand(DeleteShortUrlVisitsCommand::NAME, 'Deletes visits from a short URL')]
+class DeleteShortUrlVisitsCommand extends Command
{
public const string NAME = 'short-url:visits-delete';
- private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
-
public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter)
{
parent::__construct();
- $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
- $this,
- shortCodeDesc: 'The short code for the short URL which visits will be deleted',
- domainDesc: 'The domain if the short code does not belong to the default one',
+ }
+
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('The short code for the short URL which visits will be deleted')] string $shortCode,
+ #[Option('The domain if the short code does not belong to the default one', shortcut: 'd')]
+ string|null $domain = null,
+ ): int {
+ $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
+ return CommandUtils::executeWithWarning(
+ 'You are about to delete all visits for a short URL. This operation cannot be undone',
+ $io,
+ fn () => $this->deleteVisits($io, $identifier),
);
}
- protected function configure(): void
+ private function deleteVisits(SymfonyStyle $io, ShortUrlIdentifier $identifier): int
{
- $this
- ->setName(self::NAME)
- ->setDescription('Deletes visits from a short URL');
- }
-
- protected function doExecute(InputInterface $input, SymfonyStyle $io): int
- {
- $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
try {
$result = $this->deleter->deleteShortUrlVisits($identifier);
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
@@ -49,9 +52,4 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
return self::INVALID;
}
}
-
- protected function getWarningMessage(): string
- {
- return 'You are about to delete all visits for a short URL. This operation cannot be undone.';
- }
}
diff --git a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php
index 16fe9458..f4e3b06e 100644
--- a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php
+++ b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php
@@ -4,55 +4,49 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
-use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
-use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
+use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlDataInput;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
+use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
+use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\MapInput;
+use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
+#[AsCommand(
+ name: EditShortUrlCommand::NAME,
+ description: 'Edit an existing short URL',
+)]
class EditShortUrlCommand extends Command
{
public const string NAME = 'short-url:edit';
- private readonly ShortUrlDataInput $shortUrlDataInput;
- private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
-
public function __construct(
private readonly ShortUrlServiceInterface $shortUrlService,
private readonly ShortUrlStringifierInterface $stringifier,
) {
parent::__construct();
-
- $this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true);
- $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
- $this,
- shortCodeDesc: 'The short code to edit',
- domainDesc: 'The domain to which the short URL is attached.',
- );
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Edit an existing short URL');
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
- $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
+ public function __invoke(
+ SymfonyStyle $io,
+ #[MapInput] ShortUrlDataInput $data,
+ #[Argument('The short code to edit')] string $shortCode,
+ #[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null,
+ #[Option('The long URL to set', shortcut: 'l')] string|null $longUrl = null,
+ ): int {
+ $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$shortUrl = $this->shortUrlService->updateShortUrl(
$identifier,
- $this->shortUrlDataInput->toShortUrlEdition($input),
+ ShortUrlEdition::fromRawData($data->toArray()),
);
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));
diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php
index 8507b9ca..b0e4cce0 100644
--- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php
+++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php
@@ -4,62 +4,43 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
-use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
-use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
-use Shlinkio\Shlink\Common\Paginator\Paginator;
-use Shlinkio\Shlink\Common\Util\DateRange;
-use Shlinkio\Shlink\Core\Visit\Entity\Visit;
+use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
+use Shlinkio\Shlink\CLI\Input\VisitsListInput;
+use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
+use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Ask;
+use Symfony\Component\Console\Attribute\MapInput;
+use Symfony\Component\Console\Attribute\Option;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
-class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
+#[AsCommand(GetShortUrlVisitsCommand::NAME, 'Returns the detailed visits information for provided short code')]
+class GetShortUrlVisitsCommand extends Command
{
public const string NAME = 'short-url:visits';
- private ShortUrlIdentifierInput $shortUrlIdentifierInput;
-
- protected function configure(): void
+ public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
- $this
- ->setName(self::NAME)
- ->setDescription('Returns the detailed visits information for provided short code');
- $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
- $this,
- shortCodeDesc: 'The short code which visits we want to get.',
- domainDesc: 'The domain for the short code.',
- );
+ parent::__construct();
}
- protected function interact(InputInterface $input, OutputInterface $output): void
- {
- $shortCode = $this->shortUrlIdentifierInput->shortCode($input);
- if (! empty($shortCode)) {
- return;
- }
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('The short code which visits we want to get'), Ask('Which short code do you want to use?')]
+ string $shortCode,
+ #[MapInput] VisitsListInput $input,
+ #[Option('The domain for the short code', shortcut: 'd')]
+ string|null $domain = null,
+ ): int {
+ $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
+ $dateRange = $input->dateRange();
+ $paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
- $io = new SymfonyStyle($input, $output);
- $shortCode = $io->ask('A short code was not provided. Which short code do you want to use?');
- if (! empty($shortCode)) {
- $input->setArgument('shortCode', $shortCode);
- }
- }
+ VisitsCommandUtils::renderOutput($io, $input, $paginator);
- /**
- * @return Paginator
- */
- protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
- {
- $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
- return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
- }
-
- /**
- * @return array
- */
- protected function mapExtraFields(Visit $visit): array
- {
- return [];
+ return self::SUCCESS;
}
}
diff --git a/module/CLI/src/Command/ShortUrl/Input/ShortUrlCreationInput.php b/module/CLI/src/Command/ShortUrl/Input/ShortUrlCreationInput.php
new file mode 100644
index 00000000..729c8a94
--- /dev/null
+++ b/module/CLI/src/Command/ShortUrl/Input/ShortUrlCreationInput.php
@@ -0,0 +1,57 @@
+shortCodeLength ?? $options->defaultShortCodesLength;
+ return ShortUrlCreation::fromRawData([
+ ShortUrlInputFilter::LONG_URL => $this->longUrl,
+ ShortUrlInputFilter::DOMAIN => $this->domain,
+ ShortUrlInputFilter::CUSTOM_SLUG => $this->customSlug,
+ ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
+ ShortUrlInputFilter::PATH_PREFIX => $this->pathPrefix,
+ ShortUrlInputFilter::FIND_IF_EXISTS => $this->findIfExists,
+ ...$this->commonData->toArray(),
+ ], $options);
+ }
+}
diff --git a/module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php b/module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php
new file mode 100644
index 00000000..988e87a0
--- /dev/null
+++ b/module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php
@@ -0,0 +1,80 @@
+validSince !== null) {
+ $data[ShortUrlInputFilter::VALID_SINCE] = $this->validSince;
+ }
+ if ($this->validUntil !== null) {
+ $data[ShortUrlInputFilter::VALID_UNTIL] = $this->validUntil;
+ }
+ if ($this->maxVisits !== null) {
+ $data[ShortUrlInputFilter::MAX_VISITS] = $this->maxVisits;
+ }
+ if ($this->tags !== null) {
+ $data[ShortUrlInputFilter::TAGS] = array_unique($this->tags);
+ }
+ if ($this->title !== null) {
+ $data[ShortUrlInputFilter::TITLE] = $this->title;
+ }
+ if ($this->crawlable !== null) {
+ $data[ShortUrlInputFilter::CRAWLABLE] = $this->crawlable;
+ }
+ if ($this->noForwardQuery !== null) {
+ $data[ShortUrlInputFilter::FORWARD_QUERY] = !$this->noForwardQuery;
+ }
+
+ return $data;
+ }
+}
diff --git a/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php b/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php
new file mode 100644
index 00000000..01a0e2bd
--- /dev/null
+++ b/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php
@@ -0,0 +1,121 @@
+ $this->page,
+ ShortUrlsParamsInputFilter::SEARCH_TERM => $this->searchTerm,
+ ShortUrlsParamsInputFilter::DOMAIN => $this->domain,
+ ShortUrlsParamsInputFilter::ORDER_BY => $this->orderBy,
+ ShortUrlsParamsInputFilter::START_DATE => InputUtils::processDate('start-date', $this->startDate, $output),
+ ShortUrlsParamsInputFilter::END_DATE => InputUtils::processDate('end-date', $this->endDate, $output),
+ ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $this->excludeMaxVisitsReached,
+ ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $this->excludePastValidUntil,
+ ShortUrlsParamsInputFilter::API_KEY_NAME => $this->apiKeyName,
+ ];
+
+ if ($this->tags !== null) {
+ $tagsMode = $this->tagsAll ? TagsMode::ALL : TagsMode::ANY;
+ $data[ShortUrlsParamsInputFilter::TAGS_MODE] = $tagsMode->value;
+ $data[ShortUrlsParamsInputFilter::TAGS] = array_unique($this->tags);
+ }
+
+ if ($this->excludeTags !== null) {
+ $excludeTagsMode = $this->excludeTagsAll ? TagsMode::ALL : TagsMode::ANY;
+ $data[ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE] = $excludeTagsMode->value;
+ $data[ShortUrlsParamsInputFilter::EXCLUDE_TAGS] = array_unique($this->excludeTags);
+ }
+
+ if ($this->all) {
+ $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
+ }
+
+ return $data;
+ }
+}
diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
index d850e831..fd2d552a 100644
--- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
+++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
-use Shlinkio\Shlink\CLI\Input\EndDateOption;
-use Shlinkio\Shlink\CLI\Input\StartDateOption;
-use Shlinkio\Shlink\CLI\Input\TagsOption;
+use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlsParamsInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
@@ -14,169 +12,45 @@ use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps;
-use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_keys;
-use function array_pad;
-use function explode;
use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf;
+#[AsCommand(name: ListShortUrlsCommand::NAME, description: 'List all short URLs')]
class ListShortUrlsCommand extends Command
{
public const string NAME = 'short-url:list';
- private readonly StartDateOption $startDateOption;
- private readonly EndDateOption $endDateOption;
- private readonly TagsOption $tagsOption;
-
public function __construct(
private readonly ShortUrlListServiceInterface $shortUrlService,
private readonly ShortUrlDataTransformerInterface $transformer,
) {
parent::__construct();
- $this->startDateOption = new StartDateOption($this, 'short URLs');
- $this->endDateOption = new EndDateOption($this, 'short URLs');
- $this->tagsOption = new TagsOption($this, 'A list of tags that short URLs need to include.');
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('List all short URLs')
- ->addOption(
- 'page',
- 'p',
- InputOption::VALUE_REQUIRED,
- 'The first page to list (10 items per page unless "--all" is provided).',
- '1',
- )
- ->addOption(
- 'search-term',
- 'st',
- InputOption::VALUE_REQUIRED,
- 'A query used to filter results by searching for it on the longUrl and shortCode fields.',
- )
- ->addOption(
- 'domain',
- 'd',
- InputOption::VALUE_REQUIRED,
- 'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
- )
- ->addOption('including-all-tags', 'i', InputOption::VALUE_NONE, '[DEPRECATED] Use --tags-all instead')
- ->addOption(
- 'tags-all',
- mode: InputOption::VALUE_NONE,
- description: 'If --tags is provided, returns only short URLs including ALL of them',
- )
- ->addOption(
- 'exclude-tag',
- 'et',
- InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
- 'A list of tags that short URLs should not have.',
- )
- ->addOption(
- 'exclude-tags-all',
- mode: InputOption::VALUE_NONE,
- description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them',
- )
- ->addOption(
- 'exclude-max-visits-reached',
- null,
- InputOption::VALUE_NONE,
- 'Excludes short URLs which reached their max amount of visits.',
- )
- ->addOption(
- 'exclude-past-valid-until',
- null,
- InputOption::VALUE_NONE,
- 'Excludes short URLs which have a "validUntil" date in the past.',
- )
- ->addOption(
- 'order-by',
- 'o',
- InputOption::VALUE_REQUIRED,
- 'The field from which you want to order by. '
- . 'Define ordering dir by passing ASC or DESC after "-" or ",".',
- )
- ->addOption(
- 'api-key-name',
- 'kn',
- InputOption::VALUE_REQUIRED,
- 'List only short URLs created by the API key matching provided name.',
- )
- ->addOption(
- 'show-tags',
- null,
- InputOption::VALUE_NONE,
- 'Whether to display the tags or not.',
- )
- ->addOption(
- 'show-domain',
- null,
- InputOption::VALUE_NONE,
- 'Whether to display the domain or not. Those belonging to default domain will have value "DEFAULT".',
- )
- ->addOption(
- 'show-api-key',
- 'k',
- InputOption::VALUE_NONE,
- 'Whether to display the API key name from which the URL was generated or not.',
- )
- ->addOption('show-api-key-name', 'm', InputOption::VALUE_NONE, '[DEPRECATED] Use show-api-key')
- ->addOption(
- 'all',
- 'a',
- InputOption::VALUE_NONE,
- 'Disables pagination and just displays all existing URLs. Caution! If the amount of short URLs is big,'
- . ' this may end up failing due to memory usage.',
- );
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
-
- $page = (int) $input->getOption('page');
- $tagsMode = $input->getOption('tags-all') === true || $input->getOption('including-all-tags') === true
- ? TagsMode::ALL->value
- : TagsMode::ANY->value;
- $excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
-
- $data = [
- ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'),
- ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'),
- ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input),
- ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
- ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $input->getOption('exclude-tag'),
- ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode,
- ShortUrlsParamsInputFilter::ORDER_BY => $this->processOrderBy($input),
- ShortUrlsParamsInputFilter::START_DATE => $this->startDateOption->get($input, $output)?->toAtomString(),
- ShortUrlsParamsInputFilter::END_DATE => $this->endDateOption->get($input, $output)?->toAtomString(),
- ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'),
- ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'),
- ShortUrlsParamsInputFilter::API_KEY_NAME => $input->getOption('api-key-name'),
- ];
-
- $all = $input->getOption('all');
- if ($all) {
- $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
- }
+ public function __invoke(
+ SymfonyStyle $io,
+ InputInterface $input,
+ #[MapInput] ShortUrlsParamsInput $paramsInput,
+ ): int {
+ $page = $paramsInput->page;
+ $data = $paramsInput->toArray($io);
$columnsMap = $this->resolveColumnsMap($input);
do {
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
- $result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
+ $result = $this->renderPage($io, $columnsMap, ShortUrlsParams::fromRawData($data), $paramsInput->all);
$page++;
$continue = $result->hasNextPage() && $io->confirm(
@@ -217,17 +91,6 @@ class ListShortUrlsCommand extends Command
return $shortUrls;
}
- private function processOrderBy(InputInterface $input): string|null
- {
- $orderBy = $input->getOption('order-by');
- if (empty($orderBy)) {
- return null;
- }
-
- [$field, $dir] = array_pad(explode(',', $orderBy), 2, null);
- return $dir === null ? $field : sprintf('%s-%s', $field, $dir);
- }
-
/**
* @return array
*/
@@ -249,7 +112,7 @@ class ListShortUrlsCommand extends Command
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY;
}
- if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) {
+ if ($input->getOption('show-api-key')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null =>
$shortUrl->authorApiKey?->name;
}
diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php
index b6bf71f7..b70ba70e 100644
--- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php
+++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php
@@ -4,60 +4,42 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
-use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
+use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Ask;
+use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
+#[AsCommand(ResolveUrlCommand::NAME, 'Returns the long URL behind a short code')]
class ResolveUrlCommand extends Command
{
- public const string NAME = 'short-url:parse';
-
- private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
+ public const string NAME = 'short-url:resolve';
public function __construct(private readonly ShortUrlResolverInterface $urlResolver)
{
parent::__construct();
- $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
- $this,
- shortCodeDesc: 'The short code to parse',
- domainDesc: 'The domain to which the short URL is attached.',
- );
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Returns the long URL behind a short code');
- }
-
- protected function interact(InputInterface $input, OutputInterface $output): void
- {
- $shortCode = $this->shortUrlIdentifierInput->shortCode($input);
- if (! empty($shortCode)) {
- return;
- }
-
- $io = new SymfonyStyle($input, $output);
- $shortCode = $io->ask('A short code was not provided. Which short code do you want to parse?');
- if (! empty($shortCode)) {
- $input->setArgument('shortCode', $shortCode);
- }
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
+ public function __invoke(
+ SymfonyStyle $io,
+ #[
+ Argument('The short code to resolve'),
+ Ask('A short code was not provided. Which short code do you want to resolve?'),
+ ]
+ string $shortCode,
+ #[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null,
+ ): int {
+ $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
- $url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input));
- $output->writeln(sprintf('Long URL: %s', $url->getLongUrl()));
+ $url = $this->urlResolver->resolveShortUrl($identifier);
+ $io->writeln(sprintf('Long URL: %s', $url->getLongUrl()));
return self::SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php
index bac12ac2..0719af2b 100644
--- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php
+++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php
@@ -4,63 +4,47 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
-use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
-use Shlinkio\Shlink\CLI\Input\DomainOption;
-use Shlinkio\Shlink\Common\Paginator\Paginator;
-use Shlinkio\Shlink\Common\Util\DateRange;
+use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
+use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
-use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
-use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Ask;
+use Symfony\Component\Console\Attribute\MapInput;
+use Symfony\Component\Console\Attribute\Option;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Style\SymfonyStyle;
-use function sprintf;
-
-class GetTagVisitsCommand extends AbstractVisitsListCommand
+#[AsCommand(GetTagVisitsCommand::NAME, 'Returns the list of visits for provided tag')]
+class GetTagVisitsCommand extends Command
{
public const string NAME = 'tag:visits';
- private readonly DomainOption $domainOption;
+ public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
+ {
+ parent::__construct();
+ }
- public function __construct(
- VisitsStatsHelperInterface $visitsHelper,
- private readonly ShortUrlStringifierInterface $shortUrlStringifier,
- ) {
- parent::__construct($visitsHelper);
- $this->domainOption = new DomainOption($this, sprintf(
- 'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
- Domain::DEFAULT_AUTHORITY,
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('The tag which visits we want to get'), Ask('For what tag do you want to get visits')] string $tag,
+ #[MapInput] VisitsListInput $input,
+ #[Option(
+ 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
+ . 'in default domain',
+ shortcut: 'd',
+ )]
+ string|null $domain = null,
+ ): int {
+ $paginator = $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
+ dateRange: $input->dateRange(),
+ domain: $domain,
));
- }
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Returns the list of visits for provided tag.')
- ->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
- }
+ VisitsCommandUtils::renderOutput($io, $input, $paginator);
- /**
- * @return Paginator
- */
- protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
- {
- $tag = $input->getArgument('tag');
- return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
- dateRange: $dateRange,
- domain: $this->domainOption->get($input),
- ));
- }
-
- /**
- * @return array
- */
- protected function mapExtraFields(Visit $visit): array
- {
- $shortUrl = $visit->shortUrl;
- return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
+ return self::SUCCESS;
}
}
diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php
deleted file mode 100644
index a4c3ef5d..00000000
--- a/module/CLI/src/Command/Util/AbstractLockedCommand.php
+++ /dev/null
@@ -1,43 +0,0 @@
-getLockConfig();
- $lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking);
-
- if (! $lock->acquire($lockConfig->isBlocking)) {
- $output->writeln(
- sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName),
- );
- return self::INVALID;
- }
-
- try {
- return $this->lockedExecute($input, $output);
- } finally {
- $lock->release();
- }
- }
-
- abstract protected function lockedExecute(InputInterface $input, OutputInterface $output): int;
-
- abstract protected function getLockConfig(): LockedCommandConfig;
-}
diff --git a/module/CLI/src/Command/Util/CommandUtils.php b/module/CLI/src/Command/Util/CommandUtils.php
new file mode 100644
index 00000000..69158275
--- /dev/null
+++ b/module/CLI/src/Command/Util/CommandUtils.php
@@ -0,0 +1,58 @@
+warning($warning);
+ if (! $io->confirm('Do you want to proceed?', default: false)) {
+ $io->info('Operation aborted');
+ return Command::SUCCESS;
+ }
+
+ return $callback();
+ }
+
+ /**
+ * Runs a callback with a lock, making sure the lock is released after running the callback, and the callback does
+ * not run if the lock is already acquired.
+ *
+ * @param callable(): int $callback
+ */
+ public static function executeWithLock(
+ LockFactory $locker,
+ LockConfig $lockConfig,
+ SymfonyStyle $io,
+ callable $callback,
+ ): int {
+ $lock = $locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking);
+ if (! $lock->acquire($lockConfig->isBlocking)) {
+ $io->writeln(
+ sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName),
+ );
+ return Command::INVALID;
+ }
+
+ try {
+ return $callback();
+ } finally {
+ $lock->release();
+ }
+ }
+}
diff --git a/module/CLI/src/Command/Util/LockedCommandConfig.php b/module/CLI/src/Command/Util/LockConfig.php
similarity index 56%
rename from module/CLI/src/Command/Util/LockedCommandConfig.php
rename to module/CLI/src/Command/Util/LockConfig.php
index a8834d92..8f8fb09c 100644
--- a/module/CLI/src/Command/Util/LockedCommandConfig.php
+++ b/module/CLI/src/Command/Util/LockConfig.php
@@ -4,24 +4,24 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
-final class LockedCommandConfig
+final readonly class LockConfig
{
public const float DEFAULT_TTL = 600.0; // 10 minutes
private function __construct(
- public readonly string $lockName,
- public readonly bool $isBlocking,
- public readonly float $ttl = self::DEFAULT_TTL,
+ public string $lockName,
+ public bool $isBlocking,
+ public float $ttl = self::DEFAULT_TTL,
) {
}
public static function blocking(string $lockName): self
{
- return new self($lockName, true);
+ return new self($lockName, isBlocking: true);
}
public static function nonBlocking(string $lockName): self
{
- return new self($lockName, false);
+ return new self($lockName, isBlocking: false);
}
}
diff --git a/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php b/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php
deleted file mode 100644
index d8ef98e3..00000000
--- a/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php
+++ /dev/null
@@ -1,34 +0,0 @@
-confirm($io)) {
- $io->info('Operation aborted');
- return self::SUCCESS;
- }
-
- return $this->doExecute($input, $io);
- }
-
- private function confirm(SymfonyStyle $io): bool
- {
- $io->warning($this->getWarningMessage());
- return $io->confirm('Continue deleting visits?', false);
- }
-
- abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): int;
-
- abstract protected function getWarningMessage(): string;
-}
diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php
deleted file mode 100644
index 5916fc52..00000000
--- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php
+++ /dev/null
@@ -1,88 +0,0 @@
-startDateOption = new StartDateOption($this, 'visits');
- $this->endDateOption = new EndDateOption($this, 'visits');
- }
-
- final protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $startDate = $this->startDateOption->get($input, $output);
- $endDate = $this->endDateOption->get($input, $output);
- $paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
- [$rows, $headers] = $this->resolveRowsAndHeaders($paginator);
-
- ShlinkTable::default($output)->render($headers, $rows);
-
- return self::SUCCESS;
- }
-
- /**
- * @param Paginator $paginator
- */
- private function resolveRowsAndHeaders(Paginator $paginator): array
- {
- $extraKeys = [];
- $rows = array_map(function (Visit $visit) use (&$extraKeys) {
- $extraFields = $this->mapExtraFields($visit);
- $extraKeys = array_keys($extraFields);
-
- $rowData = [
- 'referer' => $visit->referer,
- 'date' => $visit->date->toAtomString(),
- 'userAgent' => $visit->userAgent,
- 'potentialBot' => $visit->potentialBot,
- 'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
- 'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
- ...$extraFields,
- ];
-
- // Filter out unknown keys
- return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
- }, [...$paginator->getCurrentPageResults()]);
- $extra = array_map(camelCaseToHumanFriendly(...), $extraKeys);
-
- return [
- $rows,
- ['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
- ];
- }
-
- /**
- * @return Paginator
- */
- abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
-
- /**
- * @return array
- */
- abstract protected function mapExtraFields(Visit $visit): array;
-}
diff --git a/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php
index 77fefaaa..654b92bf 100644
--- a/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php
+++ b/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php
@@ -4,13 +4,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
+use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
-use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
-class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
+#[AsCommand(DeleteOrphanVisitsCommand::NAME, 'Deletes all orphan visits')]
+class DeleteOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:orphan-delete';
@@ -19,23 +22,20 @@ class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
parent::__construct();
}
- protected function configure(): void
+ public function __invoke(SymfonyStyle $io): int
{
- $this
- ->setName(self::NAME)
- ->setDescription('Deletes all orphan visits');
+ return CommandUtils::executeWithWarning(
+ 'You are about to delete all orphan visits. This operation cannot be undone',
+ $io,
+ fn () => $this->deleteVisits($io),
+ );
}
- protected function doExecute(InputInterface $input, SymfonyStyle $io): int
+ private function deleteVisits(SymfonyStyle $io): int
{
$result = $this->deleter->deleteOrphanVisits();
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return self::SUCCESS;
}
-
- protected function getWarningMessage(): string
- {
- return 'You are about to delete all orphan visits. This operation cannot be undone.';
- }
}
diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
index f76a4dbc..e3e98fc6 100644
--- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
+++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
@@ -17,7 +17,7 @@ use function sprintf;
#[AsCommand(
DownloadGeoLiteDbCommand::NAME,
- 'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so.',
+ 'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so',
)]
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
{
diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php
index 1b40d55e..86f5b94e 100644
--- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php
+++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php
@@ -4,59 +4,42 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
-use Shlinkio\Shlink\CLI\Input\DomainOption;
-use Shlinkio\Shlink\Common\Paginator\Paginator;
-use Shlinkio\Shlink\Common\Util\DateRange;
+use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
-use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
-use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
-use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\MapInput;
+use Symfony\Component\Console\Attribute\Option;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Style\SymfonyStyle;
-use function sprintf;
-
-class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
+#[AsCommand(GetNonOrphanVisitsCommand::NAME, 'Returns the list of non-orphan visits')]
+class GetNonOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:non-orphan';
- private readonly DomainOption $domainOption;
+ public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
+ {
+ parent::__construct();
+ }
- public function __construct(
- VisitsStatsHelperInterface $visitsHelper,
- private readonly ShortUrlStringifierInterface $shortUrlStringifier,
- ) {
- parent::__construct($visitsHelper);
- $this->domainOption = new DomainOption($this, sprintf(
- 'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
- Domain::DEFAULT_AUTHORITY,
+ public function __invoke(
+ SymfonyStyle $io,
+ #[MapInput] VisitsListInput $input,
+ #[Option(
+ 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
+ . 'in default domain',
+ shortcut: 'd',
+ )]
+ string|null $domain = null,
+ ): int {
+ $paginator = $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
+ dateRange: $input->dateRange(),
+ domain: $domain,
));
- }
+ VisitsCommandUtils::renderOutput($io, $input, $paginator);
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Returns the list of non-orphan visits.');
- }
-
- /**
- * @return Paginator
- */
- protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
- {
- return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
- dateRange: $dateRange,
- domain: $this->domainOption->get($input),
- ));
- }
-
- /**
- * @return array
- */
- protected function mapExtraFields(Visit $visit): array
- {
- $shortUrl = $visit->shortUrl;
- return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
+ return self::SUCCESS;
}
}
diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php
index 0804215a..5ad4a460 100644
--- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php
+++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php
@@ -4,65 +4,45 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
-use Shlinkio\Shlink\CLI\Input\DomainOption;
-use Shlinkio\Shlink\Common\Paginator\Paginator;
-use Shlinkio\Shlink\Common\Util\DateRange;
+use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
-use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\MapInput;
+use Symfony\Component\Console\Attribute\Option;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Style\SymfonyStyle;
-use function Shlinkio\Shlink\Core\enumToString;
-use function sprintf;
-
-class GetOrphanVisitsCommand extends AbstractVisitsListCommand
+#[AsCommand(GetOrphanVisitsCommand::NAME, 'Returns the list of orphan visits')]
+class GetOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:orphan';
- private readonly DomainOption $domainOption;
-
- public function __construct(VisitsStatsHelperInterface $visitsHelper)
+ public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
- parent::__construct($visitsHelper);
- $this->domainOption = new DomainOption($this, sprintf(
- 'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
- Domain::DEFAULT_AUTHORITY,
- ));
+ parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Returns the list of orphan visits.')
- ->addOption('type', 't', InputOption::VALUE_REQUIRED, sprintf(
- 'Return visits only with this type. One of %s',
- enumToString(OrphanVisitType::class),
- ));
- }
-
- /**
- * @return Paginator
- */
- protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
- {
- $rawType = $input->getOption('type');
- $type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
- return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
- dateRange: $dateRange,
- domain: $this->domainOption->get($input),
+ public function __invoke(
+ SymfonyStyle $io,
+ #[MapInput] VisitsListInput $input,
+ #[Option(
+ 'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
+ . 'in default domain',
+ shortcut: 'd',
+ )]
+ string|null $domain = null,
+ #[Option('Return visits only with this type', shortcut: 't')] OrphanVisitType|null $type = null,
+ ): int {
+ $paginator = $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
+ dateRange: $input->dateRange(),
+ domain: $domain,
type: $type,
));
- }
+ VisitsCommandUtils::renderOutput($io, $input, $paginator);
- /**
- * @return array
- */
- protected function mapExtraFields(Visit $visit): array
- {
- return ['type' => $visit->type->value];
+ return self::SUCCESS;
}
}
diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php
index 3ed2edf9..d6ce9ea2 100644
--- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php
+++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
-use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
-use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
+use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
+use Shlinkio\Shlink\CLI\Command\Util\LockConfig;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@@ -15,18 +15,22 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocatorInterface;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
use Throwable;
use function sprintf;
-class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
+#[AsCommand(
+ name: LocateVisitsCommand::NAME,
+ description: 'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed',
+)]
+class LocateVisitsCommand extends Command implements VisitGeolocationHelperInterface
{
public const string NAME = 'visit:locate';
@@ -35,46 +39,30 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
public function __construct(
private readonly VisitLocatorInterface $visitLocator,
private readonly VisitToLocationHelperInterface $visitToLocation,
- LockFactory $locker,
+ private readonly LockFactory $locker,
) {
- parent::__construct($locker);
+ parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription(
- 'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.',
- )
- ->addOption(
- 'retry',
- 'r',
- InputOption::VALUE_NONE,
- 'Will retry the location of visits that were located with a not-found location, in case it was due to '
- . 'a temporal issue.',
- )
- ->addOption(
- 'all',
- 'a',
- InputOption::VALUE_NONE,
- 'When provided together with --retry, will locate all existing visits, regardless the fact that they '
- . 'have already been located.',
- );
- }
-
- protected function initialize(InputInterface $input, OutputInterface $output): void
- {
- $this->io = new SymfonyStyle($input, $output);
- }
-
- protected function interact(InputInterface $input, OutputInterface $output): void
- {
- $retry = $input->getOption('retry');
- $all = $input->getOption('all');
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Option(
+ 'Will retry the location of visits that were located with a not-found location, in case it was due to '
+ . 'a temporal issue.',
+ shortcut: 'r',
+ )]
+ bool $retry = false,
+ #[Option(
+ 'When provided together with --retry, will locate all existing visits, regardless the fact that they '
+ . 'have already been located.',
+ shortcut: 'a',
+ )]
+ bool $all = false,
+ ): int {
+ $this->io = $io;
if ($all && !$retry) {
- $this->io->writeln(
+ $io->writeln(
'The --all> flag has no effect on its own. You have to provide it '
. 'together with --retry>.',
);
@@ -83,6 +71,13 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
throw new RuntimeException('Execution aborted');
}
+
+ return CommandUtils::executeWithLock(
+ $this->locker,
+ LockConfig::nonBlocking(self::NAME),
+ $io,
+ fn () => $this->locateVisits($retry, $all),
+ );
}
private function warnAndVerifyContinue(): bool
@@ -97,11 +92,8 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
return $this->io->confirm('Do you want to proceed?', false);
}
- protected function lockedExecute(InputInterface $input, OutputInterface $output): int
+ private function locateVisits(bool $retry, bool $all): int
{
- $retry = $input->getOption('retry');
- $all = $retry && $input->getOption('all');
-
try {
$this->checkDbUpdate();
@@ -174,9 +166,4 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
}
}
-
- protected function getLockConfig(): LockedCommandConfig
- {
- return LockedCommandConfig::nonBlocking(self::NAME);
- }
}
diff --git a/module/CLI/src/Command/Visit/VisitsCommandUtils.php b/module/CLI/src/Command/Visit/VisitsCommandUtils.php
new file mode 100644
index 00000000..765b27c0
--- /dev/null
+++ b/module/CLI/src/Command/Visit/VisitsCommandUtils.php
@@ -0,0 +1,117 @@
+ $paginator
+ */
+ public static function renderOutput(
+ OutputInterface $output,
+ VisitsListInput $inputData,
+ Paginator $paginator,
+ callable|null $mapExtraFields = null,
+ ): void {
+ if ($inputData->format !== VisitsListFormat::FULL) {
+ // Avoid running out of memory by loading visits in chunks
+ $paginator->setMaxPerPage(1000);
+ }
+
+ match ($inputData->format) {
+ VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator),
+ default => self::renderHumanFriendlyOutput($output, $paginator),
+ };
+ }
+
+ /**
+ * @param Paginator $paginator
+ */
+ private static function renderCSVOutput(OutputInterface $output, Paginator $paginator): void
+ {
+ $page = 1;
+ do {
+ $paginator->setCurrentPage($page);
+
+ [$rows, $headers] = self::resolveRowsAndHeaders($paginator);
+ $csv = Writer::fromString();
+ if ($page === 1) {
+ $csv->insertOne($headers);
+ }
+
+ $csv->insertAll($rows);
+ $output->write($csv->toString());
+
+ $page++;
+ } while ($paginator->hasNextPage());
+ }
+
+ /**
+ * @param Paginator $paginator
+ */
+ private static function renderHumanFriendlyOutput(OutputInterface $output, Paginator $paginator): void
+ {
+ $page = 1;
+ do {
+ $paginator->setCurrentPage($page);
+ $page++;
+
+ [$rows, $headers] = self::resolveRowsAndHeaders($paginator);
+ ShlinkTable::default($output)->render(
+ $headers,
+ $rows,
+ footerTitle: PagerfantaUtils::formatCurrentPageMessage($paginator, 'Page %s of %s'),
+ );
+ } while ($paginator->hasNextPage());
+ }
+
+ /**
+ * @param Paginator $paginator
+ */
+ private static function resolveRowsAndHeaders(Paginator $paginator): array
+ {
+ $headers = [
+ 'Date',
+ 'Potential bot',
+ 'User agent',
+ 'Referer',
+ 'Country',
+ 'Region',
+ 'City',
+ 'Visited URL',
+ 'Redirect URL',
+ 'Type',
+ ];
+ $rows = array_map(function (Visit $visit) {
+ $visitLocation = $visit->visitLocation;
+
+ return [
+ 'date' => $visit->date->toAtomString(),
+ 'potentialBot' => $visit->potentialBot ? 'Potential bot' : '',
+ 'userAgent' => $visit->userAgent,
+ 'referer' => $visit->referer,
+ 'country' => $visitLocation->countryName ?? 'Unknown',
+ 'region' => $visitLocation->regionName ?? 'Unknown',
+ 'city' => $visitLocation->cityName ?? 'Unknown',
+ 'visitedUrl' => $visit->visitedUrl ?? 'Unknown',
+ 'redirectUrl' => $visit->redirectUrl ?? 'Unknown',
+ 'type' => $visit->type->value,
+ ];
+ }, [...$paginator->getCurrentPageResults()]);
+
+ return [$rows, $headers];
+ }
+}
diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php
deleted file mode 100644
index 74acc162..00000000
--- a/module/CLI/src/Input/DateOption.php
+++ /dev/null
@@ -1,47 +0,0 @@
-addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
- }
-
- public function get(InputInterface $input, OutputInterface $output): Chronos|null
- {
- $value = $input->getOption($this->name);
- if (empty($value) || ! is_string($value)) {
- return null;
- }
-
- try {
- return Chronos::parse($value);
- } catch (Throwable $e) {
- $output->writeln(sprintf(
- '> Ignored provided "%s" since its value "%s" is not a valid date. <',
- $this->name,
- $value,
- ));
-
- if ($output->isVeryVerbose()) {
- $this->command->getApplication()?->renderThrowable($e, $output);
- }
-
- return null;
- }
- }
-}
diff --git a/module/CLI/src/Input/DomainOption.php b/module/CLI/src/Input/DomainOption.php
deleted file mode 100644
index e7a15f52..00000000
--- a/module/CLI/src/Input/DomainOption.php
+++ /dev/null
@@ -1,29 +0,0 @@
-addOption(
- name: self::NAME,
- shortcut: 'd',
- mode: InputOption::VALUE_REQUIRED,
- description: $description,
- );
- }
-
- public function get(InputInterface $input): string|null
- {
- return $input->getOption(self::NAME);
- }
-}
diff --git a/module/CLI/src/Input/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php
deleted file mode 100644
index a38b9b32..00000000
--- a/module/CLI/src/Input/EndDateOption.php
+++ /dev/null
@@ -1,30 +0,0 @@
-dateOption = new DateOption($command, 'end-date', 'e', sprintf(
- 'Allows to filter %s, returning only those newer than provided date.',
- $descriptionHint,
- ));
- }
-
- public function get(InputInterface $input, OutputInterface $output): Chronos|null
- {
- return $this->dateOption->get($input, $output);
- }
-}
diff --git a/module/CLI/src/Input/InputUtils.php b/module/CLI/src/Input/InputUtils.php
new file mode 100644
index 00000000..8a719bde
--- /dev/null
+++ b/module/CLI/src/Input/InputUtils.php
@@ -0,0 +1,37 @@
+toAtomString();
+ } catch (Throwable) {
+ $output->writeln(sprintf(
+ '> Ignored provided "%s" since its value "%s" is not a valid date. <',
+ $name,
+ $value,
+ ));
+
+ return null;
+ }
+ }
+}
diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php
deleted file mode 100644
index 908e6536..00000000
--- a/module/CLI/src/Input/ShortUrlDataInput.php
+++ /dev/null
@@ -1,128 +0,0 @@
-addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set');
- } else {
- $command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
- }
-
- $this->tagsOption = new TagsOption($command, 'Tags to apply to the short URL');
-
- $command
- ->addOption(
- ShortUrlDataOption::VALID_SINCE->value,
- ShortUrlDataOption::VALID_SINCE->shortcut(),
- InputOption::VALUE_REQUIRED,
- 'The date from which this short URL will be valid. '
- . 'If someone tries to access it before this date, it will not be found.',
- )
- ->addOption(
- ShortUrlDataOption::VALID_UNTIL->value,
- ShortUrlDataOption::VALID_UNTIL->shortcut(),
- InputOption::VALUE_REQUIRED,
- 'The date until which this short URL will be valid. '
- . 'If someone tries to access it after this date, it will not be found.',
- )
- ->addOption(
- ShortUrlDataOption::MAX_VISITS->value,
- ShortUrlDataOption::MAX_VISITS->shortcut(),
- InputOption::VALUE_REQUIRED,
- 'This will limit the number of visits for this short URL.',
- )
- ->addOption(
- ShortUrlDataOption::TITLE->value,
- ShortUrlDataOption::TITLE->shortcut(),
- InputOption::VALUE_REQUIRED,
- 'A descriptive title for the short URL.',
- )
- ->addOption(
- ShortUrlDataOption::CRAWLABLE->value,
- ShortUrlDataOption::CRAWLABLE->shortcut(),
- InputOption::VALUE_NONE,
- 'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.',
- )
- ->addOption(
- ShortUrlDataOption::NO_FORWARD_QUERY->value,
- ShortUrlDataOption::NO_FORWARD_QUERY->shortcut(),
- InputOption::VALUE_NONE,
- 'Disables the forwarding of the query string to the long URL, when the short URL is visited.',
- );
- }
-
- public function toShortUrlEdition(InputInterface $input): ShortUrlEdition
- {
- return ShortUrlEdition::fromRawData($this->getCommonData($input));
- }
-
- public function toShortUrlCreation(
- InputInterface $input,
- UrlShortenerOptions $options,
- string $customSlugField,
- string $shortCodeLengthField,
- string $pathPrefixField,
- string $findIfExistsField,
- string $domainField,
- ): ShortUrlCreation {
- $shortCodeLength = $input->getOption($shortCodeLengthField) ?? $options->defaultShortCodesLength;
- return ShortUrlCreation::fromRawData([
- ...$this->getCommonData($input),
- ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption($customSlugField),
- ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
- ShortUrlInputFilter::PATH_PREFIX => $input->getOption($pathPrefixField),
- ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption($findIfExistsField),
- ShortUrlInputFilter::DOMAIN => $input->getOption($domainField),
- ], $options);
- }
-
- private function getCommonData(InputInterface $input): array
- {
- $longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl');
- $data = [ShortUrlInputFilter::LONG_URL => $longUrl];
-
- // Avoid setting arguments that were not explicitly provided.
- // This is important when editing short URLs and should not make a difference when creating.
- if (ShortUrlDataOption::VALID_SINCE->wasProvided($input)) {
- $data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since');
- }
- if (ShortUrlDataOption::VALID_UNTIL->wasProvided($input)) {
- $data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until');
- }
- if (ShortUrlDataOption::MAX_VISITS->wasProvided($input)) {
- $maxVisits = $input->getOption('max-visits');
- $data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
- }
- if ($this->tagsOption->exists($input)) {
- $data[ShortUrlInputFilter::TAGS] = $this->tagsOption->get($input);
- }
- if (ShortUrlDataOption::TITLE->wasProvided($input)) {
- $data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
- }
- if (ShortUrlDataOption::CRAWLABLE->wasProvided($input)) {
- $data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable');
- }
- if (ShortUrlDataOption::NO_FORWARD_QUERY->wasProvided($input)) {
- $data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query');
- }
-
- return $data;
- }
-}
diff --git a/module/CLI/src/Input/ShortUrlDataOption.php b/module/CLI/src/Input/ShortUrlDataOption.php
deleted file mode 100644
index 4d8b582e..00000000
--- a/module/CLI/src/Input/ShortUrlDataOption.php
+++ /dev/null
@@ -1,39 +0,0 @@
- 's',
- self::VALID_UNTIL => 'u',
- self::MAX_VISITS => 'm',
- self::TITLE => null,
- self::CRAWLABLE => 'r',
- self::NO_FORWARD_QUERY => 'w',
- };
- }
-
- public function wasProvided(InputInterface $input): bool
- {
- $option = sprintf('--%s', $this->value);
- $shortcut = $this->shortcut();
-
- return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]);
- }
-}
diff --git a/module/CLI/src/Input/ShortUrlIdentifierInput.php b/module/CLI/src/Input/ShortUrlIdentifierInput.php
deleted file mode 100644
index 46ac79da..00000000
--- a/module/CLI/src/Input/ShortUrlIdentifierInput.php
+++ /dev/null
@@ -1,34 +0,0 @@
-addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc)
- ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc);
- }
-
- public function shortCode(InputInterface $input): string|null
- {
- return $input->getArgument('shortCode');
- }
-
- public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier
- {
- $shortCode = $input->getArgument('shortCode');
- $domain = $input->getOption('domain');
-
- return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
- }
-}
diff --git a/module/CLI/src/Input/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php
deleted file mode 100644
index 453b31a2..00000000
--- a/module/CLI/src/Input/StartDateOption.php
+++ /dev/null
@@ -1,30 +0,0 @@
-dateOption = new DateOption($command, 'start-date', 's', sprintf(
- 'Allows to filter %s, returning only those older than provided date.',
- $descriptionHint,
- ));
- }
-
- public function get(InputInterface $input, OutputInterface $output): Chronos|null
- {
- return $this->dateOption->get($input, $output);
- }
-}
diff --git a/module/CLI/src/Input/TagsOption.php b/module/CLI/src/Input/TagsOption.php
deleted file mode 100644
index ff02a735..00000000
--- a/module/CLI/src/Input/TagsOption.php
+++ /dev/null
@@ -1,51 +0,0 @@
-addOption(
- 'tag',
- 't',
- InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
- $description,
- )
- ->addOption(
- 'tags',
- mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
- description: '[DEPRECATED] Use --tag instead',
- );
- }
-
- /**
- * Whether tags have been set or not, via `--tag`, `-t` or the deprecated `--tags`
- */
- public function exists(InputInterface $input): bool
- {
- return $input->hasParameterOption(['--tag', '-t']) || $input->hasParameterOption('--tags');
- }
-
- /**
- * @return string[]
- */
- public function get(InputInterface $input): array
- {
- // FIXME DEPRECATED Remove support for comma-separated tags in next major release
- $tags = [...$input->getOption('tag'), ...$input->getOption('tags')];
- return array_unique(flatten(array_map(splitByComma(...), $tags)));
- }
-}
diff --git a/module/CLI/src/Input/VisitsListFormat.php b/module/CLI/src/Input/VisitsListFormat.php
new file mode 100644
index 00000000..acb95301
--- /dev/null
+++ b/module/CLI/src/Input/VisitsListFormat.php
@@ -0,0 +1,18 @@
+value . '", "' . VisitsListFormat::PAGINATED->value . '" or "'
+ . VisitsListFormat::CSV->value . '")',
+ shortcut: 'f',
+ )]
+ public VisitsListFormat $format = VisitsListFormat::FULL;
+
+ public function dateRange(): DateRange
+ {
+ return buildDateRange(
+ startDate: normalizeOptionalDate($this->startDate),
+ endDate: normalizeOptionalDate($this->endDate),
+ );
+ }
+}
diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php
index baab9c9e..635bb48f 100644
--- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php
+++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php
@@ -24,6 +24,7 @@ use function max;
use function min;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function Shlinkio\Shlink\Core\enumValues;
+use function Shlinkio\Shlink\Core\normalizeDate;
use function sprintf;
use function str_pad;
use function strlen;
@@ -122,7 +123,13 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
),
RedirectConditionType::GEOLOCATION_CITY_NAME => RedirectCondition::forGeolocationCityName(
$this->askMandatory('City name to match?', $io),
- )
+ ),
+ RedirectConditionType::BEFORE_DATE => RedirectCondition::forBeforeDate(
+ normalizeDate($this->askMandatory('Date to match?', $io)),
+ ),
+ RedirectConditionType::AFTER_DATE => RedirectCondition::forAfterDate(
+ normalizeDate($this->askMandatory('Date to match?', $io)),
+ ),
};
$continue = $io->confirm('Do you want to add another condition?');
diff --git a/module/CLI/src/Util/PhpProcessRunner.php b/module/CLI/src/Util/PhpProcessRunner.php
new file mode 100644
index 00000000..47f8161c
--- /dev/null
+++ b/module/CLI/src/Util/PhpProcessRunner.php
@@ -0,0 +1,26 @@
+phpBinary = $phpFinder->find(includeArgs: false) ?: 'php';
+ }
+
+ public function run(OutputInterface $output, array $cmd): void
+ {
+ $this->wrappedProcessRunner->run($output, [$this->phpBinary, ...$cmd]);
+ }
+}
diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php
index af9577ea..7e650f9d 100644
--- a/module/CLI/src/Util/ProcessRunner.php
+++ b/module/CLI/src/Util/ProcessRunner.php
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Closure;
-use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
+use Shlinkio\Shlink\CLI\Command\Util\LockConfig;
use Symfony\Component\Console\Helper\DebugFormatterHelper;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
@@ -24,7 +24,7 @@ class ProcessRunner implements ProcessRunnerInterface
{
$this->createProcess = $createProcess !== null
? $createProcess(...)
- : static fn (array $cmd) => new Process($cmd, timeout: LockedCommandConfig::DEFAULT_TTL);
+ : static fn (array $cmd) => new Process($cmd, timeout: LockConfig::DEFAULT_TTL);
}
public function run(OutputInterface $output, array $cmd): void
diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php
index 184780d7..b6bf1403 100644
--- a/module/CLI/test/ApiKey/RoleResolverTest.php
+++ b/module/CLI/test/ApiKey/RoleResolverTest.php
@@ -9,13 +9,12 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
+use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
-use Shlinkio\Shlink\Rest\ApiKey\Role;
-use Symfony\Component\Console\Input\InputInterface;
class RoleResolverTest extends TestCase
{
@@ -30,11 +29,10 @@ class RoleResolverTest extends TestCase
#[Test, DataProvider('provideRoles')]
public function properRolesAreResolvedBasedOnInput(
- callable $createInput,
+ ApiKeyInput $input,
array $expectedRoles,
int $expectedDomainCalls,
): void {
- $input = $createInput($this);
$this->domainService->expects($this->exactly($expectedDomainCalls))->method('getOrCreate')->with(
'example.com',
)->willReturn(self::domainWithId(Domain::withAuthority('example.com')));
@@ -47,55 +45,46 @@ class RoleResolverTest extends TestCase
public static function provideRoles(): iterable
{
$domain = self::domainWithId(Domain::withAuthority('example.com'));
- $buildInput = static fn (array $definition) => function (TestCase $test) use ($definition): InputInterface {
- $returnMap = [];
- foreach ($definition as $param => $returnValue) {
- $returnMap[] = [$param, $returnValue];
- }
-
- $input = $test->createStub(InputInterface::class);
- $input->method('getOption')->willReturnMap($returnMap);
-
- return $input;
- };
yield 'no roles' => [
- $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => false]),
+ new ApiKeyInput(),
[],
0,
];
yield 'domain role only' => [
- $buildInput(
- [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => false],
- ),
+ (function (): ApiKeyInput {
+ $input = new ApiKeyInput();
+ $input->domain = 'example.com';
+
+ return $input;
+ })(),
[RoleDefinition::forDomain($domain)],
1,
];
- yield 'false domain role' => [
- $buildInput([Role::DOMAIN_SPECIFIC->paramName() => false]),
- [],
- 0,
- ];
- yield 'true domain role' => [
- $buildInput([Role::DOMAIN_SPECIFIC->paramName() => true]),
- [],
- 0,
- ];
- yield 'string array domain role' => [
- $buildInput([Role::DOMAIN_SPECIFIC->paramName() => ['foo', 'bar']]),
- [],
- 0,
- ];
yield 'author role only' => [
- $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => true]),
+ (function (): ApiKeyInput {
+ $input = new ApiKeyInput();
+ $input->authorOnly = true;
+
+ return $input;
+ })(),
[RoleDefinition::forAuthoredShortUrls()],
0,
];
- yield 'both roles' => [
- $buildInput(
- [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => true],
- ),
- [RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)],
+ yield 'all roles' => [
+ (function (): ApiKeyInput {
+ $input = new ApiKeyInput();
+ $input->domain = 'example.com';
+ $input->authorOnly = true;
+ $input->noOrphanVisits = true;
+
+ return $input;
+ })(),
+ [
+ RoleDefinition::forAuthoredShortUrls(),
+ RoleDefinition::forDomain($domain),
+ RoleDefinition::forNoOrphanVisits(),
+ ],
1,
];
}
@@ -103,13 +92,10 @@ class RoleResolverTest extends TestCase
#[Test]
public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void
{
- $input = $this->createStub(InputInterface::class);
- $input
- ->method('getOption')
- ->willReturnMap([
- [Role::DOMAIN_SPECIFIC->paramName(), 'default.com'],
- [Role::AUTHORED_SHORT_URLS->paramName(), null],
- ]);
+ $input = new ApiKeyInput();
+ $input->domain = 'default.com';
+
+ $this->domainService->expects($this->never())->method('getOrCreate');
$this->expectException(InvalidRoleConfigException::class);
diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php
index 85918305..3a6c93b5 100644
--- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php
+++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php
@@ -31,12 +31,9 @@ class DisableKeyCommandTest extends TestCase
public function providedApiKeyIsDisabled(): void
{
$apiKey = 'abcd1234';
- $this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey);
- $this->apiKeyService->expects($this->never())->method('disableByName');
+ $this->apiKeyService->expects($this->once())->method('disableByName')->with($apiKey);
- $exitCode = $this->commandTester->execute([
- 'key-or-name' => $apiKey,
- ]);
+ $exitCode = $this->commandTester->execute(['name' => $apiKey]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
@@ -44,55 +41,15 @@ class DisableKeyCommandTest extends TestCase
}
#[Test]
- public function providedApiKeyIsDisabledByName(): void
- {
- $name = 'the key to delete';
- $this->apiKeyService->expects($this->once())->method('disableByName')->with($name);
- $this->apiKeyService->expects($this->never())->method('disableByKey');
-
- $exitCode = $this->commandTester->execute([
- 'key-or-name' => $name,
- '--by-name' => true,
- ]);
- $output = $this->commandTester->getDisplay();
-
- self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
- self::assertEquals(Command::SUCCESS, $exitCode);
- }
-
- #[Test]
- public function errorIsReturnedIfDisableByKeyThrowsException(): void
+ public function errorIsReturnedIfDisableByNameThrowsException(): void
{
$apiKey = 'abcd1234';
$expectedMessage = 'API key "abcd1234" does not exist.';
- $this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey)->willThrowException(
+ $this->apiKeyService->expects($this->once())->method('disableByName')->with($apiKey)->willThrowException(
new InvalidArgumentException($expectedMessage),
);
- $this->apiKeyService->expects($this->never())->method('disableByName');
- $exitCode = $this->commandTester->execute([
- 'key-or-name' => $apiKey,
- ]);
- $output = $this->commandTester->getDisplay();
-
- self::assertStringContainsString($expectedMessage, $output);
- self::assertEquals(Command::FAILURE, $exitCode);
- }
-
- #[Test]
- public function errorIsReturnedIfDisableByNameThrowsException(): void
- {
- $name = 'the key to delete';
- $expectedMessage = 'API key "the key to delete" does not exist.';
- $this->apiKeyService->expects($this->once())->method('disableByName')->with($name)->willThrowException(
- new InvalidArgumentException($expectedMessage),
- );
- $this->apiKeyService->expects($this->never())->method('disableByKey');
-
- $exitCode = $this->commandTester->execute([
- 'key-or-name' => $name,
- '--by-name' => true,
- ]);
+ $exitCode = $this->commandTester->execute(['name' => $apiKey]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($expectedMessage, $output);
@@ -103,7 +60,6 @@ class DisableKeyCommandTest extends TestCase
public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void
{
$this->apiKeyService->expects($this->never())->method('disableByName');
- $this->apiKeyService->expects($this->never())->method('disableByKey');
$this->apiKeyService->expects($this->never())->method('listKeys');
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
@@ -121,7 +77,6 @@ class DisableKeyCommandTest extends TestCase
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
]);
- $this->apiKeyService->expects($this->never())->method('disableByKey');
$this->commandTester->setInputs([$name]);
$exitCode = $this->commandTester->execute([]);
diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php
index 849ea0cf..8b62a549 100644
--- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php
+++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php
@@ -25,7 +25,7 @@ class GenerateKeyCommandTest extends TestCase
protected function setUp(): void
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
- $roleResolver = $this->createMock(RoleResolverInterface::class);
+ $roleResolver = $this->createStub(RoleResolverInterface::class);
$roleResolver->method('determineRoles')->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService, $roleResolver);
diff --git a/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php
index d8c5f07f..2fe4fb9d 100644
--- a/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php
+++ b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php
@@ -9,8 +9,6 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\RenameApiKeyCommand;
use Shlinkio\Shlink\Core\Model\Renaming;
-use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
-use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
@@ -32,11 +30,6 @@ class RenameApiKeyCommandTest extends TestCase
$oldName = 'old name';
$newName = 'new name';
- $this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([
- ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')),
- ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $oldName)),
- ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
- ]);
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
Renaming::fromNames($oldName, $newName),
);
@@ -53,7 +46,6 @@ class RenameApiKeyCommandTest extends TestCase
$oldName = 'old name';
$newName = 'new name';
- $this->apiKeyService->expects($this->never())->method('listKeys');
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
Renaming::fromNames($oldName, $newName),
);
@@ -70,7 +62,6 @@ class RenameApiKeyCommandTest extends TestCase
$oldName = 'old name';
$newName = 'new name';
- $this->apiKeyService->expects($this->never())->method('listKeys');
$this->apiKeyService->expects($this->once())->method('renameApiKey')->with(
Renaming::fromNames($oldName, $newName),
);
diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php
index 8fd34fd2..4ea9dc32 100644
--- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php
+++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php
@@ -16,6 +16,7 @@ use Exception;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
@@ -24,45 +25,41 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\SharedLockInterface;
-use Symfony\Component\Process\PhpExecutableFinder;
class CreateDatabaseCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & ProcessRunnerInterface $processHelper;
private MockObject & Connection $regularConn;
- private MockObject & ClassMetadataFactory $metadataFactory;
+ private Stub & ClassMetadataFactory $metadataFactory;
/** @var MockObject&AbstractSchemaManager */
private MockObject & AbstractSchemaManager $schemaManager;
- private MockObject & Driver $driver;
+ private Stub & Driver $driver;
protected function setUp(): void
{
- $locker = $this->createMock(LockFactory::class);
- $lock = $this->createMock(SharedLockInterface::class);
+ $locker = $this->createStub(LockFactory::class);
+ $lock = $this->createStub(SharedLockInterface::class);
$lock->method('acquire')->willReturn(true);
$locker->method('createLock')->willReturn($lock);
- $phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
- $phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php');
-
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
$this->schemaManager = $this->createMock(AbstractSchemaManager::class);
$this->regularConn = $this->createMock(Connection::class);
$this->regularConn->method('createSchemaManager')->willReturn($this->schemaManager);
- $this->driver = $this->createMock(Driver::class);
+ $this->driver = $this->createStub(Driver::class);
$this->regularConn->method('getDriver')->willReturn($this->driver);
- $this->metadataFactory = $this->createMock(ClassMetadataFactory::class);
- $em = $this->createMock(EntityManagerInterface::class);
+ $this->metadataFactory = $this->createStub(ClassMetadataFactory::class);
+ $em = $this->createStub(EntityManagerInterface::class);
$em->method('getConnection')->willReturn($this->regularConn);
$em->method('getMetadataFactory')->willReturn($this->metadataFactory);
- $noDbNameConn = $this->createMock(Connection::class);
+ $noDbNameConn = $this->createStub(Connection::class);
$noDbNameConn->method('createSchemaManager')->willReturn($this->schemaManager);
- $command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn);
+ $command = new CreateDatabaseCommand($locker, $this->processHelper, $em, $noDbNameConn);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
@@ -70,13 +67,13 @@ class CreateDatabaseCommandTest extends TestCase
public function successMessageIsPrintedIfDatabaseAlreadyExists(): void
{
$this->regularConn->expects($this->never())->method('getParams');
- $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
$metadataMock = $this->createMock(ClassMetadata::class);
$metadataMock->expects($this->once())->method('getTableName')->willReturn('foo_table');
$this->metadataFactory->method('getAllMetadata')->willReturn([$metadataMock]);
$this->schemaManager->expects($this->never())->method('createDatabase');
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']);
+ $this->processHelper->expects($this->never())->method('run');
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
@@ -87,13 +84,14 @@ class CreateDatabaseCommandTest extends TestCase
#[Test]
public function databaseIsCreatedIfItDoesNotExist(): void
{
- $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
+ $this->driver->method('getDatabasePlatform')->willReturn($this->createStub(AbstractPlatform::class));
$shlinkDatabase = 'shlink_database';
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
$this->metadataFactory->method('getAllMetadata')->willReturn([]);
$this->schemaManager->expects($this->once())->method('createDatabase')->with($shlinkDatabase);
$this->schemaManager->expects($this->once())->method('listTableNames')->willThrowException(new Exception(''));
+ $this->processHelper->expects($this->once())->method('run');
$this->commandTester->execute([]);
}
@@ -102,17 +100,16 @@ class CreateDatabaseCommandTest extends TestCase
public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void
{
$this->regularConn->expects($this->never())->method('getParams');
- $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
+ $this->driver->method('getDatabasePlatform')->willReturn($this->createStub(AbstractPlatform::class));
- $metadata = $this->createMock(ClassMetadata::class);
+ $metadata = $this->createStub(ClassMetadata::class);
$metadata->method('getTableName')->willReturn('shlink_table');
$this->metadataFactory->method('getAllMetadata')->willReturn([$metadata]);
$this->schemaManager->expects($this->never())->method('createDatabase');
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn($tables);
$this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [
- '/usr/local/bin/php',
- CreateDatabaseCommand::DOCTRINE_SCRIPT,
- CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
+ CreateDatabaseCommand::SCRIPT,
+ CreateDatabaseCommand::COMMAND,
'--no-interaction',
]);
diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php
index a9fc07ad..b2987c6e 100644
--- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php
+++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php
@@ -14,7 +14,6 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\SharedLockInterface;
-use Symfony\Component\Process\PhpExecutableFinder;
class MigrateDatabaseCommandTest extends TestCase
{
@@ -23,17 +22,14 @@ class MigrateDatabaseCommandTest extends TestCase
protected function setUp(): void
{
- $locker = $this->createMock(LockFactory::class);
- $lock = $this->createMock(SharedLockInterface::class);
+ $locker = $this->createStub(LockFactory::class);
+ $lock = $this->createStub(SharedLockInterface::class);
$lock->method('acquire')->willReturn(true);
$locker->method('createLock')->willReturn($lock);
- $phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
- $phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php');
-
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
- $command = new MigrateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder);
+ $command = new MigrateDatabaseCommand($locker, $this->processHelper);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
@@ -41,9 +37,8 @@ class MigrateDatabaseCommandTest extends TestCase
public function migrationsCommandIsRunWithProperVerbosity(): void
{
$this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [
- '/usr/local/bin/php',
- MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
- MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
+ MigrateDatabaseCommand::SCRIPT,
+ MigrateDatabaseCommand::COMMAND,
'--no-interaction',
]);
diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php
index e174a3b0..9b4e2f3f 100644
--- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php
+++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php
@@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
-use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
+use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
@@ -24,16 +24,11 @@ class GetDomainVisitsCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
- private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
- $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
-
- $this->commandTester = CliTestUtils::testerForCommand(
- new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier),
- );
+ $this->commandTester = CliTestUtils::testerForCommand(new GetDomainVisitsCommand($this->visitsHelper));
}
#[Test]
@@ -41,29 +36,29 @@ class GetDomainVisitsCommandTest extends TestCase
{
$shortUrl = ShortUrl::createFake();
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
- VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
+ VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$domain = 's.test';
$this->visitsHelper->expects($this->once())->method('visitsForDomain')->with(
$domain,
$this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
- $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
- 'the_short_url',
- );
$this->commandTester->execute(['domain' => $domain]);
$output = $this->commandTester->getDisplay();
+ $type = VisitType::VALID_SHORT_URL->value;
self::assertEquals(
+ // phpcs:disable Generic.Files.LineLength
<<