mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 04:03:12 +08:00
8
.github/workflows/ci-db-tests.yml
vendored
8
.github/workflows/ci-db-tests.yml
vendored
@@ -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: |
|
||||
|
||||
8
.github/workflows/ci-tests.yml
vendored
8
.github/workflows/ci-tests.yml
vendored
@@ -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: |
|
||||
|
||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/publish-openapi-spec.yml
vendored
4
.github/workflows/publish-openapi-spec.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/publish-release.yml
vendored
10
.github/workflows/publish-release.yml
vendored
@@ -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
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -4,6 +4,51 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [5.0.0] - 2026-01-09
|
||||
### Added
|
||||
* [#2431](https://github.com/shlinkio/shlink/issues/2431) Add new date-based conditions for the dynamic rules redirections system, that allow to perform redirections based on an ISO-8601 date value.
|
||||
|
||||
* `before-date`: matches when current date and time is earlier than the defined threshold.
|
||||
* `after-date`: matches when current date and time is later than the defined threshold.
|
||||
|
||||
* [#2513](https://github.com/shlinkio/shlink/issues/2513) Add support for redis connections via unix socket (e.g. `REDIS_SERVERS=unix:/path/to/redis.sock`).
|
||||
* Visits generated in the command line can now be formatted in CSV, via `--format=csv`.
|
||||
|
||||
### Changed
|
||||
* [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue.
|
||||
|
||||
Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
|
||||
|
||||
* [#2311](https://github.com/shlinkio/shlink/issues/2311) All visits-related commands now return more information, and columns are arranged slightly differently.
|
||||
|
||||
Among other things, they now always return the type of the visit, region, visited URL, redirected URL and whether the visit comes from a potential bot or not.
|
||||
|
||||
* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
|
||||
* [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#2507](https://github.com/shlinkio/shlink/issues/2507) Drop support for PHP 8.3.
|
||||
* [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard.
|
||||
* [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead.
|
||||
* [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name.
|
||||
* [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead.
|
||||
* [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag.
|
||||
* [#2543](https://github.com/shlinkio/shlink/issues/2543) Remove support for `--order-by=field,dir` option `short-url:list` command. Use `--order-by=field-dir` instead.
|
||||
* Remove support to provide redis database index via URI path. Use `?database=3` query instead.
|
||||
* [#2565](https://github.com/shlinkio/shlink/issues/2565) Remove explicit dependency in ext-json, since it's part of PHP since v8.0
|
||||
|
||||
### Fixed
|
||||
* [#2564](https://github.com/shlinkio/shlink/issues/2564) Fix error when trying to persist non-utf-8 title without being able to determine its original charset for parsing.
|
||||
|
||||
Now, when resolving a website's charset, two improvements have been introduced:
|
||||
|
||||
1. If the `Content-Type` header does not define the charset, we fall back to `<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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
21
UPGRADE.md
21
UPGRADE.md
@@ -1,5 +1,26 @@
|
||||
# Upgrading
|
||||
|
||||
## From v4.x to v5.x
|
||||
|
||||
### General
|
||||
|
||||
* Generating QR codes by appending `/qr-code` to a short URL is no longer possible. Use external services to generate QR codes from a short URL, or the logic embedded in Shlink Web Client and Shlink Dashboard.
|
||||
* Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address.
|
||||
Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
|
||||
* PHP 8.3 is no longer supported. Only 8.4 and 8.5 are officially supported as of Shlink 5.0.0.
|
||||
|
||||
### Changes in CLI
|
||||
|
||||
* Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name.
|
||||
* All visits-related commands (`short-url:visits`, `tag:visits`, `domain:visits`, `visit:orphan` and `visit:non-orphan`) now return more information, and columns are arranged slightly differently.
|
||||
* The `short-url:list` command no longer accepts `--including-all-tags` and `--show-api-key-name` options. Use `--tags-all` and `--show-api-key` instead.
|
||||
* The `short-url:list` command no longer allows ordering using the `--order-by=field,dir` format. Use `--order-by=field-dir` instead.
|
||||
* All commands which used to accept the `--tags` flag, no longer accept it. Pass `--tag` multiple times instead, one per tag.
|
||||
|
||||
### Changes in env vars
|
||||
|
||||
* The `REDIRECT_APPEND_EXTRA_PATH` env var is no longer supported. Use `REDIRECT_EXTRA_PATH_MODE=append` to enable the same behavior.
|
||||
|
||||
## From v3.x to v4.x
|
||||
|
||||
### General
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,9 +133,6 @@
|
||||
},
|
||||
"/{shortCode}/track": {
|
||||
"$ref": "paths/{shortCode}_track.json"
|
||||
},
|
||||
"/{shortCode}/qr-code": {
|
||||
"$ref": "paths/{shortCode}_qr-code.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
33
module/CLI/src/Command/Api/Input/ApiKeyInput.php
Normal file
33
module/CLI/src/Command/Api/Input/ApiKeyInput.php
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
80
module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php
Normal file
80
module/CLI/src/Command/ShortUrl/Input/ShortUrlDataInput.php
Normal 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;
|
||||
}
|
||||
}
|
||||
121
module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php
Normal file
121
module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
58
module/CLI/src/Command/Util/CommandUtils.php
Normal file
58
module/CLI/src/Command/Util/CommandUtils.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
117
module/CLI/src/Command/Visit/VisitsCommandUtils.php
Normal file
117
module/CLI/src/Command/Visit/VisitsCommandUtils.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
37
module/CLI/src/Input/InputUtils.php
Normal file
37
module/CLI/src/Input/InputUtils.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
18
module/CLI/src/Input/VisitsListFormat.php
Normal file
18
module/CLI/src/Input/VisitsListFormat.php
Normal 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';
|
||||
}
|
||||
35
module/CLI/src/Input/VisitsListInput.php
Normal file
35
module/CLI/src/Input/VisitsListInput.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?');
|
||||
|
||||
26
module/CLI/src/Util/PhpProcessRunner.php
Normal file
26
module/CLI/src/Util/PhpProcessRunner.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
49
module/CLI/test/Input/InputUtilsTest.php
Normal file
49
module/CLI/test/Input/InputUtilsTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user