Compare commits

...

102 Commits

Author SHA1 Message Date
Alejandro Celaya
89419e278c
Merge pull request #2539 from acelaya-forks/symfony-8.0
Update to Symfony 8.0
2025-12-03 07:58:39 +01:00
Alejandro Celaya
1996745f64 Update to Symfony 8.0 2025-12-02 12:20:52 +01:00
Alejandro Celaya
cfab13bc47
Merge pull request #2533 from acelaya-forks/improve-coverage-2
Add more code coverage improvements
2025-11-18 09:33:20 +01:00
Alejandro Celaya
9432a5ba78 Add tests for events 2025-11-18 09:30:30 +01:00
Alejandro Celaya
7812a85b39 Remove unused AppOptions::__toString method 2025-11-18 09:20:52 +01:00
Alejandro Celaya
1e0b6be67d Improved NorFoundRedirectResolver test 2025-11-18 09:06:11 +01:00
Alejandro Celaya
88e5bb5618 Add test for AbstractRestAction::getRouteDef() 2025-11-18 08:56:09 +01:00
Alejandro Celaya
db1411d3f8 Remove unused method in ApiKeyNotFoundException 2025-11-18 08:45:31 +01:00
Alejandro Celaya
933c54e884 Improve some console commands coverage 2025-11-18 08:44:15 +01:00
Alejandro Celaya
f3ff059d48 Improve RoleResolver coverage 2025-11-17 12:33:08 +01:00
Alejandro Celaya
039a58bb44
Merge pull request #2532 from acelaya-forks/improve-coverage
Remove dead code that is affecting code coverage
2025-11-17 12:23:55 +01:00
Alejandro Celaya
0604237b94 Remove dead code that is affecting code coverage 2025-11-17 12:12:06 +01:00
Alejandro Celaya
c8537e4f71
Merge pull request #2529 from acelaya-forks/php-8.4-goodies
Simplify NotFoundRedirectConfigInterface with property hooks and asymetric visibility
2025-11-08 22:58:34 +01:00
Alejandro Celaya
c42fb67efc Simplify NotFoundRedirectConfigInterface with property hooks and asymetric visibility 2025-11-08 22:47:24 +01:00
Alejandro Celaya
ad15ae1922
Merge pull request #2526 from acelaya-forks/remove-trusted-proxies-workaround
Remove workaround to detect trusted proxies automatically
2025-11-08 10:44:09 +01:00
Alejandro Celaya
a731e01bd4 Remove test covering trusted proxies workaround 2025-11-08 10:41:03 +01:00
Alejandro Celaya
63bea36c05 Remove workaround to detect trusted proxies automatically 2025-11-08 10:33:06 +01:00
Alejandro Celaya
8a33c6968a
Merge pull request #2525 from acelaya-forks/remove-tags-option
Remove deprecated --tags option in console commands
2025-11-08 10:29:26 +01:00
Alejandro Celaya
359129f586 Remove deprecated --tags option in console commands 2025-11-08 10:22:02 +01:00
Alejandro Celaya
fdcc9933a3
Merge pull request #2524 from acelaya-forks/list-urls-deprecations
Remove deprecated options from short-url:list command
2025-11-08 10:13:15 +01:00
Alejandro Celaya
94adba95eb Fix codecov/codecov-action arguments for v5 2025-11-08 10:09:20 +01:00
Alejandro Celaya
8bafd82e1d Remove deprecated options from short-url:list command 2025-11-08 10:07:51 +01:00
Alejandro Celaya
d2bc9f7c2b
Merge pull request #2523 from acelaya-forks/no-disable-by-api-key
Do not allow API keys to be disabled by plain-text key
2025-11-08 09:34:34 +01:00
Alejandro Celaya
9f564b9785 Do not allow API keys to be disabled by plain-text key 2025-11-08 09:16:15 +01:00
Alejandro Celaya
1b6929acf6
Merge pull request #2518 from acelaya-forks/remove-extra-path
Remove REDIRECT_APPEND_EXTRA_PATH env var
2025-11-08 08:53:33 +01:00
Alejandro Celaya
91fd5809ff Remove REDIRECT_APPEND_EXTRA_PATH env var 2025-11-08 08:28:52 +01:00
Alejandro Celaya
c7fd6b3cba
Merge pull request #2516 from acelaya-forks/drop-php-8.3
Drop support for PHP 8.3
2025-11-08 08:17:18 +01:00
Alejandro Celaya
1eb1f5344c Drop support for PHP 8.3 2025-11-07 17:21:54 +01:00
Alejandro Celaya
f9ec4cea62
Merge pull request #2515 from acelaya-forks/remove-qr-codes
Drop support for QR code generation
2025-11-07 17:21:04 +01:00
Alejandro Celaya
c3961b139a Remove image extensions from dev docker containers 2025-11-07 17:10:51 +01:00
Alejandro Celaya
c2aae9640d Remove requirement on ext-gd 2025-11-07 17:07:34 +01:00
Alejandro Celaya
b4043be7fa Drop support for QR code generation 2025-11-07 16:58:19 +01:00
Alejandro Celaya
49c67abf0a Add missing entry in 4.6.0 changelog 2025-11-01 12:53:14 +01:00
Alejandro Celaya
c6f718eb11 Add proper version contraints for shlinkio packages 2025-11-01 12:43:22 +01:00
Alejandro Celaya
d3e8e9a735 Add v4.6.0 to changelog 2025-11-01 12:38:00 +01:00
Alejandro Celaya
8f1542c7aa
Merge pull request #2509 from acelaya-forks/invokable-commands
Invokable commands
2025-11-01 12:37:04 +01:00
Alejandro Celaya
058c0ebfaf Update changelog 2025-11-01 12:31:46 +01:00
Alejandro Celaya
b69db91378 Make DownloadGeoLiteDbCommand invokable 2025-11-01 12:30:15 +01:00
Alejandro Celaya
6113c28768 Make RenameTagCommand invokable 2025-11-01 12:28:04 +01:00
Alejandro Celaya
506ed47531 Make ListTagsCommand invokable 2025-11-01 12:25:52 +01:00
Alejandro Celaya
10173d2ab8 Make DeleteTagsCommand invokable 2025-11-01 12:24:18 +01:00
Alejandro Celaya
9ee709f0f3 Make DeleteExpiredShortUrlsCommand invokable 2025-11-01 12:18:29 +01:00
Alejandro Celaya
0fe28a5eb5 Make MatomoSendVisitsCommand invokable 2025-11-01 11:56:40 +01:00
Alejandro Celaya
2142afae89 Make ListDomainsCommand invokable 2025-11-01 11:50:43 +01:00
Alejandro Celaya
e7f4b84c65 Make DomainRedirectsCommand invokable 2025-11-01 11:45:27 +01:00
Alejandro Celaya
2d83b8d046 Make InitialApiKeyCommand invokable 2025-11-01 11:41:50 +01:00
Alejandro Celaya
dfef735c89 Make ReadEnvVarCommand invokable 2025-11-01 11:38:10 +01:00
Alejandro Celaya
c34c4e0eea
Merge pull request #2508 from acelaya-forks/php-8.5-support
Add support for PHP 8.5
2025-11-01 11:04:08 +01:00
Alejandro Celaya
f024fd414e Add support for PHP 8.5 2025-11-01 10:13:00 +01:00
Alejandro Celaya
12d81c3213 Update changelog 2025-11-01 10:03:03 +01:00
Alejandro Celaya
628fb9ebb5
Merge pull request #2503 from acelaya-forks/domain-visits-filter
Allow tags, orphan and non-orphan visits lists to be filtered by domain
2025-11-01 10:02:32 +01:00
Alejandro Celaya
e21cea1971 Add API tests for visits domain filtering 2025-11-01 09:56:15 +01:00
Alejandro Celaya
37088b1a4b Allow filtering orphan visits by domain via DEFAULT keyword 2025-10-31 08:53:31 +01:00
Alejandro Celaya
b5f8e8a4cd Document domain param for visits endpoints 2025-10-30 10:23:00 +01:00
Alejandro Celaya
a236f19dc4 Allow filtering by domain in VisitRepository::findNonOrphanVisits 2025-10-30 10:08:46 +01:00
Alejandro Celaya
94426c7bf4 Allow filtering by domain in VisitRepository::findOrphanVisits 2025-10-30 09:04:51 +01:00
Alejandro Celaya
9dcc51abde Allow filtering by domain in VisitRepository::findVisitsByTag 2025-10-29 12:04:36 +01:00
Alejandro Celaya
70e376d569 Allow domain to be passed to tag:visits, visit:orphan and visit:non-orphan commands 2025-10-29 08:43:01 +01:00
Alejandro Celaya
14a7e3bb05 Allow tags, orphan and non-orphan visits to be provided a domain filter param 2025-10-28 11:08:33 +01:00
Alejandro Celaya
10dab5be20
Merge pull request #2504 from acelaya-forks/remove-set-accessible
Remove all calls to ReflectionProperty::setAccessible
2025-10-28 11:08:03 +01:00
Alejandro Celaya
532700028a Remove all calls to ReflectionProperty::setAccessible 2025-10-28 11:03:41 +01:00
Alejandro Celaya
fc54a25c32
Merge pull request #2501 from acelaya-forks/redis-sentinels-acl
Add support for redis credentials when using sentinels
2025-10-27 10:47:36 +01:00
Alejandro Celaya
ba16ba45f2 Add support for redis credentials when using sentinels 2025-10-27 10:16:57 +01:00
Alejandro Celaya
51c732a013 Document support for frankenphp in changelog 2025-10-24 08:45:52 +02:00
Alejandro Celaya
0f17990821
Merge pull request #2499 from acelaya-forks/api-key-filter
Allow filtering short URLs by API key name
2025-10-22 08:40:33 +02:00
Alejandro Celaya
02500143c1 Update changelog 2025-10-22 08:31:04 +02:00
Alejandro Celaya
9c22c7fc9c Add more tests for apiKeyName short URLs filtering 2025-10-22 08:28:45 +02:00
Alejandro Celaya
7860225c25 Add api-key-name option to short-url:list command 2025-10-22 08:04:29 +02:00
Alejandro Celaya
506ed6207f Allow filtering short URLs by API key name 2025-10-21 12:25:06 +02:00
Alejandro Celaya
30ed1d7c6b
Merge pull request #2497 from acelaya-forks/delete-api-key
Add new command to delete API keys
2025-10-20 15:06:50 +02:00
Alejandro Celaya
b5a9353b85 Add new command to delete API keys 2025-10-20 10:34:53 +02:00
Alejandro Celaya
cae18ccfb3
Merge pull request #2495 from acelaya-forks/tags-option
Extract tags option to its own Option class
2025-10-20 09:02:15 +02:00
Alejandro Celaya
f876769b67 Extract tags option to its own Option class 2025-10-20 08:58:07 +02:00
Alejandro Celaya
2b06f56a9a
Merge pull request #2492 from acelaya-forks/feature/exclude-tags
Allow listing short URLs which DO NOT include certain tags
2025-10-17 09:33:51 +02:00
Alejandro Celaya
1c38ab1217 Add exclude-tags CLI tests 2025-10-17 09:26:18 +02:00
Alejandro Celaya
fb9e8cd79f Update changelog 2025-10-17 08:56:26 +02:00
Alejandro Celaya
eb199a61da Add exclude-tags API tests 2025-10-17 08:52:25 +02:00
Alejandro Celaya
25de0263c5 Deprecate --tags and add --tag for short-url:list command 2025-10-17 08:35:41 +02:00
Alejandro Celaya
41c03a66e4 Fix static analysis 2025-10-16 19:16:11 +02:00
Alejandro Celaya
13c1b12d84 Update logic in ShortUrlListRepository to take excluded tags into consideration 2025-10-16 19:16:11 +02:00
Alejandro Celaya
fe10aaf245 Make --tags option allow multiple values in list short URLs command 2025-10-16 19:16:11 +02:00
Alejandro Celaya
464e3d7f8e Support excludeTags and excludeTagsMode in list short URLs command 2025-10-16 19:16:11 +02:00
Alejandro Celaya
ac40a7021b Document excludeTags and excludeTagsMode params for short URLs list 2025-10-16 19:16:11 +02:00
Alejandro Celaya
c60a5e750b Reference Jetbrains in README 2025-10-16 15:08:29 +02:00
Alejandro Celaya
785f728afc
Merge pull request #2493 from acelaya-forks/fix-phpstan
Fix issue reported by phpstan in CrossDomainMiddleware
2025-10-16 10:16:33 +02:00
Alejandro Celaya
648696f778 Fix issue reported by phpstan in CrossDomainMiddleware 2025-10-16 10:08:24 +02:00
Alejandro Celaya
774a579a94 Add v4.5.3 to changelog 2025-10-10 10:29:06 +02:00
Alejandro Celaya
98bbb01165 Update coding standard 2025-10-06 08:46:34 +02:00
Alejandro Celaya
0bcb9e0438 Update changelog 2025-10-03 10:24:38 +02:00
Alejandro Celaya
edb8b57a48
Merge pull request #2489 from acelaya-forks/feature/cors-credentials-fix
Make sure Access-Control-Allow-Credentials is always set if configured
2025-10-03 10:23:02 +02:00
Alejandro Celaya
b01f271f72 Make sure Access-Control-Allow-Credentials is always set if configured 2025-10-03 10:15:27 +02:00
Alejandro Celaya
98b504a2de
Merge pull request #2484 from acelaya-forks/feature/memory-efficient-geolite
Make GeoLite db download memory efficient
2025-09-11 09:39:32 +02:00
Alejandro Celaya
075e6347b6 Make GeoLite db download memory efficient 2025-09-11 09:28:44 +02:00
Alejandro Celaya
92a70b8c11
Merge pull request #2477 from acelaya-forks/feature/frankenphp
Add a development FrankenPHP server
2025-08-28 09:01:41 +02:00
Alejandro Celaya
613c7b7368
Merge pull request #2480 from acelaya-forks/feature/garbage-collection
Garbage collect after every request
2025-08-27 09:41:38 +02:00
Alejandro Celaya
232f6e37c6 Ensure pipeline is not marked as failed if only v8.5 fails 2025-08-27 09:30:41 +02:00
Alejandro Celaya
c818d5603d Garbage collect after every request 2025-08-27 09:24:28 +02:00
Alejandro Celaya
766b227e47 Add a development FrankenPHP server 2025-08-26 08:52:22 +02:00
Alejandro Celaya
95be5a93fc
Merge pull request #2478 from acelaya-forks/feature/memory-leak-mitigation
Try to mitigate memory leaks when using RoadRunner
2025-08-24 11:23:57 +02:00
Alejandro Celaya
20c41690da Try to mitigate memory leaks when using RoadRunner 2025-08-24 11:18:40 +02:00
Alejandro Celaya
22b5fa5a83
Merge pull request #2474 from acelaya-forks/feature/symfony-lock
Update to symfony/lock ^7.3.2
2025-08-01 08:28:05 +02:00
Alejandro Celaya
0c4d1b6d2f Update to symfony/lock ^7.3.2 2025-08-01 08:21:37 +02:00
170 changed files with 1985 additions and 1978 deletions

View File

@ -43,5 +43,5 @@ runs:
coverage: xdebug
- name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }}
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.5' && '--ignore-platform-req=php' || '' }}
run: composer install --no-interaction --prefer-dist
shell: bash

View File

@ -13,12 +13,11 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
continue-on-error: ${{ inputs.php-version == '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
@ -36,8 +35,8 @@ jobs:
- name: Run tests
run: composer test:db:${{ inputs.platform }}
- name: Upload code coverage
uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
uses: actions/upload-artifact@v5
if: ${{ matrix.php-version == '8.4' && inputs.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |

View File

@ -13,12 +13,11 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
continue-on-error: ${{ inputs.php-version == '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
@ -33,8 +32,8 @@ jobs:
if: ${{ inputs.test-group == 'api' }}
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.3' }}
- uses: actions/upload-artifact@v5
if: ${{ matrix.php-version == '8.4' }}
with:
name: coverage-${{ inputs.test-group }}
path: |

View File

@ -27,10 +27,10 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3']
php-version: ['8.4']
command: ['cs', 'stan', 'openapi:validate']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
@ -69,16 +69,15 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3']
php-version: ['8.4']
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Use PHP
uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: tests-extensions-${{ matrix.php-version }}
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v6
with:
path: build
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
@ -87,9 +86,9 @@ jobs:
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
- run: vendor/bin/phpcov merge build --clover build/clover.xml
- name: Publish coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
file: ./build/clover.xml
files: ./build/clover.xml
delete-artifacts:
needs:

View File

@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3']
php-version: ['8.4']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Determine version
id: determine_version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

View File

@ -10,16 +10,16 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3', '8.4']
php-version: ['8.4', '8.4', '8.5']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no'
- run: ./build.sh ${GITHUB_REF#refs/tags/v}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
with:
name: dist-files-${{ matrix.php-version }}
path: build
@ -28,8 +28,8 @@ jobs:
needs: ['build']
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@v5
- uses: actions/download-artifact@v6
with:
path: build
- name: Publish release with assets

View File

@ -4,6 +4,122 @@ 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).
## [Unreleased]
### Added
* *Nothing*
### 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.
* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
### 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` deprecated 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.
### Fixed
* *Nothing*
## [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.
Now, the `GET /short-urls` endpoint accepts two new params: `excludeTags`, which is an array of strings with the tags that should not be included, and `excludeTagsMode`, which accepts the values `any` and `all`, and determines if short URLs should be filtered out if they contain any of the excluded tags, or all the excluded tags.
Additionally, the `short-url:list` command also supports the same feature via `--exclude-tag` option, which requires a value and can be provided multiple times, and `--exclude-tags-all`, which does not expect a value and determines if the mode should be `all`, or `any`.
* [#2192](https://github.com/shlinkio/shlink/issues/2192) Allow filtering short URL lists by the API key that was used to create them.
Now, the `GET /short-urls` endpoint accepts a new `apiKeyName` param, which is ignored if the request is performed with a non-admin API key which name does not match the one provided here.
Additionally, the `short-url:list` command also supports the same feature via the `--api-key-name` option.
* [#2330](https://github.com/shlinkio/shlink/issues/2330) Add support to serve Shlink with FrankenPHP, by providing a worker script in `bin/frankenphp-worker.php`.
* [#2449](https://github.com/shlinkio/shlink/issues/2449) Add support to provide redis credentials separately when using redis sentinels, where provided servers are the sentinels and not the redis instances.
For this, Shlink supports two new env ras / config options, as `REDIS_SERVERS_USER` and `REDIS_SERVERS_PASSWORD`.
* [#2498](https://github.com/shlinkio/shlink/issues/2498) Allow orphan visits, non-orphan visits and tag visits lists to be filtered by domain.
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.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.5.3] - 2025-10-10
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2488](https://github.com/shlinkio/shlink/issues/2488) Ensure `Access-Control-Allow-Credentials` is set in all cross-origin responses when `CORS_ALLOW_ORIGIN=true`.
## [4.5.2] - 2025-08-27
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2433](https://github.com/shlinkio/shlink/issues/2433) Try to mitigate memory leaks allowing RoadRunner to garbage collect memory after every request and every job, by setting `GC_COLLECT_CYCLES=true`.
## [4.5.1] - 2025-08-24
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2433](https://github.com/shlinkio/shlink/issues/2433) Try to mitigate memory leaks by restarting job and http workers every 250 executions when using RoadRunner.
## [4.5.0] - 2025-07-24
### Added

View File

@ -16,9 +16,8 @@ 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 && \
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

View File

@ -36,10 +36,9 @@ The idea is that you can just generate a container using the image and provide t
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 8.3 or 8.4
* PHP 8.4 or 8.5
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
* apcu extension is recommended if you don't plan to use RoadRunner.
* xml extension is required if you want to generate QR codes in svg format.
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite.
* You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`.
@ -99,6 +98,12 @@ Both the API and CLI allow you to do mostly the same operations, except for API
If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
## Powered by
Thanks to [JetBrains](https://www.jetbrains.com/) for their continuous support to this project in the form of IDE licenses.
![JetBrains logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)
---
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)

36
bin/frankenphp-worker.php Normal file
View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\EmitterInterface;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use function frankenphp_handle_request;
use function gc_collect_cycles;
(static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$app = $container->get(Application::class);
$responseEmitter = $container->get(EmitterInterface::class);
$handler = static function () use ($app, $responseEmitter): void {
$response = $app->handle(ServerRequestFactory::fromGlobals());
$responseEmitter->emit($response);
};
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
for ($nbRequests = 0; !$maxRequests || $nbRequests < $maxRequests; ++$nbRequests) {
$keepRunning = frankenphp_handle_request($handler);
// Call the garbage collector to reduce the chances of it being triggered in the middle of a page generation
gc_collect_cycles();
if (! $keepRunning) {
break;
}
}
})();

View File

@ -2,18 +2,22 @@
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use Shlinkio\Shlink\EventDispatcher\RoadRunner\RoadRunnerTaskConsumerToListener;
use Spiral\RoadRunner\Http\PSR7Worker;
use function gc_collect_cycles;
use function Shlinkio\Shlink\Config\env;
(static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$rrMode = env('RR_MODE');
$gcCollectCycles = env('GC_COLLECT_CYCLES', default: false);
if ($rrMode === 'http') {
// This was spin-up as a web worker
@ -25,6 +29,10 @@ use function Shlinkio\Shlink\Config\env;
$worker->respond($app->handle($req));
} catch (Throwable $e) {
$worker->getWorker()->error((string) $e);
} finally {
if ($gcCollectCycles) {
gc_collect_cycles();
}
}
}
} else {

View File

@ -12,19 +12,17 @@
}
],
"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.2",
"doctrine/migrations": "^3.8",
"doctrine/orm": "^3.3",
"doctrine/dbal": "^4.3",
"doctrine/migrations": "^3.9",
"doctrine/orm": "^3.5",
"donatj/phpuseragentparser": "^1.10",
"endroid/qr-code": "^6.0.5",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.1",
"guzzlehttp/guzzle": "^7.9",
@ -43,22 +41,22 @@
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "^7.1",
"shlinkio/shlink-config": "^4.0",
"shlinkio/shlink-event-dispatcher": "^4.2",
"shlinkio/shlink-importer": "^5.6",
"shlinkio/shlink-installer": "^9.6",
"shlinkio/shlink-ip-geolocation": "^4.3",
"shlinkio/shlink-json": "^1.2",
"shlinkio/shlink-common": "dev-main#f2550b5 as 7.3.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#4498f0a as 5.7.0",
"shlinkio/shlink-installer": "dev-develop#40e08cb as 10.0.0",
"shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0",
"shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0",
"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.1.6",
"symfony/process": "^7.3",
"symfony/string": "^7.3"
"symfony/console": "^8.0 || ^7.4",
"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",
@ -71,9 +69,9 @@
"phpunit/phpcov": "^11.0",
"phpunit/phpunit": "^12.0.10",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.4.2",
"shlinkio/shlink-test-utils": "^4.3.1",
"symfony/var-dumper": "^7.3",
"shlinkio/php-coding-standard": "~2.5.0",
"shlinkio/shlink-test-utils": "^4.4",
"symfony/var-dumper": "^8.0",
"veewee/composer-run-parallel": "^1.4"
},
"conflict": {
@ -147,12 +145,12 @@
"test:cli:ci": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
"vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
"@php -d memory_limit=-1 vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
],
"test:cli:pretty": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
"@php -d memory_limit=-1 phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
],
"openapi:validate": "php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",

View File

@ -11,6 +11,8 @@ return (static function (): array {
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
'username' => EnvVars::REDIS_SERVERS_USER->loadFromEnv(),
'password' => EnvVars::REDIS_SERVERS_PASSWORD->loadFromEnv(),
],
];

View File

@ -8,7 +8,7 @@ return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data',
'temp_dir' => __DIR__ . '/../../data/temp-geolite',
'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(),
],

View File

@ -33,6 +33,8 @@ return [
Option\Cache\CacheNamespaceConfigOption::class,
Option\Redis\RedisServersConfigOption::class,
Option\Redis\RedisSentinelServiceConfigOption::class,
Option\Redis\RedisServersUserConfigOption::class,
Option\Redis\RedisServersPasswordConfigOption::class,
Option\Redis\RedisPubSubConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,
Option\Mercure\EnableMercureConfigOption::class,
@ -58,15 +60,6 @@ return [
Option\Tracking\DisableIpTrackingConfigOption::class,
Option\Tracking\DisableReferrerTrackingConfigOption::class,
Option\Tracking\DisableUaTrackingConfigOption::class,
Option\QrCode\DefaultSizeConfigOption::class,
Option\QrCode\DefaultMarginConfigOption::class,
Option\QrCode\DefaultFormatConfigOption::class,
Option\QrCode\DefaultErrorCorrectionConfigOption::class,
Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
Option\QrCode\DefaultColorConfigOption::class,
Option\QrCode\DefaultBgColorConfigOption::class,
Option\QrCode\DefaultLogoUrlConfigOption::class,
Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class,
Option\RabbitMq\RabbitMqEnabledConfigOption::class,
Option\RabbitMq\RabbitMqHostConfigOption::class,
Option\RabbitMq\RabbitMqUseSslConfigOption::class,

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
use RKA\Middleware\IpAddress;
use RKA\Middleware\Mezzio\IpAddressFactory;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator;
use function Shlinkio\Shlink\Core\splitByComma;
@ -43,18 +42,6 @@ return (static function (): array {
'factories' => [
IpAddress::class => IpAddressFactory::class,
],
'delegators' => [
// Make middleware decoration transparent to other parts of the code
IpAddress::class => [
fn ($c, $n, callable $callback) =>
// If trusted proxies have been provided, use original middleware verbatim, otherwise decorate
// with workaround
$trustedProxies !== null
? $callback()
: new ReverseForwardedAddressesMiddlewareDecorator($callback()),
],
],
],
];

View File

@ -94,14 +94,6 @@ return (static function (): array {
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
[
'name' => CoreAction\QrCodeAction::class,
'path' => '/{shortCode}/qr-code',
'middleware' => [
CoreAction\QrCodeAction::class,
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
[
'name' => CoreAction\RedirectAction::class,
'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix),

View File

@ -10,7 +10,7 @@ use Mezzio;
use Mezzio\ProblemDetails;
use Shlinkio\Shlink\Core\Config\EnvVars;
return (new ConfigAggregator\ConfigAggregator(
return new ConfigAggregator\ConfigAggregator(
providers: [
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
@ -39,4 +39,4 @@ return (new ConfigAggregator\ConfigAggregator(
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
],
))->getMergedConfig();
)->getMergedConfig();

View File

@ -38,20 +38,3 @@ const ISO_COUNTRY_CODES = [
'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU',
'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW',
];
/** @deprecated */
const DEFAULT_QR_CODE_SIZE = 300;
/** @deprecated */
const DEFAULT_QR_CODE_MARGIN = 0;
/** @deprecated */
const DEFAULT_QR_CODE_FORMAT = 'png';
/** @deprecated */
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
/** @deprecated */
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
/** @deprecated */
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
/** @deprecated */
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
/** @deprecated */
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White

View File

@ -4,13 +4,15 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
EnvVars::APP_ENV->value => 'dev',
// EnvVars::GEOLITE_LICENSE_KEY->value => '',
// URL shortener
EnvVars::DEFAULT_DOMAIN->value => 'localhost:8800',
EnvVars::DEFAULT_DOMAIN->value => runningInRoadRunner() ? 'localhost:8800' : 'localhost:8008',
EnvVars::IS_HTTPS_ENABLED->value => false,
// Database - MySQL

View File

@ -14,11 +14,13 @@ http:
forbid: ['.php', '.htaccess']
pool:
num_workers: ${WEB_WORKER_NUM:-0}
max_jobs: 250 # Restart worker after processing this amount of requests to mitigate memory leaks
jobs:
timeout: 300 # 5 minutes
pool:
num_workers: ${TASK_WORKER_NUM:-0}
max_jobs: 250 # Restart worker after processing this amount of jobs to mitigate memory leaks
consume: ['shlink']
pipelines:
shlink:

View File

@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

View File

@ -0,0 +1,52 @@
FROM dunglas/frankenphp:1-php8.4-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install calendar
RUN apk add --no-cache oniguruma-dev
RUN docker-php-ext-install mbstring
RUN apk add --no-cache sqlite-libs
RUN apk add --no-cache sqlite-dev
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 postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install xdebug and 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 && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home
VOLUME /home/shlink
WORKDIR /home/shlink

View File

@ -0,0 +1,2 @@
*
!.gitignore

2
data/infra/frankenphp_caddy_data/.gitignore vendored Executable file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -25,9 +25,6 @@ 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

View File

@ -24,9 +24,6 @@ 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

2
data/temp-geolite/.gitignore vendored Executable file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -66,6 +66,37 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_frankenphp:
container_name: shlink_frankenphp
user: 1000:1000
build:
context: .
dockerfile: ./data/infra/frankenphp.Dockerfile
ports:
- "8008:8008"
volumes:
- ./:/home/shlink
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
- ./data/infra/frankenphp_caddy_data:/data
- ./data/infra/frankenphp_caddy_config:/config
links:
- shlink_db_mysql
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
FRANKENPHP_CONFIG: 'worker /home/shlink/bin/frankenphp-worker.php'
SERVER_NAME: ':8008 https:8009'
extra_hosts:
- 'host.docker.internal:host-gateway'
tty: true
shlink_db_mysql:
container_name: shlink_db_mysql
user: 1000:1000

View File

@ -4,7 +4,7 @@ set -e
cd /etc/shlink
# Create data directories if they do not exist. This allows data dir to be mounted as an empty dir if needed
mkdir -p data/cache data/locks data/log data/proxies
mkdir -p data/cache data/locks data/log data/proxies data/temp-geolite
flags="--no-interaction --clear-db-cache"

View File

@ -31,7 +31,7 @@
{
"name": "searchTerm",
"in": "query",
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields.",
"required": false,
"schema": {
"type": "string"
@ -40,7 +40,7 @@
{
"name": "tags[]",
"in": "query",
"description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
"description": "A list of tags used to filter the result set. Only short URLs **with** these tags will be returned.",
"required": false,
"schema": {
"type": "array",
@ -52,7 +52,29 @@
{
"name": "tagsMode",
"in": "query",
"description": "Tells how the filtering by tags should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. It's ignored if no tags are provided, and defaults to \"any\" if not provided.",
"description": "Tells how the filtering by `tags` should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. Defaults to \"any\".<br />It's ignored if `tags` is not provided.",
"required": false,
"schema": {
"type": "string",
"enum": ["any", "all"]
}
},
{
"name": "excludeTags[]",
"in": "query",
"description": "A list of tags used to filter the result set. Only short URLs **without** these tags will be returned.",
"required": false,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "excludeTagsMode",
"in": "query",
"description": "Tells how the filtering by `excludeTags` should work, returning short URLs not containing \"any\" of the tags, or not containing \"all\" the tags. Defaults to \"any\".<br />It's ignored if `excludeTags` is not provided.",
"required": false,
"schema": {
"type": "string",
@ -134,6 +156,15 @@
"schema": {
"type": "string"
}
},
{
"name": "apiKeyName",
"in": "query",
"description": "Only get short URLs created with this API key.<br />This value is **ignored** if the request is performed with a non-admin API key that does not match this name.",
"required": false,
"schema": {
"type": "string"
}
}
],
"security": [

View File

@ -64,6 +64,10 @@
"type": "string",
"enum": ["true"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@ -55,6 +55,10 @@
"type": "string",
"enum": ["true"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@ -65,6 +65,10 @@
"type": "string",
"enum": ["invalid_short_url", "base_url", "regular_404"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return only visits for this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@ -1,121 +0,0 @@
{
"get": {
"deprecated": true,
"operationId": "shortUrlQrCode",
"tags": [
"URL Shortener"
],
"summary": "[Deprecated] Short URL QR code",
"description": "**[Deprecated]** Use an external mechanism to generate QR codes. Shlink dashboard and shlink-web-client provide their own.",
"parameters": [
{
"$ref": "../parameters/shortCode.json"
},
{
"name": "size",
"in": "query",
"description": "The size of the image to be returned.",
"required": false,
"schema": {
"type": "integer",
"minimum": 50,
"maximum": 1000,
"default": 300
}
},
{
"name": "format",
"in": "query",
"description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
"required": false,
"schema": {
"type": "string",
"enum": ["png", "svg"],
"default": "png"
}
},
{
"name": "margin",
"in": "query",
"description": "The margin around the QR code image.",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0
}
},
{
"name": "errorCorrection",
"in": "query",
"description": "The error correction level to apply to the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
"required": false,
"schema": {
"type": "string",
"enum": ["L", "M", "Q", "H"],
"default": "L"
}
},
{
"name": "roundBlockSize",
"in": "query",
"description": "Allows to disable block size rounding, which might reduce the readability of the QR code, but ensures no extra margin is added.",
"required": false,
"schema": {
"type": "string",
"enum": ["true", "false"],
"default": "false"
}
},
{
"name": "color",
"in": "query",
"description": "The QR code foreground color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
"required": false,
"schema": {
"type": "string",
"default": "#000000"
}
},
{
"name": "bgColor",
"in": "query",
"description": "The QR code background color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
"required": false,
"schema": {
"type": "string",
"default": "#ffffff"
}
},
{
"name": "logo",
"in": "query",
"description": "Currently used to disable the logo that was set via configuration options. It may be used in future to dynamically choose from multiple logos.",
"required": false,
"schema": {
"type": "string",
"enum": ["disable"]
}
}
],
"responses": {
"200": {
"description": "QR code in PNG format",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/svg+xml": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}

View File

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

View File

@ -26,6 +26,7 @@ return [
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
Command\Api\DeleteKeyCommand::NAME => Command\Api\DeleteKeyCommand::class,
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class,

View File

@ -52,6 +52,7 @@ return [
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DeleteKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class,
@ -108,6 +109,7 @@ return [
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\DeleteKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class],

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
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;
use function sprintf;
#[AsCommand(
name: DeleteKeyCommand::NAME,
description: 'Deletes an API key by name',
help: <<<HELP
The <info>%command.name%</info> command allows you to delete an existing API key via its name.
If no arguments are provided, you will be prompted to select one of the existing API keys.
<info>%command.full_name%</info>
You can optionally pass the API key name to be disabled:
<info>%command.full_name% the_key_name</info>
HELP,
)]
class DeleteKeyCommand extends Command
{
public const string NAME = 'api-key:delete';
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$apiKeyName = $input->getArgument('name');
if ($apiKeyName === null) {
$apiKeys = $this->apiKeyService->listKeys();
$name = new SymfonyStyle($input, $output)->choice(
'What API key do you want to delete?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('name', $name);
}
}
public function __invoke(
SymfonyStyle $io,
InputInterface $input,
#[Argument(description: 'The API key to delete.')]
string|null $name = null,
): int {
if ($name === null) {
$io->warning('An API key name was not provided.');
return Command::INVALID;
}
if (! $this->shouldProceed($io, $input)) {
return Command::INVALID;
}
try {
$this->apiKeyService->deleteByName($name);
$io->success(sprintf('API key "%s" properly deleted', $name));
return Command::SUCCESS;
} catch (ApiKeyNotFoundException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
}
private function shouldProceed(SymfonyStyle $io, InputInterface $input): bool
{
if (! $input->isInteractive()) {
return true;
}
$io->warning('You are about to delete an API key. This action cannot be undone.');
return $io->confirm('Are you sure you want to delete the API key?');
}
}

View File

@ -9,7 +9,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@ -20,24 +19,17 @@ use function sprintf;
#[AsCommand(
name: DisableKeyCommand::NAME,
description: 'Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)',
description: 'Disables an API key by name',
help: <<<HELP
The <info>%command.name%</info> command allows you to disable an existing API key, via its name or the
plain-text key.
The <info>%command.name%</info> command allows you to disable an existing API key.
If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys.
<info>%command.full_name%</info>
You can optionally pass the API key name to be disabled. In that case <comment>--by-name</comment> is also
required, to indicate the first argument is the API key name and not the plain-text key:
You can optionally pass the API key name to be disabled:
<info>%command.full_name% the_key_name --by-name</info>
You can pass the plain-text key to be disabled, but that is <options=bold>DEPRECATED</>. In next major version,
the argument will always be assumed to be the name:
<info>%command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143</info>
<info>%command.full_name% the_key_name</info>
HELP,
)]
@ -52,41 +44,31 @@ class DisableKeyCommand extends Command
protected function interact(InputInterface $input, OutputInterface $output): void
{
$keyOrName = $input->getArgument('key-or-name');
$name = $input->getArgument('name');
if ($keyOrName === null) {
if ($name === null) {
$apiKeys = $this->apiKeyService->listKeys(enabledOnly: true);
$name = (new SymfonyStyle($input, $output))->choice(
$name = new SymfonyStyle($input, $output)->choice(
'What API key do you want to disable?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('key-or-name', $name);
$input->setOption('by-name', true);
$input->setArgument('name', $name);
}
}
public function __invoke(
SymfonyStyle $io,
#[Argument(
description: 'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.',
)]
string|null $keyOrName = null,
#[Option(description: 'Indicates the first argument is the API key name, not the plain-text key.')]
bool $byName = false,
#[Argument('The name of the API key to disable.')] string|null $name = null,
): int {
if ($keyOrName === null) {
if ($name === null) {
$io->warning('An API key name was not provided.');
return Command::INVALID;
}
try {
if ($byName) {
$this->apiKeyService->disableByName($keyOrName);
} else {
$this->apiKeyService->disableByKey($keyOrName);
}
$io->success(sprintf('API key "%s" properly disabled', $keyOrName));
$this->apiKeyService->disableByName($name);
$io->success(sprintf('API key "%s" properly disabled', $name));
return Command::SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());

View File

@ -5,11 +5,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: InitialApiKeyCommand::NAME,
description: 'Tries to create initial API key',
)]
class InitialApiKeyCommand extends Command
{
public const string NAME = 'api-key:initial';
@ -19,22 +23,14 @@ class InitialApiKeyCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setHidden()
->setName(self::NAME)
->setDescription('Tries to create initial API key')
->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
}
public function __invoke(
SymfonyStyle $io,
#[Argument('The initial API to create')] string $apiKey,
): int {
$result = $this->apiKeyService->createInitial($apiKey);
protected function execute(InputInterface $input, OutputInterface $output): int
{
$key = $input->getArgument('apiKey');
$result = $this->apiKeyService->createInitial($key);
if ($result === null && $output->isVerbose()) {
$output->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
if ($result === null && $io->isVerbose()) {
$io->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
}
return Command::SUCCESS;

View File

@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\CLI\Command\Config;
use Closure;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@ -18,6 +19,11 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
use function sprintf;
#[AsCommand(
name: ReadEnvVarCommand::NAME,
description: 'Display current value for an env var',
hidden: true,
)]
class ReadEnvVarCommand extends Command
{
public const string NAME = 'env-var:read';
@ -31,19 +37,10 @@ class ReadEnvVarCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setHidden()
->setDescription('Display current value for an env var')
->addArgument('envVar', InputArgument::REQUIRED, 'The env var to read');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$envVar = $input->getArgument('envVar');
$envVar = $input->getArgument('env-var');
$validEnvVars = enumValues(EnvVars::class);
if ($envVar === null) {
@ -54,14 +51,14 @@ class ReadEnvVarCommand extends Command
throw new InvalidArgumentException(sprintf('%s is not a valid Shlink environment variable', $envVar));
}
$input->setArgument('envVar', $envVar);
$input->setArgument('env-var', $envVar);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$envVar = $input->getArgument('envVar');
$output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
public function __invoke(
SymfonyStyle $io,
#[Argument(description: 'The env var to read')] string $envVar,
): int {
$io->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
return Command::SUCCESS;
}
}

View File

@ -7,8 +7,9 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
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\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@ -18,6 +19,10 @@ use function array_map;
use function sprintf;
use function str_contains;
#[AsCommand(
name: DomainRedirectsCommand::NAME,
description: 'Set specific "not found" redirects for individual domains.',
)]
class DomainRedirectsCommand extends Command
{
public const string NAME = 'domain:redirects';
@ -27,18 +32,6 @@ class DomainRedirectsCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Set specific "not found" redirects for individual domains.')
->addArgument(
'domain',
InputArgument::REQUIRED,
'The domain authority to which you want to set the specific redirects',
);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
/** @var string|null $domain */
@ -67,10 +60,11 @@ class DomainRedirectsCommand extends Command
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$domainAuthority = $input->getArgument('domain');
public function __invoke(
SymfonyStyle $io,
#[Argument('The domain authority to which you want to set the specific redirects', name: 'domain')]
string $domainAuthority,
): int {
$domain = $this->domainService->findByAuthority($domainAuthority);
$ask = static function (string $message, string|null $current) use ($io): string|null {
@ -94,15 +88,15 @@ class DomainRedirectsCommand extends Command
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
$ask(
'URL to redirect to when a user hits this domain\'s base URL',
$domain?->baseUrlRedirect(),
$domain?->baseUrlRedirect,
),
$ask(
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
$domain?->regular404Redirect(),
$domain?->regular404Redirect,
),
$ask(
'URL to redirect to when a user hits an invalid short URL',
$domain?->invalidShortUrlRedirect(),
$domain?->invalidShortUrlRedirect,
),
));

View File

@ -8,13 +8,17 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
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 array_map;
#[AsCommand(
name: ListDomainsCommand::NAME,
description: 'List all domains that have been ever used for some short URL',
)]
class ListDomainsCommand extends Command
{
public const string NAME = 'domain:list';
@ -24,25 +28,17 @@ class ListDomainsCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('List all domains that have been ever used for some short URL')
->addOption(
'show-redirects',
'r',
InputOption::VALUE_NONE,
'Will display an extra column with the information of the "not found" redirects for every domain.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
public function __invoke(
SymfonyStyle $io,
#[Option(
'Will display an extra column with the information of the "not found" redirects for every domain.',
shortcut: 'r',
)]
bool $showRedirects = false,
): int {
$domains = $this->domainService->listDomains();
$showRedirects = $input->getOption('show-redirects');
$commonFields = ['Domain', 'Is default'];
$table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
$table = $showRedirects ? ShlinkTable::withRowSeparators($io) : ShlinkTable::default($io);
$table->render(
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
@ -53,7 +49,7 @@ class ListDomainsCommand extends Command
? [
...$commonValues,
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig),
]
]
: $commonValues;
}, $domains),
);
@ -63,9 +59,9 @@ class ListDomainsCommand extends Command
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
{
$baseUrl = $config->baseUrlRedirect() ?? 'N/A';
$regular404 = $config->regular404Redirect() ?? 'N/A';
$invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
$baseUrl = $config->baseUrlRedirect ?? 'N/A';
$regular404 = $config->regular404Redirect ?? 'N/A';
$invalidShortUrl = $config->invalidShortUrlRedirect ?? 'N/A';
return <<<EOL
* Base URL: {$baseUrl}

View File

@ -8,10 +8,10 @@ use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
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 Throwable;
@ -19,22 +19,9 @@ use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
use function sprintf;
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
{
public const string NAME = 'integration:matomo:send-visits';
private readonly bool $matomoEnabled;
private SymfonyStyle $io;
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
{
$this->matomoEnabled = $matomoOptions->enabled;
parent::__construct();
}
protected function configure(): void
{
$help = <<<HELP
#[AsCommand(
name: MatomoSendVisitsCommand::NAME,
help: <<<HELP
This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
Its intention is to allow you to configure Matomo at some point in time, and still have your whole visits
@ -54,32 +41,38 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
Send all visits created during 2022:
<info>%command.name% --since 2022-01-01 --until 2022-12-31</info>
HELP;
HELP,
)]
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
{
public const string NAME = 'integration:matomo:send-visits';
$this
->setName(self::NAME)
->setDescription(sprintf(
'%sSend existing visits to the configured matomo instance',
$this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ',
))
->setHelp($help)
->addOption(
'since',
's',
InputOption::VALUE_REQUIRED,
'Only visits created since this date, inclusively, will be sent to Matomo',
)
->addOption(
'until',
'u',
InputOption::VALUE_REQUIRED,
'Only visits created until this date, inclusively, will be sent to Matomo',
);
private readonly bool $matomoEnabled;
private SymfonyStyle $io;
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
{
$this->matomoEnabled = $matomoOptions->enabled;
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
protected function configure(): void
{
$this->io = new SymfonyStyle($input, $output);
$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,
#[Option('Only visits created since this date, inclusively, will be sent to Matomo', shortcut: 's')]
string|null $since = null,
#[Option('Only visits created until this date, inclusively, will be sent to Matomo', shortcut: 'u')]
string|null $until = null,
): int {
$this->io = $io;
if (! $this->matomoEnabled) {
$this->io->warning('Matomo integration is not enabled in this Shlink instance');
@ -87,8 +80,6 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
}
// TODO Validate provided date formats
$since = $input->getOption('since');
$until = $input->getOption('until');
$dateRange = buildDateRange(
startDate: $since !== null ? Chronos::parse($since) : null,
endDate: $until !== null ? Chronos::parse($until) : null,

View File

@ -6,14 +6,18 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
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: DeleteExpiredShortUrlsCommand::NAME,
description: 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
)]
class DeleteExpiredShortUrlsCommand extends Command
{
public const string NAME = 'short-url:delete-expired';
@ -23,32 +27,17 @@ class DeleteExpiredShortUrlsCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription(
'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
)
->addOption(
'evaluate-max-visits',
mode: InputOption::VALUE_NONE,
description: 'Also take into consideration short URLs which have reached their max amount of visits.',
)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Delete short URLs with no confirmation')
->addOption(
'dry-run',
mode: InputOption::VALUE_NONE,
description: 'Delete short URLs with no confirmation',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$force = $input->getOption('force') || ! $input->isInteractive();
$dryRun = $input->getOption('dry-run');
$conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $input->getOption('evaluate-max-visits'));
public function __invoke(
SymfonyStyle $io,
InputInterface $input,
#[Option('Also take into consideration short URLs which have reached their max amount of visits.')]
bool $evaluateMaxVisits = false,
#[Option('Delete short URLs with no confirmation', shortcut: 'f')] bool $force = false,
#[Option('Only check how many short URLs would be affected, without actually deleting them')]
bool $dryRun = false,
): int {
$conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $evaluateMaxVisits);
$force = $force || ! $input->isInteractive();
if (! $force && ! $dryRun) {
$io->warning([
@ -69,6 +58,7 @@ class DeleteExpiredShortUrlsCommand extends Command
$result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions);
$io->success(sprintf('%s expired short URLs have been deleted', $result));
return self::SUCCESS;
}
}

View File

@ -6,6 +6,7 @@ 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\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
@ -36,6 +37,7 @@ class ListShortUrlsCommand extends Command
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
private readonly TagsOption $tagsOption;
public function __construct(
private readonly ShortUrlListServiceInterface $shortUrlService,
@ -44,6 +46,7 @@ class ListShortUrlsCommand extends Command
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
@ -71,16 +74,20 @@ class ListShortUrlsCommand extends Command
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
)
->addOption(
'tags',
't',
InputOption::VALUE_REQUIRED,
'A comma-separated list of tags to filter results.',
'tags-all',
mode: InputOption::VALUE_NONE,
description: 'If --tag is provided, returns only short URLs including ALL of them',
)
->addOption(
'including-all-tags',
'i',
InputOption::VALUE_NONE,
'If tags is provided, returns only short URLs having ALL tags.',
'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',
@ -101,6 +108,12 @@ class ListShortUrlsCommand extends Command
'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,
@ -119,7 +132,6 @@ class ListShortUrlsCommand extends Command
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',
@ -134,33 +146,30 @@ class ListShortUrlsCommand extends Command
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('search-term');
$domain = $input->getOption('domain');
$tags = $input->getOption('tags');
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$tags = ! empty($tags) ? explode(',', $tags) : [];
$all = $input->getOption('all');
$startDate = $this->startDateOption->get($input, $output);
$endDate = $this->endDateOption->get($input, $output);
$orderBy = $this->processOrderBy($input);
$columnsMap = $this->resolveColumnsMap($input);
$tagsMode = $input->getOption('tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$data = [
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::DOMAIN => $domain,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'),
ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input),
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
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;
}
$columnsMap = $this->resolveColumnsMap($input);
do {
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
@ -168,7 +177,7 @@ class ListShortUrlsCommand extends Command
$continue = $result->hasNextPage() && $io->confirm(
sprintf('Continue with page <options=bold>%s</>?', $page),
false,
default: false,
);
} while ($continue);
@ -236,7 +245,7 @@ class ListShortUrlsCommand extends Command
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY;
}
if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) {
if ($input->getOption('show-api-key')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null =>
$shortUrl->authorApiKey?->name;
}

View File

@ -5,12 +5,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
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;
#[AsCommand(name: DeleteTagsCommand::NAME, description: 'Deletes one or more tags.')]
class DeleteTagsCommand extends Command
{
public const string NAME = 'tag:delete';
@ -20,24 +20,13 @@ class DeleteTagsCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Deletes one or more tags.')
->addOption(
'name',
't',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'The name of the tags to delete',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name');
/**
* @param string[] $tagNames
*/
public function __invoke(
SymfonyStyle $io,
#[Option('The name of the tags to delete', name: 'name', shortcut: 't')] array $tagNames = [],
): int {
if (empty($tagNames)) {
$io->warning('You have to provide at least one tag name');
return self::INVALID;
@ -45,6 +34,7 @@ class DeleteTagsCommand extends Command
$this->tagService->deleteTags($tagNames);
$io->success('Tags properly deleted');
return self::SUCCESS;
}
}

View File

@ -5,24 +5,34 @@ 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\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
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 function sprintf;
class GetTagVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'tag:visits';
private readonly DomainOption $domainOption;
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,
));
}
protected function configure(): void
@ -39,7 +49,10 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');
return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
));
}
/**

View File

@ -8,12 +8,13 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Attribute\AsCommand;
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_map;
#[AsCommand(ListTagsCommand::NAME, 'Lists existing tags.')]
class ListTagsCommand extends Command
{
public const string NAME = 'tag:list';
@ -23,16 +24,9 @@ class ListTagsCommand extends Command
parent::__construct();
}
protected function configure(): void
public function __invoke(SymfonyStyle $io): int
{
$this
->setName(self::NAME)
->setDescription('Lists existing tags.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
ShlinkTable::default($io)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return self::SUCCESS;
}

View File

@ -8,12 +8,12 @@ use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(RenameTagCommand::NAME, 'Renames one existing tag.')]
class RenameTagCommand extends Command
{
public const string NAME = 'tag:rename';
@ -23,21 +23,11 @@ class RenameTagCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Renames one existing tag.')
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the tag.')
->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName');
public function __invoke(
SymfonyStyle $io,
#[Argument('Current name of the tag.')] string $oldName,
#[Argument('New name of the tag.')] string $newName,
): int {
try {
$this->tagService->renameTag(Renaming::fromNames($oldName, $newName));
$io->success('Tag properly renamed.');

View File

@ -8,14 +8,17 @@ use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
use Shlinkio\Shlink\Core\Geolocation\GeolocationResult;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
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.',
)]
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
{
public const string NAME = 'visit:download-db';
@ -28,19 +31,9 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
parent::__construct();
}
protected function configure(): void
public function __invoke(SymfonyStyle $io): int
{
$this
->setName(self::NAME)
->setDescription(
'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.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->io = new SymfonyStyle($input, $output);
$this->io = $io;
try {
$result = $this->dbUpdater->checkDbUpdate($this);

View File

@ -4,23 +4,33 @@ 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\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'visit:non-orphan';
private readonly DomainOption $domainOption;
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,
));
}
protected function configure(): void
@ -35,7 +45,10 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
));
}
/**

View File

@ -4,11 +4,14 @@ 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\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;
@ -19,6 +22,17 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'visit:orphan';
private readonly DomainOption $domainOption;
public function __construct(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,
));
}
protected function configure(): void
{
$this
@ -37,7 +51,11 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
$rawType = $input->getOption('type');
$type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type));
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
type: $type,
));
}
/**

View File

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

View File

@ -13,13 +13,10 @@ use Symfony\Component\Console\Input\InputArgument;
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;
final readonly class ShortUrlDataInput
{
private readonly TagsOption $tagsOption;
public function __construct(Command $command, private bool $longUrlAsOption = false)
{
if ($longUrlAsOption) {
@ -28,13 +25,9 @@ final readonly class ShortUrlDataInput
$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::TAGS->value,
ShortUrlDataOption::TAGS->shortcut(),
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Tags to apply to the short URL',
)
->addOption(
ShortUrlDataOption::VALID_SINCE->value,
ShortUrlDataOption::VALID_SINCE->shortcut(),
@ -117,9 +110,8 @@ final readonly class ShortUrlDataInput
$maxVisits = $input->getOption('max-visits');
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
}
if (ShortUrlDataOption::TAGS->wasProvided($input)) {
$tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
$data[ShortUrlInputFilter::TAGS] = $tags;
if ($this->tagsOption->exists($input)) {
$data[ShortUrlInputFilter::TAGS] = $this->tagsOption->get($input);
}
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');

View File

@ -10,7 +10,6 @@ use function sprintf;
enum ShortUrlDataOption: string
{
case TAGS = 'tags';
case VALID_SINCE = 'valid-since';
case VALID_UNTIL = 'valid-until';
case MAX_VISITS = 'max-visits';
@ -21,7 +20,6 @@ enum ShortUrlDataOption: string
public function shortcut(): string|null
{
return match ($this) {
self::TAGS => 't',
self::VALID_SINCE => 's',
self::VALID_UNTIL => 'u',
self::MAX_VISITS => 'm',

View File

@ -0,0 +1,41 @@
<?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_unique;
readonly class TagsOption
{
public function __construct(Command $command, string $description)
{
$command
->addOption(
'tag',
't',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
$description,
);
}
/**
* Whether tags have been set or not, via `--tag` or `-t`
*/
public function exists(InputInterface $input): bool
{
return $input->hasParameterOption(['--tag', '-t']);
}
/**
* @return string[]
*/
public function get(InputInterface $input): array
{
return array_unique($input->getOption('tag'));
}
}

View File

@ -87,6 +87,15 @@ class ListShortUrlsTest extends CliTestCase
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
+------------+---------------+----------------------+--------------------------------------- Page 1 of 1 -------------------------------------------------+---------------------------+--------------+
OUTPUT];
yield 'exclude tags' => [['--exclude-tag=foo'], <<<OUTPUT
+--------------------+-------+-------------------------------------------+----------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+--------------------+-------+-------------------------------------------+----------------------------------+---------------------------+--------------+
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
+--------------------+-------+--------------------------------------- Page 1 of 1 --------------------------+---------------------------+--------------+
OUTPUT];
// phpcs:enable
}
}

View File

@ -91,11 +91,17 @@ class RoleResolverTest extends TestCase
[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' => [
$buildInput([
Role::DOMAIN_SPECIFIC->paramName() => 'example.com',
Role::AUTHORED_SHORT_URLS->paramName() => true,
Role::NO_ORPHAN_VISITS->paramName() => true,
]),
[
RoleDefinition::forAuthoredShortUrls(),
RoleDefinition::forDomain($domain),
RoleDefinition::forNoOrphanVisits(),
],
1,
];
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\DeleteKeyCommand;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class DeleteKeyCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & ApiKeyServiceInterface $apiKeyService;
protected function setUp(): void
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = CliTestUtils::testerForCommand(new DeleteKeyCommand($this->apiKeyService));
}
#[Test]
public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void
{
$this->apiKeyService->expects($this->never())->method('deleteByName');
$this->apiKeyService->expects($this->never())->method('listKeys');
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
self::assertEquals(Command::INVALID, $exitCode);
}
#[Test]
public function confirmationIsSkippedInNonInteractiveMode(): void
{
$this->apiKeyService->expects($this->once())->method('deleteByName');
$this->apiKeyService->expects($this->never())->method('listKeys');
$exitCode = $this->commandTester->execute(['name' => 'key to delete'], ['interactive' => false]);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode);
self::assertStringNotContainsString('Are you sure you want to delete the API key?', $output);
}
#[Test]
public function keyIsNotDeletedIfConfirmationIsCancelled(): void
{
$this->apiKeyService->expects($this->never())->method('deleteByName');
$this->apiKeyService->expects($this->never())->method('listKeys');
$this->commandTester->setInputs(['no']);
$exitCode = $this->commandTester->execute(['name' => 'key_to_delete']);
self::assertEquals(Command::INVALID, $exitCode);
}
#[Test]
public function existingApiKeyNamesAreListedIfNoArgumentIsProvidedInInteractiveMode(): void
{
$name = 'the key to delete';
$this->apiKeyService->expects($this->once())->method('deleteByName')->with($name);
$this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
]);
$this->commandTester->setInputs([$name, 'y']);
$exitCode = $this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('What API key do you want to delete?', $output);
self::assertStringContainsString('API key "the key to delete" properly deleted', $output);
self::assertEquals(Command::SUCCESS, $exitCode);
}
#[Test]
public function errorIsReturnedIfDisableByKeyThrowsException(): void
{
$apiKey = 'key to delete';
$e = ApiKeyNotFoundException::forName($apiKey);
$this->apiKeyService->expects($this->once())->method('deleteByName')->with($apiKey)->willThrowException($e);
$this->apiKeyService->expects($this->never())->method('listKeys');
$exitCode = $this->commandTester->execute(['name' => $apiKey]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($e->getMessage(), $output);
self::assertEquals(Command::FAILURE, $exitCode);
}
}

View File

@ -31,12 +31,9 @@ class DisableKeyCommandTest extends TestCase
public function providedApiKeyIsDisabled(): void
{
$apiKey = 'abcd1234';
$this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey);
$this->apiKeyService->expects($this->never())->method('disableByName');
$this->apiKeyService->expects($this->once())->method('disableByName')->with($apiKey);
$exitCode = $this->commandTester->execute([
'key-or-name' => $apiKey,
]);
$exitCode = $this->commandTester->execute(['name' => $apiKey]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
@ -44,55 +41,15 @@ class DisableKeyCommandTest extends TestCase
}
#[Test]
public function providedApiKeyIsDisabledByName(): void
{
$name = 'the key to delete';
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name);
$this->apiKeyService->expects($this->never())->method('disableByKey');
$exitCode = $this->commandTester->execute([
'key-or-name' => $name,
'--by-name' => true,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
self::assertEquals(Command::SUCCESS, $exitCode);
}
#[Test]
public function errorIsReturnedIfDisableByKeyThrowsException(): void
public function errorIsReturnedIfDisableByNameThrowsException(): void
{
$apiKey = 'abcd1234';
$expectedMessage = 'API key "abcd1234" does not exist.';
$this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey)->willThrowException(
$this->apiKeyService->expects($this->once())->method('disableByName')->with($apiKey)->willThrowException(
new InvalidArgumentException($expectedMessage),
);
$this->apiKeyService->expects($this->never())->method('disableByName');
$exitCode = $this->commandTester->execute([
'key-or-name' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($expectedMessage, $output);
self::assertEquals(Command::FAILURE, $exitCode);
}
#[Test]
public function errorIsReturnedIfDisableByNameThrowsException(): void
{
$name = 'the key to delete';
$expectedMessage = 'API key "the key to delete" does not exist.';
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name)->willThrowException(
new InvalidArgumentException($expectedMessage),
);
$this->apiKeyService->expects($this->never())->method('disableByKey');
$exitCode = $this->commandTester->execute([
'key-or-name' => $name,
'--by-name' => true,
]);
$exitCode = $this->commandTester->execute(['name' => $apiKey]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($expectedMessage, $output);
@ -103,7 +60,6 @@ class DisableKeyCommandTest extends TestCase
public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void
{
$this->apiKeyService->expects($this->never())->method('disableByName');
$this->apiKeyService->expects($this->never())->method('disableByKey');
$this->apiKeyService->expects($this->never())->method('listKeys');
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
@ -121,7 +77,6 @@ class DisableKeyCommandTest extends TestCase
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)),
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
]);
$this->apiKeyService->expects($this->never())->method('disableByKey');
$this->commandTester->setInputs([$name]);
$exitCode = $this->commandTester->execute([]);

View File

@ -35,7 +35,7 @@ class InitialApiKeyCommandTest extends TestCase
$this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result);
$this->commandTester->execute(
['apiKey' => 'the_key'],
['api-key' => 'the_key'],
['verbosity' => $verbose ? OutputInterface::VERBOSITY_VERBOSE : OutputInterface::VERBOSITY_NORMAL],
);
$output = $this->commandTester->getDisplay();

View File

@ -28,13 +28,13 @@ class ReadEnvVarCommandTest extends TestCase
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('foo is not a valid Shlink environment variable');
$this->commandTester->execute(['envVar' => 'foo']);
$this->commandTester->execute(['env-var' => 'foo']);
}
#[Test]
public function valueIsPrintedIfProvidedEnvVarIsValid(): void
{
$this->commandTester->execute(['envVar' => EnvVars::BASE_PATH->value]);
$this->commandTester->execute(['env-var' => EnvVars::BASE_PATH->value]);
$output = $this->commandTester->getDisplay();
self::assertStringNotContainsString('Select the env var to read', $output);

View File

@ -41,7 +41,7 @@ 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(

View File

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

View File

@ -114,6 +114,7 @@ class MatomoSendVisitsCommandTest extends TestCase
}
/**
* @param list<string> $input
* @return array{string, int, MatomoSendVisitsCommand}
*/
private function executeCommand(

View File

@ -64,6 +64,21 @@ 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->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url',
);
$this->commandTester->setInputs([$shortUrl->getLongUrl()]);
$this->commandTester->execute([]);
}
#[Test]
public function providingNonUniqueSlugOutputsError(): void
{
@ -95,7 +110,7 @@ class CreateShortUrlCommandTest extends TestCase
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
'--tags' => ['foo,bar', 'baz', 'boo,zar,baz'],
'--tag' => ['foo', 'bar', 'baz', 'boo', 'zar', 'baz'],
]);
$output = $this->commandTester->getDisplay();

View File

@ -64,6 +64,9 @@ class DeleteShortUrlCommandTest extends TestCase
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
}
/**
* @param list<string> $retryAnswer
*/
#[Test, DataProvider('provideRetryDeleteAnswers')]
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
array $retryAnswer,

View File

@ -27,6 +27,9 @@ 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
{

View File

@ -50,6 +50,19 @@ class GetShortUrlVisitsCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $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]
public function providingDateFlagsTheListGetsFiltered(): void
{
@ -94,7 +107,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(): 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(

View File

@ -25,8 +25,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function explode;
class ListShortUrlsCommandTest extends TestCase
{
private CommandTester $commandTester;
@ -209,6 +207,9 @@ class ListShortUrlsCommandTest extends TestCase
string $tagsMode,
string|null $startDate = null,
string|null $endDate = null,
array $excludeTags = [],
string $excludeTagsMode = TagsMode::ANY->value,
string|null $apiKeyName = null,
): void {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
'page' => $page,
@ -217,6 +218,9 @@ class ListShortUrlsCommandTest extends TestCase
'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([])));
$this->commandTester->setInputs(['n']);
@ -227,13 +231,13 @@ 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], 1, null, [], TagsMode::ALL->value];
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value];
yield [
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']],
$page,
$searchTerm,
explode(',', $tags),
$tags,
TagsMode::ANY->value,
];
yield [
@ -262,6 +266,29 @@ class ListShortUrlsCommandTest extends TestCase
$startDate,
$endDate,
];
yield [
['--exclude-tag' => ['foo', 'bar'], '--exclude-tags-all' => true],
1,
null,
[],
TagsMode::ANY->value,
null,
null,
['foo', 'bar'],
TagsMode::ALL->value,
];
yield [
['--api-key-name' => 'foo'],
1,
null,
[],
TagsMode::ANY->value,
null,
null,
[],
TagsMode::ANY->value,
'foo',
];
}
#[Test, DataProvider('provideOrderBy')]

View File

@ -45,6 +45,19 @@ class ResolveUrlCommandTest extends TestCase
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
{

View File

@ -41,7 +41,7 @@ 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(

View File

@ -36,8 +36,8 @@ class RenameTagCommandTest extends TestCase
)->willThrowException(TagNotFoundException::fromTag('foo'));
$this->commandTester->execute([
'oldName' => $oldName,
'newName' => $newName,
'old-name' => $oldName,
'new-name' => $newName,
]);
$output = $this->commandTester->getDisplay();
@ -54,8 +54,8 @@ class RenameTagCommandTest extends TestCase
)->willReturn(new Tag($newName));
$this->commandTester->execute([
'oldName' => $oldName,
'newName' => $newName,
'old-name' => $oldName,
'new-name' => $newName,
]);
$output = $this->commandTester->getDisplay();

View File

@ -41,7 +41,7 @@ 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])),

View File

@ -38,7 +38,7 @@ class GetOrphanVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(array $args, bool $includesType): void
{
$visit = Visit::forBasePath(Visitor::fromParams('bar', 'foo', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback(
fn (OrphanVisitsParams $param) => (

View File

@ -63,7 +63,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 +81,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 +107,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 +134,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 +204,9 @@ class LocateVisitsCommandTest extends TestCase
self::assertStringContainsString('The --all flag has no effect on its own', $output);
}
/**
* @param list<string> $inputs
*/
#[Test, DataProvider('provideAbortInputs')]
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
{

View File

@ -40,9 +40,9 @@ class CliTestUtils
public static function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester
{
$app = new Application();
$app->add($mainCommand);
$app->addCommand($mainCommand);
foreach ($extraCommands as $command) {
$app->add($command);
$app->addCommand($command);
}
return new CommandTester($mainCommand);

View File

@ -51,7 +51,6 @@ class ShlinkTableTest extends TestCase
$ref = new ReflectionObject($instance);
$baseTable = $ref->getProperty('baseTable');
$baseTable->setAccessible(true);
self::assertInstanceOf(Table::class, $baseTable->getValue($instance));
}

View File

@ -33,7 +33,6 @@ return [
Config\Options\RedirectOptions::class => [Config\Options\RedirectOptions::class, 'fromEnv'],
Config\Options\UrlShortenerOptions::class => [Config\Options\UrlShortenerOptions::class, 'fromEnv'],
Config\Options\TrackingOptions::class => [Config\Options\TrackingOptions::class, 'fromEnv'],
Config\Options\QrCodeOptions::class => [Config\Options\QrCodeOptions::class, 'fromEnv'],
Config\Options\RabbitMqOptions::class => [Config\Options\RabbitMqOptions::class, 'fromEnv'],
Config\Options\RobotsOptions::class => [Config\Options\RobotsOptions::class, 'fromEnv'],
Config\Options\RealTimeUpdatesOptions::class => [Config\Options\RealTimeUpdatesOptions::class, 'fromEnv'],
@ -103,7 +102,6 @@ return [
Action\RedirectAction::class => ConfigAbstractFactory::class,
Action\PixelAction::class => ConfigAbstractFactory::class,
Action\QrCodeAction::class => ConfigAbstractFactory::class,
Action\RobotsAction::class => ConfigAbstractFactory::class,
EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class,
@ -168,7 +166,7 @@ return [
],
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class],
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
Visit\VisitsStatsHelper::class => ['em'],
Visit\VisitsStatsHelper::class => ['em', Config\Options\UrlShortenerOptions::class],
Tag\TagService::class => ['em', Tag\Repository\TagRepository::class],
ShortUrl\DeleteShortUrlService::class => [
'em',
@ -209,12 +207,6 @@ return [
Util\RedirectResponseHelper::class,
],
Action\PixelAction::class => [ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class],
Action\QrCodeAction::class => [
ShortUrl\ShortUrlResolver::class,
ShortUrl\Helper\ShortUrlStringifier::class,
'Logger_Shlink',
Config\Options\QrCodeOptions::class,
],
Action\RobotsAction::class => [Crawling\CrawlingHelper::class, Config\Options\RobotsOptions::class],
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [

View File

@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action\Model;
use Endroid\QrCode\Color\Color;
use Endroid\QrCode\Color\ColorInterface;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\RoundBlockSizeMode;
use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\Writer\SvgWriter;
use Endroid\QrCode\Writer\WriterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Config\Options\QrCodeOptions;
use function ctype_xdigit;
use function hexdec;
use function ltrim;
use function max;
use function min;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function strlen;
use function strtolower;
use function substr;
use function trim;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
/** @deprecated */
final readonly class QrCodeParams
{
private const int MIN_SIZE = 50;
private const int MAX_SIZE = 1000;
private const array SUPPORTED_FORMATS = ['png', 'svg'];
private function __construct(
public int $size,
public int $margin,
public WriterInterface $writer,
public array $writerOptions,
public ErrorCorrectionLevel $errorCorrectionLevel,
public RoundBlockSizeMode $roundBlockSizeMode,
public ColorInterface $color,
public ColorInterface $bgColor,
public bool $disableLogo,
) {
}
public static function fromRequest(ServerRequestInterface $request, QrCodeOptions $defaults): self
{
$query = $request->getQueryParams();
[$writer, $writerOptions] = self::resolveWriterAndWriterOptions($query, $defaults);
return new self(
size: self::resolveSize($query, $defaults),
margin: self::resolveMargin($query, $defaults),
writer: $writer,
writerOptions: $writerOptions,
errorCorrectionLevel: self::resolveErrorCorrection($query, $defaults),
roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults),
color: self::resolveColor($query, $defaults),
bgColor: self::resolveBackgroundColor($query, $defaults),
disableLogo: isset($query['logo']) && $query['logo'] === 'disable',
);
}
private static function resolveSize(array $query, QrCodeOptions $defaults): int
{
$size = (int) ($query['size'] ?? $defaults->size);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
return min($size, self::MAX_SIZE);
}
private static function resolveMargin(array $query, QrCodeOptions $defaults): int
{
$margin = $query['margin'] ?? (string) $defaults->margin;
$intMargin = (int) $margin;
if ($margin !== (string) $intMargin) {
return 0;
}
return max($intMargin, 0);
}
/**
* @return array{WriterInterface, array}
*/
private static function resolveWriterAndWriterOptions(array $query, QrCodeOptions $defaults): array
{
$qFormat = self::normalizeParam($query['format'] ?? '');
$format = contains($qFormat, self::SUPPORTED_FORMATS) ? $qFormat : self::normalizeParam($defaults->format);
return match ($format) {
'svg' => [new SvgWriter(), []],
default => [new PngWriter(), [PngWriter::WRITER_OPTION_NUMBER_OF_COLORS => null]],
};
}
private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevel
{
$errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection);
return match ($errorCorrectionLevel) {
'h' => ErrorCorrectionLevel::High,
'q' => ErrorCorrectionLevel::Quartile,
'm' => ErrorCorrectionLevel::Medium,
default => ErrorCorrectionLevel::Low, // 'l'
};
}
private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeMode
{
$doNotRoundBlockSize = isset($query['roundBlockSize'])
? $query['roundBlockSize'] === 'false'
: ! $defaults->roundBlockSize;
return $doNotRoundBlockSize ? RoundBlockSizeMode::None : RoundBlockSizeMode::Margin;
}
private static function resolveColor(array $query, QrCodeOptions $defaults): ColorInterface
{
$color = self::normalizeParam($query['color'] ?? $defaults->color);
return self::parseHexColor($color, DEFAULT_QR_CODE_COLOR);
}
private static function resolveBackgroundColor(array $query, QrCodeOptions $defaults): ColorInterface
{
$bgColor = self::normalizeParam($query['bgColor'] ?? $defaults->bgColor);
return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR);
}
private static function parseHexColor(string $hexColor, string|null $fallback): Color
{
$hexColor = ltrim($hexColor, '#');
if (! ctype_xdigit($hexColor) && $fallback !== null) {
return self::parseHexColor($fallback, null);
}
if (strlen($hexColor) === 3) {
return new Color(
(int) hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)),
(int) hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)),
(int) hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)),
);
}
return new Color(
(int) hexdec(substr($hexColor, 0, 2)),
(int) hexdec(substr($hexColor, 2, 2)),
(int) hexdec(substr($hexColor, 4, 2)),
);
}
private static function normalizeParam(string $param): string
{
return strtolower(trim($param));
}
}

View File

@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\Result\ResultInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
use Shlinkio\Shlink\Core\Config\Options\QrCodeOptions;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
/** @deprecated */
readonly class QrCodeAction implements MiddlewareInterface
{
public function __construct(
private ShortUrlResolverInterface $urlResolver,
private ShortUrlStringifierInterface $stringifier,
private LoggerInterface $logger,
private QrCodeOptions $options,
) {
}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
try {
$shortUrl = $this->options->enabledForDisabledShortUrls
? $this->urlResolver->resolvePublicShortUrl($identifier)
: $this->urlResolver->resolveEnabledShortUrl($identifier);
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
return $handler->handle($request);
}
$params = QrCodeParams::fromRequest($request, $this->options);
$qrCodeBuilder = new Builder(
writer: $params->writer,
writerOptions: $params->writerOptions,
data: $this->stringifier->stringify($shortUrl),
errorCorrectionLevel: $params->errorCorrectionLevel,
size: $params->size,
margin: $params->margin,
roundBlockSizeMode: $params->roundBlockSizeMode,
foregroundColor: $params->color,
backgroundColor: $params->bgColor,
);
return new QrCodeResponse($this->buildQrCode($qrCodeBuilder, $params));
}
private function buildQrCode(Builder $qrCodeBuilder, QrCodeParams $params): ResultInterface
{
$logoUrl = $this->options->logoUrl;
if ($logoUrl === null || $params->disableLogo) {
return $qrCodeBuilder->build();
}
return $qrCodeBuilder->build(
logoPath: $logoUrl,
logoResizeToHeight: (int) ($params->size / 4),
);
}
}

View File

@ -6,33 +6,7 @@ namespace Shlinkio\Shlink\Core\Config;
final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface
{
public function invalidShortUrlRedirect(): string|null
{
return null;
}
public function hasInvalidShortUrlRedirect(): bool
{
return false;
}
public function regular404Redirect(): string|null
{
return null;
}
public function hasRegular404Redirect(): bool
{
return false;
}
public function baseUrlRedirect(): string|null
{
return null;
}
public function hasBaseUrlRedirect(): bool
{
return false;
}
private(set) string|null $invalidShortUrlRedirect = null;
private(set) string|null $regular404Redirect = null;
private(set) string|null $baseUrlRedirect = null;
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use Shlinkio\Shlink\Core\Config\Options\ExtraPathMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use function date_default_timezone_get;
@ -13,14 +14,6 @@ use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Config\parseEnvVar;
use function sprintf;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
@ -41,6 +34,8 @@ enum EnvVars: string
case CACHE_NAMESPACE = 'CACHE_NAMESPACE';
case REDIS_SERVERS = 'REDIS_SERVERS';
case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
case REDIS_SERVERS_USER = 'REDIS_SERVERS_USER';
case REDIS_SERVERS_PASSWORD = 'REDIS_SERVERS_PASSWORD';
case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED';
case MERCURE_ENABLED = 'MERCURE_ENABLED';
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
@ -93,27 +88,6 @@ enum EnvVars: string
case TRUSTED_PROXIES = 'TRUSTED_PROXIES';
case LOGS_FORMAT = 'LOGS_FORMAT';
/** @deprecated Use REDIRECT_EXTRA_PATH */
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
/** @deprecated */
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
/** @deprecated */
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
/** @deprecated */
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
/** @deprecated */
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
/** @deprecated */
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
/** @deprecated */
case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS';
/** @deprecated */
case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR';
/** @deprecated */
case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR';
/** @deprecated */
case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL';
public function loadFromEnv(): mixed
{
return env($this->value) ?? $this->loadFromFileEnv() ?? $this->defaultValue();
@ -150,9 +124,7 @@ enum EnvVars: string
self::SHORT_URL_TRAILING_SLASH => false,
self::DEFAULT_DOMAIN, self::BASE_PATH => '',
self::CACHE_NAMESPACE => 'Shlink',
// Deprecated. In Shlink 5.0.0, add default value for REDIRECT_EXTRA_PATH_MODE
self::REDIRECT_APPEND_EXTRA_PATH => false,
// self::REDIRECT_EXTRA_PATH_MODE => ExtraPathMode::DEFAULT->value,
self::REDIRECT_EXTRA_PATH_MODE => ExtraPathMode::DEFAULT->value,
self::REDIS_PUB_SUB_ENABLED,
self::MATOMO_ENABLED,
@ -171,15 +143,6 @@ enum EnvVars: string
self::MERCURE_ENABLED => self::MERCURE_PUBLIC_HUB_URL->existsInEnv(),
self::MERCURE_INTERNAL_HUB_URL => self::MERCURE_PUBLIC_HUB_URL->loadFromEnv(),
self::DEFAULT_QR_CODE_SIZE, => DEFAULT_QR_CODE_SIZE,
self::DEFAULT_QR_CODE_MARGIN, => DEFAULT_QR_CODE_MARGIN,
self::DEFAULT_QR_CODE_FORMAT, => DEFAULT_QR_CODE_FORMAT,
self::DEFAULT_QR_CODE_ERROR_CORRECTION, => DEFAULT_QR_CODE_ERROR_CORRECTION,
self::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, => DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
self::QR_CODE_FOR_DISABLED_SHORT_URLS, => DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
self::DEFAULT_QR_CODE_COLOR, => DEFAULT_QR_CODE_COLOR,
self::DEFAULT_QR_CODE_BG_COLOR, => DEFAULT_QR_CODE_BG_COLOR,
self::RABBITMQ_ENABLED, self::RABBITMQ_USE_SSL => false,
self::RABBITMQ_PORT => 5672,
self::RABBITMQ_VHOST => '/',

View File

@ -6,15 +6,7 @@ namespace Shlinkio\Shlink\Core\Config;
interface NotFoundRedirectConfigInterface
{
public function invalidShortUrlRedirect(): string|null;
public function hasInvalidShortUrlRedirect(): bool;
public function regular404Redirect(): string|null;
public function hasRegular404Redirect(): bool;
public function baseUrlRedirect(): string|null;
public function hasBaseUrlRedirect(): bool;
public string|null $invalidShortUrlRedirect { get; }
public string|null $regular404Redirect { get; }
public string|null $baseUrlRedirect { get; }
}

View File

@ -32,10 +32,9 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
UriInterface $currentUri,
): ResponseInterface|null {
$urlToRedirectTo = match (true) {
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(),
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(),
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
$config->invalidShortUrlRedirect(),
$notFoundType->isBaseUrl() => $config->baseUrlRedirect,
$notFoundType->isRegularNotFound() => $config->regular404Redirect,
$notFoundType->isInvalidShortUrl() => $config->invalidShortUrlRedirect,
default => null,
};

View File

@ -6,12 +6,12 @@ namespace Shlinkio\Shlink\Core\Config;
use JsonSerializable;
final class NotFoundRedirects implements JsonSerializable
final readonly class NotFoundRedirects implements JsonSerializable
{
private function __construct(
public readonly string|null $baseUrlRedirect,
public readonly string|null $regular404Redirect,
public readonly string|null $invalidShortUrlRedirect,
public string|null $baseUrlRedirect,
public string|null $regular404Redirect,
public string|null $invalidShortUrlRedirect,
) {
}
@ -30,7 +30,7 @@ final class NotFoundRedirects implements JsonSerializable
public static function fromConfig(NotFoundRedirectConfigInterface $config): self
{
return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect());
return new self($config->baseUrlRedirect, $config->regular404Redirect, $config->invalidShortUrlRedirect);
}
public function jsonSerialize(): array

View File

@ -6,8 +6,6 @@ namespace Shlinkio\Shlink\Core\Config\Options;
use Shlinkio\Shlink\Core\Config\EnvVars;
use function sprintf;
final class AppOptions
{
public function __construct(public string $name = 'Shlink', public string $version = '4.0.0')
@ -19,9 +17,4 @@ final class AppOptions
$version = EnvVars::isDevEnv() ? 'latest' : '%SHLINK_VERSION%';
return new self(version: $version);
}
public function __toString(): string
{
return sprintf('%s:v%s', $this->name, $this->version);
}
}

View File

@ -37,7 +37,19 @@ final readonly class CorsOptions
);
}
public function responseWithAllowOrigin(RequestInterface $request, ResponseInterface $response): ResponseInterface
/**
* Creates a new response which contains the CORS headers that apply to provided request
*/
public function responseWithCorsHeaders(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
$response = $this->responseWithAllowOrigin($request, $response);
return $this->allowCredentials ? $response->withHeader('Access-Control-Allow-Credentials', 'true') : $response;
}
/**
* If applicable, a new response with the appropriate Access-Control-Allow-Origin header is returned
*/
private function responseWithAllowOrigin(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
if ($this->allowOrigins === '*') {
return $response->withHeader('Access-Control-Allow-Origin', '*');

View File

@ -10,48 +10,18 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigInterface
{
public function __construct(
public string|null $invalidShortUrl = null,
public string|null $regular404 = null,
public string|null $baseUrl = null,
public string|null $invalidShortUrlRedirect = null,
public string|null $regular404Redirect = null,
public string|null $baseUrlRedirect = null,
) {
}
public static function fromEnv(): self
{
return new self(
invalidShortUrl: EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(),
regular404: EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(),
baseUrl: EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(),
invalidShortUrlRedirect: EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(),
regular404Redirect: EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(),
baseUrlRedirect: EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(),
);
}
public function invalidShortUrlRedirect(): string|null
{
return $this->invalidShortUrl;
}
public function hasInvalidShortUrlRedirect(): bool
{
return $this->invalidShortUrl !== null;
}
public function regular404Redirect(): string|null
{
return $this->regular404;
}
public function hasRegular404Redirect(): bool
{
return $this->regular404 !== null;
}
public function baseUrlRedirect(): string|null
{
return $this->baseUrl;
}
public function hasBaseUrlRedirect(): bool
{
return $this->baseUrl !== null;
}
}

View File

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config\Options;
use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
/** @deprecated */
final readonly class QrCodeOptions
{
public function __construct(
public int $size = DEFAULT_QR_CODE_SIZE,
public int $margin = DEFAULT_QR_CODE_MARGIN,
public string $format = DEFAULT_QR_CODE_FORMAT,
public string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION,
public bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
public string $color = DEFAULT_QR_CODE_COLOR,
public string $bgColor = DEFAULT_QR_CODE_BG_COLOR,
public string|null $logoUrl = null,
) {
}
public static function fromEnv(): self
{
return new self(
size: (int) EnvVars::DEFAULT_QR_CODE_SIZE->loadFromEnv(),
margin: (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(),
format: EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(),
errorCorrection: EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv(),
roundBlockSize: (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(),
enabledForDisabledShortUrls: (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv(),
color: EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(),
bgColor: EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(),
logoUrl: EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(),
);
}
}

View File

@ -36,15 +36,8 @@ final readonly class UrlShortenerOptions
MIN_SHORT_CODES_LENGTH,
);
// Deprecated. Initialize extra path from REDIRECT_APPEND_EXTRA_PATH.
$appendExtraPath = EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv();
$extraPathMode = $appendExtraPath ? ExtraPathMode::APPEND : ExtraPathMode::DEFAULT;
// If REDIRECT_EXTRA_PATH_MODE was explicitly provided, it has precedence
$extraPathModeFromEnv = EnvVars::REDIRECT_EXTRA_PATH_MODE->loadFromEnv();
if ($extraPathModeFromEnv !== null) {
$extraPathMode = ExtraPathMode::tryFrom($extraPathModeFromEnv) ?? ExtraPathMode::DEFAULT;
}
$extraPathMode = ExtraPathMode::tryFrom($extraPathModeFromEnv) ?? ExtraPathMode::DEFAULT;
return new self(
defaultDomain: EnvVars::DEFAULT_DOMAIN->loadFromEnv(),

View File

@ -15,9 +15,9 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
private function __construct(
public readonly string $authority,
private string|null $baseUrlRedirect = null,
private string|null $regular404Redirect = null,
private string|null $invalidShortUrlRedirect = null,
private(set) string|null $baseUrlRedirect = null,
private(set) string|null $regular404Redirect = null,
private(set) string|null $invalidShortUrlRedirect = null,
) {
}
@ -31,36 +31,6 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
return $this->authority;
}
public function invalidShortUrlRedirect(): string|null
{
return $this->invalidShortUrlRedirect;
}
public function hasInvalidShortUrlRedirect(): bool
{
return $this->invalidShortUrlRedirect !== null;
}
public function regular404Redirect(): string|null
{
return $this->regular404Redirect;
}
public function hasRegular404Redirect(): bool
{
return $this->regular404Redirect !== null;
}
public function baseUrlRedirect(): string|null
{
return $this->baseUrlRedirect;
}
public function hasBaseUrlRedirect(): bool
{
return $this->baseUrlRedirect !== null;
}
public function configureNotFoundRedirects(NotFoundRedirects $redirects): void
{
$this->baseUrlRedirect = $redirects->baseUrlRedirect;

View File

@ -15,9 +15,7 @@ final readonly class ShortUrlCreated implements JsonSerializable, JsonUnserializ
public function jsonSerialize(): array
{
return [
'shortUrlId' => $this->shortUrlId,
];
return ['shortUrlId' => $this->shortUrlId];
}
public static function fromPayload(array $payload): self

View File

@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use function array_reverse;
use function explode;
use function implode;
/**
* Decorates a middleware to make sure it gets called with a list of reversed addresses in `X-Forwarded-For`.
*
* This is a workaround for a change in behavior introduced in akrabat/ip-address-middleware 2.5, which now
* takes the first non-trusted-proxy address in that header, starting from the right, instead of the first
* address starting from the left.
* That change breaks Shlink's visitor IP resolution when more than one proxy is used, and trusted proxies
* are not explicitly set for akrabat/ip-address-middleware (which Shlink does not do).
*
* A proper solution would require allowing trusted proxies to be configurable, and apply this logic conditionally, only
* if trusted proxies are not set.
*
* @see https://github.com/akrabat/ip-address-middleware/pull/51
* @deprecated Remove in future major version, and enforce users with multiple reverse proxies to provide the list via
* TRUSTED_PROXIES
*/
readonly class ReverseForwardedAddressesMiddlewareDecorator implements MiddlewareInterface
{
public const string FORWARDED_FOR_HEADER = 'X-Forwarded-For';
public function __construct(private MiddlewareInterface $wrappedMiddleware)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->hasHeader(self::FORWARDED_FOR_HEADER)) {
$request = $request->withHeader(
self::FORWARDED_FOR_HEADER,
implode(',', array_reverse(explode(',', $request->getHeaderLine(self::FORWARDED_FOR_HEADER)))),
);
}
return $this->wrappedMiddleware->process($request, $handler);
}
}

View File

@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Paginator\Paginator;
abstract class AbstractInfinitePaginableListParams
{
private const FIRST_PAGE = 1;
private const int FIRST_PAGE = 1;
public readonly int $page;
public readonly int $itemsPerPage;

View File

@ -12,21 +12,27 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlsParams
/**
* Represents all the params that can be used to filter a list of short URLs
*/
final readonly class ShortUrlsParams
{
public const DEFAULT_ITEMS_PER_PAGE = 10;
public const int DEFAULT_ITEMS_PER_PAGE = 10;
private function __construct(
public readonly int $page,
public readonly int $itemsPerPage,
public readonly string|null $searchTerm,
public readonly array $tags,
public readonly Ordering $orderBy,
public readonly DateRange|null $dateRange,
public readonly bool $excludeMaxVisitsReached,
public readonly bool $excludePastValidUntil,
public readonly TagsMode $tagsMode = TagsMode::ANY,
public readonly string|null $domain = null,
public int $page,
public int $itemsPerPage,
public string|null $searchTerm,
public array $tags,
public Ordering $orderBy,
public DateRange|null $dateRange,
public bool $excludeMaxVisitsReached,
public bool $excludePastValidUntil,
public TagsMode $tagsMode = TagsMode::ANY,
public string|null $domain = null,
public array $excludeTags = [],
public TagsMode $excludeTagsMode = TagsMode::ANY,
public string|null $apiKeyName = null,
) {
}
@ -61,6 +67,11 @@ final class ShortUrlsParams
excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL),
tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)),
domain: $inputFilter->getValue(ShortUrlsParamsInputFilter::DOMAIN),
excludeTags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS),
excludeTagsMode: self::resolveTagsMode(
$inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE),
),
apiKeyName: $inputFilter->getValue(ShortUrlsParamsInputFilter::API_KEY_NAME),
);
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
use Laminas\InputFilter\Input;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator\InArray;
use Shlinkio\Shlink\Common\Paginator\Paginator;
@ -19,14 +20,17 @@ class ShortUrlsParamsInputFilter extends InputFilter
public const string PAGE = 'page';
public const string SEARCH_TERM = 'searchTerm';
public const string TAGS = 'tags';
public const string TAGS_MODE = 'tagsMode';
public const string EXCLUDE_TAGS = 'excludeTags';
public const string EXCLUDE_TAGS_MODE = 'excludeTagsMode';
public const string START_DATE = 'startDate';
public const string END_DATE = 'endDate';
public const string ITEMS_PER_PAGE = 'itemsPerPage';
public const string TAGS_MODE = 'tagsMode';
public const string ORDER_BY = 'orderBy';
public const string EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached';
public const string EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil';
public const string DOMAIN = 'domain';
public const string API_KEY_NAME = 'apiKeyName';
public function __construct(array $data)
{
@ -45,13 +49,10 @@ class ShortUrlsParamsInputFilter extends InputFilter
$this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS));
$this->add(InputFactory::tags(self::TAGS));
$this->add($this->createTagsModeInput(self::TAGS_MODE));
$tagsMode = InputFactory::basic(self::TAGS_MODE);
$tagsMode->getValidatorChain()->attach(new InArray([
'haystack' => enumValues(TagsMode::class),
'strict' => InArray::COMPARE_STRICT,
]));
$this->add($tagsMode);
$this->add(InputFactory::tags(self::EXCLUDE_TAGS));
$this->add($this->createTagsModeInput(self::EXCLUDE_TAGS_MODE));
$this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class)));
@ -59,5 +60,17 @@ class ShortUrlsParamsInputFilter extends InputFilter
$this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL));
$this->add(InputFactory::basic(self::DOMAIN));
$this->add(InputFactory::basic(self::API_KEY_NAME));
}
private function createTagsModeInput(string $name): Input
{
$tagsMode = InputFactory::basic($name);
$tagsMode->getValidatorChain()->attach(new InArray([
'haystack' => enumValues(TagsMode::class),
'strict' => InArray::COMPARE_STRICT,
]));
return $tagsMode;
}
}

View File

@ -15,22 +15,33 @@ use function strtolower;
class ShortUrlsCountFiltering
{
public readonly bool $searchIncludesDefaultDomain;
public readonly string|null $apiKeyName;
/**
* @param $defaultDomain - Used only to determine if search term includes default domain
*/
public function __construct(
public readonly string|null $searchTerm = null,
public readonly array $tags = [],
public readonly TagsMode|null $tagsMode = null,
public readonly TagsMode $tagsMode = TagsMode::ANY,
public readonly DateRange|null $dateRange = null,
public readonly bool $excludeMaxVisitsReached = false,
public readonly bool $excludePastValidUntil = false,
public readonly ApiKey|null $apiKey = null,
string|null $defaultDomain = null,
public readonly string|null $domain = null,
public readonly array $excludeTags = [],
public readonly TagsMode $excludeTagsMode = TagsMode::ANY,
string|null $apiKeyName = null,
) {
$this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains(
strtolower($defaultDomain),
strtolower($searchTerm),
);
// Filtering by API key name is only allowed if the API key used in the request is an admin one, or it matches
// the API key name
$this->apiKeyName = $apiKey?->name === $apiKeyName || ApiKey::isAdmin($apiKey) ? $apiKeyName : null;
}
public static function fromParams(ShortUrlsParams $params, ApiKey|null $apiKey, string $defaultDomain): self
@ -45,6 +56,9 @@ class ShortUrlsCountFiltering
$apiKey,
$defaultDomain,
$params->domain,
$params->excludeTags,
$params->excludeTagsMode,
$params->apiKeyName,
);
}
}

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