Merge pull request #2570 from shlinkio/develop

Release 5.0.0
This commit is contained in:
Alejandro Celaya
2026-01-09 17:23:46 +01:00
committed by GitHub
191 changed files with 2027 additions and 3299 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 `<meta charset>` or `<meta http-equiv="Content-Type">`.
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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -133,9 +133,6 @@
},
"/{shortCode}/track": {
"$ref": "paths/{shortCode}_track.json"
},
"/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json"
}
}
}

View File

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

View File

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

View File

@@ -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<RoleDefinition>
*/
public function determineRoles(InputInterface $input): iterable;
public function determineRoles(ApiKeyInput $input): iterable;
}

View File

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

View File

@@ -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: <<<HELP
The <info>%command.name%</info> command allows you to disable an existing API key, via its name or the
plain-text key.
The <info>%command.name%</info> 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.
<info>%command.full_name%</info>
You can optionally pass the API key name to be disabled. In that case <comment>--by-name</comment> 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:
<info>%command.full_name% the_key_name --by-name</info>
You can pass the plain-text key to be disabled, but that is <options=bold>DEPRECATED</>. In next major version,
the argument will always be assumed to be the name:
<info>%command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143</info>
<info>%command.full_name% the_key_name</info>
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());

View File

@@ -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 = <<<HELP
#[AsCommand(
name: GenerateKeyCommand::NAME,
description: 'Generate a new valid API key',
help: <<<HELP
The <info>%command.name%</info> generates a new valid API key.
<info>%command.full_name%</info>
@@ -49,62 +36,26 @@ class GenerateKeyCommand extends Command
You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
<info>%command.full_name% --expiration-date 2020-01-01</info>
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: <info>%command.full_name% --{$authorOnly}</info>
* Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info>
* Cannot see orphan visits: <info>%command.full_name% --{$noOrphanVisits}</info>
* All: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits}</info>
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);

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api\Input;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Symfony\Component\Console\Attribute\Option;
final class ApiKeyInput
{
#[Option('The unique name by which this API key will be known', shortcut: 'm')]
public string|null $name = null;
#[Option('The date in which the API key should expire. Use any valid PHP format', shortcut: 'e')]
public string|null $expirationDate = null;
/** @deprecated */
#[Option('Adds the "' . Role::AUTHORED_SHORT_URLS->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;
}

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
{
private string $phpBinary;
public function __construct(
LockFactory $locker,
private readonly ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
) {
parent::__construct($locker);
$this->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);
}
}

View File

@@ -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('<fg=blue>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;

View File

@@ -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('<fg=blue>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;

View File

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

View File

@@ -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<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$domain = $input->getArgument('domain');
return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
return self::SUCCESS;
}
}

View File

@@ -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 <<<EOL
* Base URL: {$baseUrl}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Integration;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
@@ -17,10 +16,12 @@ use Throwable;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
#[AsCommand(
name: MatomoSendVisitsCommand::NAME,
description: 'Send existing visits to the configured matomo instance',
help: <<<HELP
This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
@@ -56,14 +57,6 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
parent::__construct();
}
protected function configure(): void
{
$this->setDescription(sprintf(
'%sSend existing visits to the configured matomo instance',
$this->matomoEnabled ? '' : '<comment>[MATOMO INTEGRATION DISABLED]</comment> ',
));
}
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()) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
return [];
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
/**
* Data used for short URL creation
*/
final class ShortUrlCreationInput
{
#[Argument('The long URL to set'), Ask('Which URL do you want to shorten?')]
public string $longUrl;
#[MapInput]
public ShortUrlDataInput $commonData;
#[Option('The domain to which this short URL will be attached', shortcut: 'd')]
public string|null $domain = null;
#[Option('If provided, this slug will be used instead of generating a short code', shortcut: 'c')]
public string|null $customSlug = null;
#[Option('The length for generated short code (it will be ignored if --custom-slug was provided)', shortcut: 'l')]
public int|null $shortCodeLength = null;
#[Option('Prefix to prepend before the generated short code or provided custom slug', shortcut: 'p')]
public string|null $pathPrefix = null;
#[Option(
'This will force existing matching URL to be returned if found, instead of creating a new one',
shortcut: 'f',
)]
public bool $findIfExists = false;
public function toShortUrlCreation(UrlShortenerOptions $options): ShortUrlCreation
{
$shortCodeLength = $this->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);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Attribute\Option;
use function array_unique;
/**
* Common input used for short URL creation and edition
*/
final class ShortUrlDataInput
{
/** @var string[]|null */
#[Option('Tags to apply to the short URL', name: 'tag', shortcut: 't')]
public array|null $tags = null;
#[Option(
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found',
shortcut: 's',
)]
public string|null $validSince = null;
#[Option(
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found',
shortcut: 'u',
)]
public string|null $validUntil = null;
#[Option('This will limit the number of visits for this short URL', shortcut: 'm')]
public int|null $maxVisits = null;
#[Option('A descriptive title for the short URL')]
public string|null $title = null;
#[Option('Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt', shortcut: 'r')]
public bool|null $crawlable = null;
#[Option(
'Disables the forwarding of the query string to the long URL, when the short URL is visited',
shortcut: 'w',
)]
public bool|null $noForwardQuery = null;
public function toArray(): array
{
$data = [];
// Avoid setting arguments that were not explicitly provided.
// This is important when editing short URLs and should not make a difference when creating.
if ($this->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;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\CLI\Input\InputUtils;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Output\OutputInterface;
use function array_unique;
/**
* Input arguments and options for short-url:list command
* @see ListShortUrlsCommand
*/
final class ShortUrlsParamsInput
{
#[Option('The first page to list (10 items per page unless "--all" is provided).', shortcut: 'p')]
public int $page = 1;
#[Option(
'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.',
)]
public bool $all = false;
#[Option('Only return short URLs older than this date', shortcut: 's')]
public string|null $startDate = null;
#[Option('Only return short URLs newer than this date', shortcut: 'e')]
public string|null $endDate = null;
#[Option('A query used to filter results by searching for it on the longUrl and shortCode fields', shortcut: 'st')]
public string|null $searchTerm = null;
#[Option(
'Used to filter results by domain. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword to filter by default domain',
shortcut: 'd',
)]
public string|null $domain = null;
/** @var string[]|null */
#[Option('A list of tags that short URLs need to include', name: 'tag', shortcut: 't')]
public array|null $tags = null;
#[Option('If --tag is provided, returns only short URLs including ALL of them')]
public bool $tagsAll = false;
/** @var string[]|null */
#[Option('A list of tags that short URLs should NOT include', name: 'exclude-tag', shortcut: 'et')]
public array|null $excludeTags = null;
#[Option('If --exclude-tag is provided, returns only short URLs not including ANY of them')]
public bool $excludeTagsAll = false;
#[Option('Excludes short URLs which reached their max amount of visits')]
public bool $excludeMaxVisitsReached = false;
#[Option('Excludes short URLs which have a "validUntil" date in the past')]
public bool $excludePastValidUntil = false;
#[Option(
'Field name to order the list by. Set the dir by optionally passing ASC or DESC after "-": --orderBy=tags-ASC',
shortcut: 'o',
)]
public string|null $orderBy = null;
#[Option('List only short URLs created by the API key matching provided name', shortcut: 'kn')]
public string|null $apiKeyName = null;
#[Option('Whether to display the tags or not')]
public bool $showTags = false;
#[Option(
'Whether to display the domain or not. Those belonging to default domain will have value '
. '"' . Domain::DEFAULT_AUTHORITY . '"',
)]
public bool $showDomain = false;
#[Option('Whether to display the API key name from which the URL was generated or not', shortcut: 'k')]
public bool $showApiKey = false;
public function toArray(OutputInterface $output): array
{
$data = [
ShortUrlsParamsInputFilter::PAGE => $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;
}
}

View File

@@ -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<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string>
*/
@@ -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;
}

View File

@@ -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: <info>%s</info>', $url->getLongUrl()));
$url = $this->urlResolver->resolveShortUrl($identifier);
$io->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return self::SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error($e->getMessage());

View File

@@ -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<Visit>
*/
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<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
return self::SUCCESS;
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
use function sprintf;
abstract class AbstractLockedCommand extends Command
{
public function __construct(private readonly LockFactory $locker)
{
parent::__construct();
}
final protected function execute(InputInterface $input, OutputInterface $output): int
{
$lockConfig = $this->getLockConfig();
$lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking);
if (! $lock->acquire($lockConfig->isBlocking)) {
$output->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $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;
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
use function sprintf;
class CommandUtils
{
/**
* Displays a warning and confirmation message before running a callback. If the response to the confirmation is
* positive, the callback is executed normally.
*
* @param callable(): int $callback
*/
public static function executeWithWarning(string $warning, SymfonyStyle $io, callable $callback): int
{
$io->warning($warning);
if (! $io->confirm('<comment>Do you want to proceed?</comment>', 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('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
);
return Command::INVALID;
}
try {
return $callback();
} finally {
$lock->release();
}
}
}

View File

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

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractDeleteVisitsCommand extends Command
{
final protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (! $this->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('<comment>Continue deleting visits?</comment>', false);
}
abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): int;
abstract protected function getWarningMessage(): string;
}

View File

@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function array_map;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
abstract class AbstractVisitsListCommand extends Command
{
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
$this->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<Visit> $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<Visit>
*/
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
/**
* @return array<string, string>
*/
abstract protected function mapExtraFields(Visit $visit): array;
}

View File

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

View File

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

View File

@@ -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<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
return self::SUCCESS;
}
}

View File

@@ -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<Visit>
*/
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<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
return ['type' => $visit->type->value];
return self::SUCCESS;
}
}

View File

@@ -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(
'<comment>The <fg=yellow;options=bold>--all</> flag has no effect on its own. You have to provide it '
. 'together with <fg=yellow;options=bold>--retry</>.</comment>',
);
@@ -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);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use League\Csv\Writer;
use Shlinkio\Shlink\CLI\Input\VisitsListFormat;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Symfony\Component\Console\Output\OutputInterface;
use function array_map;
class VisitsCommandUtils
{
/**
* @param Paginator<Visit> $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<Visit> $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<Visit> $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<Visit> $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];
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
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 Throwable;
use function is_string;
use function sprintf;
readonly class DateOption
{
public function __construct(private Command $command, private string $name, string $shortcut, string $description)
{
$command->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(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$this->name,
$value,
));
if ($output->isVeryVerbose()) {
$this->command->getApplication()?->renderThrowable($e, $output);
}
return null;
}
}
}

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
final readonly class DomainOption
{
private const string NAME = 'domain';
public function __construct(Command $command, string $description)
{
$command->addOption(
name: self::NAME,
shortcut: 'd',
mode: InputOption::VALUE_REQUIRED,
description: $description,
);
}
public function get(InputInterface $input): string|null
{
return $input->getOption(self::NAME);
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
final readonly class EndDateOption
{
private DateOption $dateOption;
public function __construct(Command $command, string $descriptionHint)
{
$this->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);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
final class InputUtils
{
/**
* Process a date provided via input params, and format it as ATOM.
* A warning is printed if the date cannot be parsed, returning `null` in that case.
*/
public static function processDate(string $name, string|null $value, OutputInterface $output): string|null
{
if ($value === null || $value === '') {
return null;
}
try {
return normalizeOptionalDate($value)->toAtomString();
} catch (Throwable) {
$output->writeln(sprintf(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$name,
$value,
));
return null;
}
}
}

View File

@@ -1,128 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
final readonly class ShortUrlDataInput
{
private readonly TagsOption $tagsOption;
public function __construct(Command $command, private bool $longUrlAsOption = false)
{
if ($longUrlAsOption) {
$command->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;
}
}

View File

@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
enum ShortUrlDataOption: string
{
case VALID_SINCE = 'valid-since';
case VALID_UNTIL = 'valid-until';
case MAX_VISITS = 'max-visits';
case TITLE = 'title';
case CRAWLABLE = 'crawlable';
case NO_FORWARD_QUERY = 'no-forward-query';
public function shortcut(): string|null
{
return match ($this) {
self::VALID_SINCE => '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)]);
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
final readonly class ShortUrlIdentifierInput
{
public function __construct(Command $command, string $shortCodeDesc, string $domainDesc)
{
$command
->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);
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
final readonly class StartDateOption
{
private DateOption $dateOption;
public function __construct(Command $command, string $descriptionHint)
{
$this->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);
}
}

View File

@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use function array_map;
use function array_unique;
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function Shlinkio\Shlink\Core\splitByComma;
readonly class TagsOption
{
public function __construct(Command $command, string $description)
{
$command
->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)));
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Shlinkio\Shlink\CLI\Input;
enum VisitsListFormat: string
{
/** Load and dump all visits at once, in a human-friendly format */
case FULL = 'full';
/**
* Load and dump visits in 1000-visit chunks, in a human-friendly format.
* This format is recommended over `default` for large number of visits, to avoid running out of memory.
*/
case PAGINATED = 'paginated';
/** Load and dump visits in chunks, in CSV format */
case CSV = 'csv';
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Common\Util\DateRange;
use Symfony\Component\Console\Attribute\Option;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
class VisitsListInput
{
#[Option('Only return visits older than this date', shortcut: 's')]
public string|null $startDate = null;
#[Option('Only return visits newer than this date', shortcut: 'e')]
public string|null $endDate = null;
#[Option(
'Output format ("' . VisitsListFormat::FULL->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),
);
}
}

View File

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

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder;
/**
* Wraps another process manager prefixing any command run with current PHP binary
*/
readonly class PhpProcessRunner implements ProcessRunnerInterface
{
private string $phpBinary;
public function __construct(private ProcessRunnerInterface $wrappedProcessRunner, PhpExecutableFinder $phpFinder)
{
$this->phpBinary = $phpFinder->find(includeArgs: false) ?: 'php';
}
public function run(OutputInterface $output, array $cmd): void
{
$this->wrappedProcessRunner->run($output, [$this->phpBinary, ...$cmd]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<SQLitePlatform> */
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',
]);

View File

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

View File

@@ -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
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT,
// phpcs:enable
$output,
);
}

View File

@@ -41,8 +41,8 @@ class ListDomainsCommandTest extends TestCase
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions(
invalidShortUrl: 'https://foo.com/default/invalid',
baseUrl: 'https://foo.com/default/base',
invalidShortUrlRedirect: 'https://foo.com/default/invalid',
baseUrlRedirect: 'https://foo.com/default/base',
)),
DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')),
DomainItem::forNonDefaultDomain($bazDomain),

View File

@@ -3,6 +3,7 @@
namespace ShlinkioTest\Shlink\CLI\Command\Integration;
use Exception;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
@@ -27,6 +28,8 @@ class MatomoSendVisitsCommandTest extends TestCase
#[Test]
public function warningDisplayedIfIntegrationIsNotEnabled(): void
{
$this->visitSender->expects($this->never())->method('sendVisitsInDateRange');
[$output, $exitCode] = $this->executeCommand(matomoEnabled: false);
self::assertStringContainsString('Matomo integration is not enabled in this Shlink instance', $output);
@@ -38,7 +41,7 @@ class MatomoSendVisitsCommandTest extends TestCase
#[TestWith([false], 'not interactive')]
public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void
{
$this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult());
$this->visitSender->expects($this->once())->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult());
[$output] = $this->executeCommand(['y'], ['interactive' => $interactive]);
@@ -80,7 +83,7 @@ class MatomoSendVisitsCommandTest extends TestCase
#[Test]
public function printsResultOfSendingVisits(): void
{
$this->visitSender->method('sendVisitsInDateRange')->willReturnCallback(
$this->visitSender->expects($this->once())->method('sendVisitsInDateRange')->willReturnCallback(
function (DateRange $_, MatomoSendVisitsCommand $command): SendVisitsResult {
// Call it a few times for an easier match of its result in the command putput
$command->success(0);
@@ -99,7 +102,7 @@ class MatomoSendVisitsCommandTest extends TestCase
self::assertStringContainsString('...E.E', $output);
}
#[Test]
#[Test, AllowMockObjectsWithoutExpectations]
#[TestWith([[], 'All time'])]
#[TestWith([['--since' => '2023-05-01'], 'Since 2023-05-01 00:00:00'])]
#[TestWith([['--until' => '2023-05-01'], 'Until 2023-05-01 00:00:00'])]
@@ -114,6 +117,7 @@ class MatomoSendVisitsCommandTest extends TestCase
}
/**
* @param list<string> $input
* @return array{string, int, MatomoSendVisitsCommand}
*/
private function executeCommand(

View File

@@ -48,7 +48,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$this->ruleService->expects($this->never())->method('saveRulesForShortUrl');
$this->ruleHandler->expects($this->never())->method('manageRules');
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::FAILURE, $exitCode);
@@ -67,7 +67,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$this->ruleHandler->expects($this->once())->method('manageRules')->willReturn(null);
$this->ruleService->expects($this->never())->method('saveRulesForShortUrl');
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode);
@@ -86,7 +86,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$this->ruleHandler->expects($this->once())->method('manageRules')->willReturn([]);
$this->ruleService->expects($this->once())->method('saveRulesForShortUrl')->with($shortUrl, []);
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode);

View File

@@ -9,6 +9,7 @@ use PHPUnit\Framework\Assert;
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\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
@@ -27,12 +28,12 @@ class CreateShortUrlCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & UrlShortenerInterface $urlShortener;
private MockObject & ShortUrlStringifierInterface $stringifier;
private Stub & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->urlShortener = $this->createMock(UrlShortenerInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->stringifier = $this->createStub(ShortUrlStringifierInterface::class);
$command = new CreateShortUrlCommand(
$this->urlShortener,
@@ -49,12 +50,10 @@ class CreateShortUrlCommandTest extends TestCase
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl),
);
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url',
);
$this->stringifier->method('stringify')->with($shortUrl)->willReturn('stringified_short_url');
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
'long-url' => 'http://domain.com/foo/bar',
'--max-visits' => '3',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
@@ -64,6 +63,19 @@ class CreateShortUrlCommandTest extends TestCase
self::assertStringNotContainsString('but the real-time updates cannot', $output);
}
#[Test]
public function longUrlIsAskedIfNotProvided(): void
{
$shortUrl = ShortUrl::createFake();
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl),
);
$this->stringifier->method('stringify')->with($shortUrl)->willReturn('stringified_short_url');
$this->commandTester->setInputs([$shortUrl->getLongUrl()]);
$this->commandTester->execute([]);
}
#[Test]
public function providingNonUniqueSlugOutputsError(): void
{
@@ -72,7 +84,7 @@ class CreateShortUrlCommandTest extends TestCase
);
$this->stringifier->method('stringify')->willReturn('');
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$this->commandTester->execute(['long-url' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::FAILURE, $this->commandTester->getStatusCode());
@@ -89,13 +101,11 @@ class CreateShortUrlCommandTest extends TestCase
return true;
}),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url',
);
$this->stringifier->method('stringify')->with($shortUrl)->willReturn('stringified_short_url');
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
'--tags' => ['foo,bar', 'baz', 'boo,zar,baz'],
'long-url' => 'http://domain.com/foo/bar',
'--tag' => ['foo', 'bar', 'baz', 'boo', 'zar', 'baz'],
]);
$output = $this->commandTester->getDisplay();
@@ -114,7 +124,7 @@ class CreateShortUrlCommandTest extends TestCase
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
$this->stringifier->method('stringify')->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar';
$input['long-url'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($input);
self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
@@ -141,7 +151,7 @@ class CreateShortUrlCommandTest extends TestCase
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->method('stringify')->willReturn('');
$options['longUrl'] = 'http://domain.com/foo/bar';
$options['long-url'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options);
}
@@ -163,7 +173,7 @@ class CreateShortUrlCommandTest extends TestCase
);
$this->stringifier->method('stringify')->willReturn('stringified_short_url');
$this->commandTester->execute(['longUrl' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]);
$this->commandTester->execute(['long-url' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]);
$output = $this->commandTester->getDisplay();
$assert($output);

View File

@@ -39,7 +39,7 @@ class DeleteShortUrlCommandTest extends TestCase
$this->isFalse(),
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(
@@ -58,12 +58,15 @@ class DeleteShortUrlCommandTest extends TestCase
$this->isFalse(),
)->willThrowException(Exception\ShortUrlNotFoundException::fromNotFound($identifier));
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
}
/**
* @param list<string> $retryAnswer
*/
#[Test, DataProvider('provideRetryDeleteAnswers')]
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
array $retryAnswer,
@@ -85,7 +88,7 @@ class DeleteShortUrlCommandTest extends TestCase
});
$this->commandTester->setInputs($retryAnswer);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf(
@@ -115,7 +118,7 @@ class DeleteShortUrlCommandTest extends TestCase
));
$this->commandTester->setInputs(['no']);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf(

View File

@@ -27,13 +27,16 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
$this->commandTester = CliTestUtils::testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter));
}
/**
* @param list<string> $input
*/
#[Test, DataProvider('provideCancellingInputs')]
public function executionIsAbortedIfManuallyCancelled(array $input): void
{
$this->deleter->expects($this->never())->method('deleteShortUrlVisits');
$this->commandTester->setInputs($input);
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode);
@@ -64,8 +67,8 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
public static function provideErrorArgs(): iterable
{
yield 'domain' => [['shortCode' => 'foo'], 'Short URL not found for "foo"'];
yield 'no domain' => [['shortCode' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"'];
yield 'domain' => [['short-code' => 'foo'], 'Short URL not found for "foo"'];
yield 'no domain' => [['short-code' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"'];
}
#[Test]
@@ -74,7 +77,7 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
$this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willReturn(new BulkDeleteResult(5));
$this->commandTester->setInputs(['yes']);
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode);

View File

@@ -40,7 +40,7 @@ class EditShortUrlCommandTest extends TestCase
);
$this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo');
$this->commandTester->execute(['shortCode' => 'foobar']);
$this->commandTester->execute(['short-code' => 'foobar']);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
@@ -59,7 +59,7 @@ class EditShortUrlCommandTest extends TestCase
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e);
$this->stringifier->expects($this->never())->method('stringify');
$this->commandTester->execute(['shortCode' => 'foo'], ['verbosity' => $verbosity]);
$this->commandTester->execute(['short-code' => 'foo'], ['verbosity' => $verbosity]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();

View File

@@ -6,10 +6,12 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand;
use Shlinkio\Shlink\CLI\Input\VisitsListFormat;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@@ -24,7 +26,6 @@ use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetShortUrlVisitsCommandTest extends TestCase
{
@@ -47,7 +48,20 @@ class GetShortUrlVisitsCommandTest extends TestCase
new VisitsParams(DateRange::allTime()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
}
#[Test]
public function shortCodeIsAskedIfNotProvided(): void
{
$shortCode = 'abc123';
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
$this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->setInputs([$shortCode]);
$this->commandTester->execute([]);
}
#[Test]
@@ -62,39 +76,20 @@ class GetShortUrlVisitsCommandTest extends TestCase
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
'shortCode' => $shortCode,
'short-code' => $shortCode,
'--start-date' => $startDate,
'--end-date' => $endDate,
]);
}
#[Test]
public function providingInvalidDatesPrintsWarning(): void
{
$shortCode = 'abc123';
$startDate = 'foo';
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(DateRange::allTime()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
'shortCode' => $shortCode,
'--start-date' => $startDate,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(
sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate),
$output,
);
}
#[Test]
public function outputIsProperlyGenerated(): void
/**
* @param callable(Chronos $date): string $getExpectedOutput
*/
#[Test, DataProvider('provideOutput')]
public function outputIsProperlyGenerated(VisitsListFormat $format, callable $getExpectedOutput): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('bar', 'foo', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$shortCode = 'abc123';
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
@@ -102,19 +97,34 @@ class GetShortUrlVisitsCommandTest extends TestCase
$this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode, '--format' => $format->value]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+
| Referer | Date | User agent | Country | City |
+---------+---------------------------+------------+---------+--------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid |
+---------+---------------------------+------------+---------+--------+
self::assertEquals($getExpectedOutput($visit->date), $output);
}
OUTPUT,
$output,
);
public static function provideOutput(): iterable
{
yield 'regular' => [
VisitsListFormat::FULL,
// phpcs:disable Generic.Files.LineLength
static fn (Chronos $date) => <<<OUTPUT
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | valid_short_url |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT,
// phpcs:enable
];
yield 'CSV' => [
VisitsListFormat::CSV,
static fn (Chronos $date) => <<<OUTPUT
Date,"Potential bot","User agent",Referer,Country,Region,City,"Visited URL","Redirect URL",Type
{$date->toAtomString()},,bar,foo,Spain,,Madrid,,Unknown,valid_short_url
OUTPUT,
];
}
}

View File

@@ -203,25 +203,34 @@ class ListShortUrlsCommandTest extends TestCase
array $commandArgs,
int|null $page,
string|null $searchTerm,
array $tags,
array|null $tags,
string $tagsMode,
string|null $startDate = null,
string|null $endDate = null,
array $excludeTags = [],
array|null $excludeTags = null,
string $excludeTagsMode = TagsMode::ANY->value,
string|null $apiKeyName = null,
): void {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
$expectedData = [
'page' => $page,
'searchTerm' => $searchTerm,
'tags' => $tags,
'tagsMode' => $tagsMode,
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
'excludeTags' => $excludeTags,
'excludeTagsMode' => $excludeTagsMode,
'apiKeyName' => $apiKeyName,
]))->willReturn(new Paginator(new ArrayAdapter([])));
];
if ($tags !== null) {
$expectedData['tags'] = $tags;
}
if ($excludeTags !== null) {
$expectedData['excludeTags'] = $excludeTags;
}
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData(
$expectedData,
))->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->setInputs(['n']);
$this->commandTester->execute($commandArgs);
@@ -231,7 +240,7 @@ class ListShortUrlsCommandTest extends TestCase
{
yield [[], 1, null, [], TagsMode::ANY->value];
yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value];
yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value];
yield [['--tags-all' => true, '--tag' => ['foo']], 1, null, ['foo'], TagsMode::ALL->value];
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value];
yield [
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']],
@@ -270,7 +279,7 @@ class ListShortUrlsCommandTest extends TestCase
['--exclude-tag' => ['foo', 'bar'], '--exclude-tags-all' => true],
1,
null,
[],
null,
TagsMode::ANY->value,
null,
null,
@@ -306,8 +315,6 @@ class ListShortUrlsCommandTest extends TestCase
{
yield [[], null];
yield [['--order-by' => 'visits'], 'visits'];
yield [['--order-by' => 'longUrl,ASC'], 'longUrl-ASC'];
yield [['--order-by' => 'shortCode,DESC'], 'shortCode-DESC'];
yield [['--order-by' => 'title-DESC'], 'title-DESC'];
}

View File

@@ -40,11 +40,24 @@ class ResolveUrlCommandTest extends TestCase
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
)->willReturn($shortUrl);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}
#[Test]
public function shortCodeIsAskedIfNotProvided(): void
{
$shortCode = 'abc123';
$shortUrl = ShortUrl::createFake();
$this->urlResolver->expects($this->once())->method('resolveShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
)->willReturn($shortUrl);
$this->commandTester->setInputs([$shortCode]);
$this->commandTester->execute([]);
}
#[Test]
public function incorrectShortCodeOutputsErrorMessage(): void
{
@@ -55,7 +68,7 @@ class ResolveUrlCommandTest extends TestCase
ShortUrlNotFoundException::fromNotFound($identifier),
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
}

View File

@@ -26,6 +26,8 @@ class DeleteTagsCommandTest extends TestCase
#[Test]
public function errorIsReturnedWhenNoTagsAreProvided(): void
{
$this->tagService->expects($this->never())->method('deleteTags');
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();

View File

@@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand;
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 GetTagVisitsCommandTest 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 GetTagVisitsCommand($this->visitsHelper, $this->stringifier),
);
$this->commandTester = CliTestUtils::testerForCommand(new GetTagVisitsCommand($this->visitsHelper));
}
#[Test]
@@ -41,26 +36,28 @@ class GetTagVisitsCommandTest 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, '')),
);
$tag = 'abc123';
$this->visitsHelper->expects($this->once())->method('visitsForTag')->with($tag, $this->anything())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute(['tag' => $tag]);
$output = $this->commandTester->getDisplay();
$type = VisitType::VALID_SHORT_URL->value;
self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT,
// phpcs:enable
$output,
);
}

View File

@@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand;
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 GetNonOrphanVisitsCommandTest 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 GetNonOrphanVisitsCommand($this->visitsHelper, $this->stringifier),
);
$this->commandTester = CliTestUtils::testerForCommand(new GetNonOrphanVisitsCommand($this->visitsHelper));
}
#[Test]
@@ -41,25 +36,27 @@ class GetNonOrphanVisitsCommandTest 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, '')),
);
$this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$type = VisitType::VALID_SHORT_URL->value;
self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT,
// phpcs:enable
$output,
);
}

View File

@@ -38,7 +38,7 @@ class GetOrphanVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(array $args, bool $includesType): void
{
$visit = Visit::forBasePath(Visitor::fromParams('bar', 'foo', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback(
fn (OrphanVisitsParams $param) => (
@@ -48,16 +48,19 @@ class GetOrphanVisitsCommandTest extends TestCase
$this->commandTester->execute($args);
$output = $this->commandTester->getDisplay();
$type = OrphanVisitType::BASE_URL->value;
self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+----------+
| Referer | Date | User agent | Country | City | Type |
+---------+---------------------------+------------+---------+--------+----------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | base_url |
+---------+---------------------------+------------+---------+--------+----------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+----------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+----------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+--- Page 1 of 1 ---+--------+--------+-------------+--------------+----------+
OUTPUT,
// phpcs:enable
$output,
);
}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
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\Visit\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
@@ -31,26 +33,27 @@ use function sprintf;
use const PHP_EOL;
#[AllowMockObjectsWithoutExpectations]
class LocateVisitsCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & VisitLocatorInterface $visitService;
private MockObject & VisitToLocationHelperInterface $visitToLocation;
private MockObject & Lock\LockInterface $lock;
private MockObject & Command $downloadDbCommand;
private Stub & Lock\LockInterface $lock;
private Stub & Command $downloadDbCommand;
protected function setUp(): void
{
$this->visitService = $this->createMock(VisitLocatorInterface::class);
$this->visitToLocation = $this->createMock(VisitToLocationHelperInterface::class);
$locker = $this->createMock(Lock\LockFactory::class);
$this->lock = $this->createMock(Lock\SharedLockInterface::class);
$locker = $this->createStub(Lock\LockFactory::class);
$this->lock = $this->createStub(Lock\SharedLockInterface::class);
$locker->method('createLock')->willReturn($this->lock);
$command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker);
$this->downloadDbCommand = CliTestUtils::createCommandMock(DownloadGeoLiteDbCommand::NAME);
$this->downloadDbCommand = CliTestUtils::createCommandStub(DownloadGeoLiteDbCommand::NAME);
$this->commandTester = CliTestUtils::testerForCommand($command, $this->downloadDbCommand);
}
@@ -63,7 +66,7 @@ class LocateVisitsCommandTest extends TestCase
array $args,
): void {
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('', '', '1.2.3.4'));
$location = VisitLocation::fromGeolocation(Location::empty());
$location = VisitLocation::fromLocation(Location::empty());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
$this->lock->method('acquire')->willReturn(true);
@@ -81,7 +84,7 @@ class LocateVisitsCommandTest extends TestCase
->willReturnCallback($mockMethodBehavior);
$this->visitToLocation->expects(
$this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls),
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance());
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::empty());
$this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS);
$this->commandTester->setInputs(['y']);
@@ -107,7 +110,7 @@ class LocateVisitsCommandTest extends TestCase
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty());
$location = VisitLocation::fromGeolocation(Location::empty());
$location = VisitLocation::fromLocation(Location::empty());
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->once())
@@ -134,7 +137,7 @@ class LocateVisitsCommandTest extends TestCase
public function errorWhileLocatingIpIsDisplayed(): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4'));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$location = VisitLocation::fromLocation(Location::empty());
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->once())
@@ -204,6 +207,9 @@ class LocateVisitsCommandTest extends TestCase
self::assertStringContainsString('The --all flag has no effect on its own', $output);
}
/**
* @param list<string> $inputs
*/
#[Test, DataProvider('provideAbortInputs')]
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
{

View File

@@ -30,8 +30,8 @@ class ApplicationFactoryTest extends TestCase
'baz' => 'baz',
],
]);
$sm->setService('foo', CliTestUtils::createCommandMock('foo'));
$sm->setService('bar', CliTestUtils::createCommandMock('bar'));
$sm->setService('foo', CliTestUtils::createCommandStub('foo'));
$sm->setService('bar', CliTestUtils::createCommandStub('bar'));
$instance = ($this->factory)($sm);

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Input\InputUtils;
use Symfony\Component\Console\Output\OutputInterface;
class InputUtilsTest extends TestCase
{
private MockObject & OutputInterface $input;
protected function setUp(): void
{
$this->input = $this->createMock(OutputInterface::class);
}
#[Test]
#[TestWith([null], 'null')]
#[TestWith([''], 'empty string')]
public function processDateReturnsNullForEmptyDates(string|null $date): void
{
$this->input->expects($this->never())->method('writeln');
self::assertNull(InputUtils::processDate('name', $date, $this->input));
}
#[Test]
public function processDateReturnsAtomFormatedForValidDates(): void
{
$date = '2025-01-20';
$this->input->expects($this->never())->method('writeln');
self::assertEquals(Chronos::parse($date)->toAtomString(), InputUtils::processDate('name', $date, $this->input));
}
#[Test]
public function warningIsPrintedWhenDateIsInvalid(): void
{
$this->input->expects($this->once())->method('writeln')->with(
'<comment>> Ignored provided "name" since its value "invalid" is not a valid date. <</comment>',
);
self::assertNull(InputUtils::processDate('name', 'invalid', $this->input));
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\RedirectRule;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
@@ -18,8 +19,10 @@ use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Symfony\Component\Console\Style\StyleInterface;
use function Shlinkio\Shlink\Core\normalizeDate;
use function sprintf;
#[AllowMockObjectsWithoutExpectations]
class RedirectRuleHandlerTest extends TestCase
{
private RedirectRuleHandler $handler;
@@ -120,6 +123,7 @@ class RedirectRuleHandlerTest extends TestCase
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
'Country code to match?' => 'FR',
'City name to match?' => 'Los angeles',
'Date to match?' => '2016-05-01T20:34:16+02:00',
default => '',
},
);
@@ -184,6 +188,14 @@ class RedirectRuleHandlerTest extends TestCase
RedirectConditionType::GEOLOCATION_CITY_NAME,
[RedirectCondition::forGeolocationCityName('Los angeles')],
];
yield 'Before date' => [
RedirectConditionType::BEFORE_DATE,
[RedirectCondition::forBeforeDate(normalizeDate('2016-05-01T20:34:16+02:00'))],
];
yield 'After date' => [
RedirectConditionType::AFTER_DATE,
[RedirectCondition::forAfterDate(normalizeDate('2016-05-01T20:34:16+02:00'))],
];
}
#[Test]

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