Compare commits

...

115 Commits

Author SHA1 Message Date
Alejandro Celaya
ff77d8b149 Merge pull request #2479 from shlinkio/develop
Release 4.5.1
2025-08-24 11:28:44 +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
Alejandro Celaya
4520ba50bf Merge pull request #2471 from shlinkio/develop
Release 4.5.0
2025-07-24 20:00:42 +02:00
Alejandro Celaya
d2514b7555 Merge pull request #2470 from acelaya-forks/feature/release-4.5.0
Add v4.5.0 to changelog
2025-07-24 12:11:03 +02:00
Alejandro Celaya
2d5734fc8b Add v4.5.0 to changelog 2025-07-24 12:07:11 +02:00
Alejandro Celaya
478ac344ff Merge pull request #2469 from acelaya-forks/feature/logs-encoding
Allow logs format to be configured as console or JSON
2025-07-24 10:01:36 +02:00
Alejandro Celaya
e40b82618a Allow logs format to be configured as console or JSON 2025-07-24 09:57:34 +02:00
Alejandro Celaya
51dd671174 Merge pull request #2467 from acelaya-forks/feature/nullable-match-value
Make RedirectCondition->matchValue nullable
2025-07-22 08:32:25 +02:00
Alejandro Celaya
5b5d0aae49 Make RedirectCondition->matchValue nullable 2025-07-22 08:28:09 +02:00
Alejandro Celaya
56df880a93 Merge pull request #2466 from acelaya-forks/feature/php-8.5
Run tests under PHP 8.5 in CI
2025-07-21 10:38:02 +02:00
Alejandro Celaya
afa509613a Run tests under PHP 8.5 in CI 2025-07-21 10:30:35 +02:00
Alejandro Celaya
3be49a25a0 Merge pull request #2465 from acelaya-forks/feature/redirect-cache-visibility
Allow redirect cache visibility to be configured
2025-07-21 10:21:36 +02:00
Alejandro Celaya
8b259b364d Allow redirect cache visibility to be configured 2025-07-21 10:13:17 +02:00
Alejandro Celaya
13d9b7b0a7 Merge pull request #2464 from acelaya-forks/feature/desktop-devices
Add support for more device types in device-specific redirects
2025-07-20 12:02:11 +02:00
Alejandro Celaya
2b33095392 Add support for more device types in device-specific redirects 2025-07-20 11:56:33 +02:00
Alejandro Celaya
3a1ce40a49 Merge pull request #2461 from acelaya-forks/feature/trusted-proxies
Allow trusted proxies to be provided via TRUSTED_PROXIES env var or config option
2025-07-18 08:32:48 +02:00
Alejandro Celaya
a68300f19a Fix phpstan report 2025-07-18 08:29:16 +02:00
Alejandro Celaya
3318987d63 Allow providing hop count via TRUSTED_PROXIES 2025-07-18 08:24:57 +02:00
Alejandro Celaya
1f825797f6 Allow trusted proxies to be provided via TRUSTED_PROXIES env var 2025-07-17 09:57:34 +02:00
Alejandro Celaya
650fafb7c4 Register ReverseForwardedAddressesMiddlewareDecorator via ServiceManager delegator 2025-07-17 09:47:02 +02:00
Alejandro Celaya
978e24d6fa Merge pull request #2460 from acelaya-forks/feature/enhanced-query-param-rules
Add support for any-value and valueless query param redirect rules
2025-07-17 08:57:30 +02:00
Alejandro Celaya
c3d3cc6288 Test RedirectConditionType::isValid() in isolation 2025-07-17 08:51:59 +02:00
Alejandro Celaya
223901324f Enhance RedirectRuleHandlerTest with new query-param-related conditions 2025-07-17 08:44:19 +02:00
Alejandro Celaya
47293be85c Enhance RedirectConditionTest with new query-param-related conditions 2025-07-17 08:39:37 +02:00
Alejandro Celaya
18c4c39fee Add support for any-value and valueless query param redirect rules 2025-07-17 08:31:29 +02:00
Alejandro Celaya
e762d28b67 Merge pull request #2455 from acelaya-forks/feature/cors-customization
Add new CORS configuration options
2025-07-16 08:41:42 +02:00
Alejandro Celaya
f5c6bc8204 Update changelog 2025-07-16 08:39:12 +02:00
Alejandro Celaya
3369afe22c Add CorsOptions test 2025-07-16 08:29:57 +02:00
Alejandro Celaya
1d96cc0279 Update CrossDomainMiddleware test 2025-07-08 13:17:46 +02:00
Alejandro Celaya
cd4fcc9b0a Update shlink-installer 2025-07-08 13:07:04 +02:00
Alejandro Celaya
834bc4ae20 Allow credentials to be enabled in CORS 2025-07-08 10:36:12 +02:00
Alejandro Celaya
92d7a44cee Add new CORS configuration options 2025-07-05 10:34:50 +02:00
Alejandro Celaya
c8e3b3df0a Update changelog 2025-07-04 18:31:20 +02:00
Alejandro Celaya
77244b52c9 Merge pull request #2454 from acelaya-forks/feature/real-time-updates-options
Allow individual real-time updates topics to be enabled
2025-07-04 18:29:12 +02:00
Alejandro Celaya
9e93e34e12 Add test to cover when visit updates topics are disabled 2025-07-04 18:25:45 +02:00
Alejandro Celaya
733b2e5647 Add test to cover when short URL updates topic is disabled 2025-07-04 18:04:27 +02:00
Alejandro Celaya
26fef87f3b Add RealTimeUpdatesOptions test 2025-07-04 10:07:40 +02:00
Alejandro Celaya
f4aaf02d55 Reduce duplicated code between enumValues and enumNames 2025-07-04 09:52:35 +02:00
Alejandro Celaya
314a99862d Update to latest shlink-installer with real-time updates support 2025-07-03 18:35:14 +02:00
Alejandro Celaya
240d9df177 Validate topic names in RealTimeUpdateOptions 2025-07-03 14:34:27 +02:00
Alejandro Celaya
fb995f2bea Allow individual real-time updates topics to be enabled 2025-07-03 10:10:06 +02:00
Alejandro Celaya
436be1985c Merge pull request #2452 from acelaya-forks/feature/invokable-command-poc
Use invokable commands approach on some API console commands
2025-06-26 08:46:20 +02:00
Alejandro Celaya
850e8574e9 Use invokable commands approach on some API console commands 2025-06-26 08:41:18 +02:00
Alejandro Celaya
c2743cb488 Merge pull request #2453 from acelaya-forks/feature/phpunit-warnings
Adjust tests to fix warnings
2025-06-26 08:40:10 +02:00
Alejandro Celaya
f1157aa177 Adjust tests to fix warnings 2025-06-24 19:47:18 +02:00
Alejandro Celaya
497429e685 Forward questions to the global discussions repo 2025-06-23 10:14:18 +02:00
Alejandro Celaya
2cad5dd435 Update to roadrunner 2025.1 2025-05-27 14:23:49 +02:00
Alejandro Celaya
f38f1ae5da Merge pull request #2439 from acelaya-forks/feature/mercure-enabled
Add new MERCURE_ENABLED env var
2025-05-22 08:29:23 +01:00
Alejandro Celaya
9c1db35d81 Add new MERCURE_ENABLED env var 2025-05-22 09:20:50 +02:00
Alejandro Celaya
11b8943919 Merge pull request #2432 from acelaya-forks/feature/docker-env-syntax
Update syntax used for env vars in Dockerfiles
2025-05-06 12:25:14 +02:00
Alejandro Celaya
27d24a4f15 Update syntax used for env vars in Dockerfiles 2025-05-06 11:56:49 +02:00
Alejandro Celaya
b2dbc4cf52 Fix typo in Dockerfile 2025-05-04 15:57:29 +02:00
Alejandro Celaya
1a7a745f2e Update Dockerfile marking image-related extensions as delegated 2025-05-04 15:56:44 +02:00
Alejandro Celaya
99bc1a21dd Merge pull request #2425 from acelaya-forks/feature/command-exit-codes
Replace ExitCode with standard symfony Command constants
2025-04-22 19:49:16 +02:00
Alejandro Celaya
cea8a982e2 Replace ExitCode with standard symfony Command constants 2025-04-22 12:07:41 +02:00
Alejandro Celaya
8bd1c6a79a Merge pull request #2423 from acelaya-forks/feature/remove-bootstrap
Remove references to bootstrap from error templates
2025-04-22 09:12:08 +02:00
Alejandro Celaya
71a3b993b1 Remove references to bootstrap from error templates 2025-04-22 09:09:52 +02:00
Alejandro Celaya
6e25e3c31d Merge pull request #2422 from acelaya-forks/feature/deprecate-qr-codes
Deprecate QR code generation endpoint
2025-04-22 08:50:34 +02:00
Alejandro Celaya
b15e832cf4 Deprecate QR code generation endpoint 2025-04-22 08:47:37 +02:00
Alejandro Celaya
851929ebef Merge pull request #2403 from acelaya-forks/feature/phpunit-phpstan-fixes
Fix compatibility with PHPUnit 12.0.9 and phpstan-phpunit
2025-03-24 19:36:44 +01:00
Alejandro Celaya
87d5f9bc75 Fix compatibility with PHPUnit 12.0.9 and phpstan-phpunit 2025-03-24 19:33:52 +01:00
Alejandro Celaya
c2649395f8 Merge pull request #2398 from shlinkio/develop
Release 4.4.6
2025-03-20 09:24:27 +01:00
Alejandro Celaya
b7d9ba8258 Merge pull request #2397 from acelaya-forks/feature/endroid-fix
Fix error intrduced by endroid/qr-code 6.0.4
2025-03-20 09:19:58 +01:00
Alejandro Celaya
6526cf8c44 Fix error intrduced by endroid/qr-code 6.0.4 2025-03-20 09:16:53 +01:00
Alejandro Celaya
a85afb2bee Merge pull request #2394 from acelaya-forks/feature/fix-artifact-removal
Update geekyeggo/delete-artifact action to v5
2025-03-14 18:00:47 +01:00
Alejandro Celaya
8b4067efbe Update geekyeggo/delete-artifact action to v5 2025-03-14 17:57:55 +01:00
Alejandro Celaya
c7c2272fab Update changelog 2025-03-14 17:53:23 +01:00
Alejandro Celaya
bc77750713 Merge pull request #2392 from wuuei/patch-1
Fix Matomo country logging by sending country code instead of country
2025-03-14 17:51:37 +01:00
Alejandro Celaya
1ceb38f50b Test actual arguments set to matomo tracker when sending visits 2025-03-14 17:40:37 +01:00
wuuei
d273b56144 Lock "endroid/qr-code" to 6.0.3 so that unit tests complete 2025-03-14 15:21:55 +00:00
wuuei
5cd7305666 Fix code style to resolve failing check 2025-03-14 15:20:49 +00:00
wuuei
3040a22c02 Fix Matomo country logging by sending country code instead of country name
Matomo expects the country code in lowercase for accurate logging and proper flag display
2025-03-13 15:33:00 +01:00
Alejandro Celaya
6991138812 Merge pull request #2379 from shlinkio/develop
Release 4.4.5
2025-03-01 09:41:16 +01:00
Alejandro Celaya
5eb1808217 Update CHANGELOG.md with V4.4.5 2025-03-01 09:14:37 +01:00
Alejandro Celaya
5eb14c5315 Merge pull request #2375 from acelaya-forks/feature/deprecation-error-reporting
Disable deprecation warnings when running in production envs
2025-02-21 21:18:44 +01:00
Alejandro Celaya
a18360a4d6 Disable deprecation warnings when running in production envs 2025-02-21 21:13:29 +01:00
Alejandro Celaya
104b1e7d04 Merge pull request #2371 from shlinkio/develop
Release 4.4.4
2025-02-19 19:40:28 +01:00
Alejandro Celaya
af2d67695b Merge pull request #2370 from acelaya-forks/feature/missing-join-fix
Fix 500 error when listing non-orphan visits with short-url-depending API key
2025-02-19 19:37:36 +01:00
Alejandro Celaya
449a588796 Fix 500 error when listing non-orphan visits with short-url-depending API key 2025-02-19 19:33:44 +01:00
Alejandro Celaya
7bbc938743 Merge pull request #2369 from acelaya-forks/feature/redis-cluster-fix
Downgrade to symfony/lock 7.1.6
2025-02-19 17:55:53 +01:00
Alejandro Celaya
766758ff9b Downgrade to symfony/lock 7.1.6 2025-02-19 17:45:52 +01:00
Alejandro Celaya
bee9f2a9cc Merge pull request #2364 from shlinkio/develop
Release 4.4.3
2025-02-15 11:28:09 +01:00
Alejandro Celaya
63d943d59d Merge pull request #2363 from acelaya-forks/feature/find-url-perf
Fix unique_short_code_plus_domain index in Microsoft SQL
2025-02-15 11:24:26 +01:00
Alejandro Celaya
053e1f3073 Update changelog 2025-02-15 11:19:30 +01:00
Alejandro Celaya
f3da345bf3 Fix unique_short_code_plus_domain index in Microsoft SQL 2025-02-15 11:17:14 +01:00
Alejandro Celaya
745255736a Simplify query to find short URL when domain is null 2025-02-14 10:20:50 +01:00
Alejandro Celaya
8fd53afe3f Merge pull request #2361 from acelaya-forks/feature/lock-downgrade
Downgrade symfony/lock to v7.2.0 to work around redis issue
2025-02-14 08:52:33 +01:00
Alejandro Celaya
259635ea2a Downgrade symfony/lock to v7.2.0 to work around redis issue 2025-02-14 08:40:06 +01:00
Alejandro Celaya
a1f2e6dc5c Merge pull request #2359 from acelaya-forks/feature/multi-proxy-fix
Workaround for IP resolution from x-Forwarded-For with multiple proxies
2025-02-13 22:03:36 +01:00
Alejandro Celaya
81e07bf08d Merge pull request #2358 from acelaya-forks/feature/phpunit-12
Update to PHPUnit 12
2025-02-13 21:59:00 +01:00
Alejandro Celaya
c650a3e665 Workaround for IP resolution from x-Forwarded-For with multiple proxies 2025-02-13 21:52:38 +01:00
Alejandro Celaya
65c01034ff Update to PHPUnit 12 2025-02-13 10:35:58 +01:00
Alejandro Celaya
48f910aaaa Merge pull request #2355 from acelaya-forks/feature/openapi-warnings
Remove suppressed warnings when running openapi tools
2025-02-05 08:43:28 +01:00
Alejandro Celaya
e511e15a87 Remove suppressed warnings when running openapi tools 2025-02-05 08:39:22 +01:00
Alejandro Celaya
888dc84d3f Merge pull request #2348 from shlinkio/develop
Release 4.4.2
2025-01-29 12:08:51 +01:00
Alejandro Celaya
ed09bf90eb Tag v4.4.2 in changelog 2025-01-29 12:05:53 +01:00
Alejandro Celaya
0ddfcb75dd Merge pull request #2347 from acelaya-forks/feature/docker-arm
Get back docker image building for ARM architecture
2025-01-29 12:02:19 +01:00
Alejandro Celaya
193be55f0c Get back docker image building for ARM architecture 2025-01-29 11:59:42 +01:00
Alejandro Celaya
3ba7ad3839 Merge pull request #2345 from shlinkio/develop
Release 4.4.1 - fixes
2025-01-28 15:53:49 +01:00
Alejandro Celaya
7ffb64eee1 Do not build docker image for ARM 2025-01-28 15:51:20 +01:00
Alejandro Celaya
0a2cc554c6 Build docker image with buildx 0.19.2 2025-01-28 15:38:47 +01:00
Alejandro Celaya
7c2b918d5d Merge pull request #2344 from shlinkio/develop
Release 4.4.1
2025-01-28 10:15:24 +01:00
Alejandro Celaya
af783dea57 Add v4.4.1 to changelog 2025-01-28 10:12:15 +01:00
Alejandro Celaya
a68a17f6b4 Merge pull request #2343 from acelaya-forks/feature/defensive-title-encoding
Fix error when creating short URL for page with unsupported encoding
2025-01-28 10:11:04 +01:00
Alejandro Celaya
e9fe1ac5d4 Fix error when creating short URL for page with unsupported encoding 2025-01-28 10:04:30 +01:00
Alejandro Celaya
88e97f18ad Merge pull request #2342 from acelaya-forks/feature/too-many-connections
Close connections after every async job that uses the db
2025-01-27 15:48:22 +01:00
Alejandro Celaya
3372a2a9c8 Close connections after every async job that uses the db 2025-01-27 15:45:37 +01:00
Alejandro Celaya
f02a8c876c Merge pull request #2340 from acelaya-forks/feature/update-shlink-deps
Update shlink packages
2025-01-25 16:16:42 +01:00
Alejandro Celaya
1549509eb8 Update shlink packages 2025-01-25 16:13:40 +01:00
Alejandro Celaya
62fde5a8e2 Update changelog 2025-01-13 08:47:19 +01:00
Alejandro Celaya
221e061ea6 Merge pull request #2332 from MaZe3D/develop
Add ADDRESS environment vairable to define the listening interface.
2025-01-13 08:45:20 +01:00
Mark Orlando Zeller
9ad565f8c8 Add ADDRESS environment vairable to define the listening interface. 2025-01-10 22:10:51 +01:00
153 changed files with 1727 additions and 743 deletions

View File

@@ -1,49 +0,0 @@
title: 'Help wanted'
body:
- type: input
validations:
required: true
attributes:
label: Shlink version
placeholder: x.y.z
- type: input
validations:
required: true
attributes:
label: PHP version
placeholder: x.y.z
- type: dropdown
validations:
required: true
attributes:
label: How do you serve Shlink
options:
- Self-hosted Apache
- Self-hosted nginx
- Self-hosted RoadRunner
- Docker image
- Other (explain in summary)
- type: dropdown
validations:
required: true
attributes:
label: Database engine
options:
- MySQL
- MariaDB
- PostgreSQL
- MicrosoftSQL
- SQLite
- type: input
validations:
required: true
attributes:
label: Database version
placeholder: x.y.z
- type: textarea
validations:
required: true
attributes:
label: Summary
value: '<!-- Describe your issue, question or request here. -->'

View File

@@ -1,7 +0,0 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

View File

@@ -2,4 +2,4 @@ blank_issues_enabled: true
contact_links:
- name: Question - Support
about: Do you need help setting up or using Shlink?
url: https://github.com/shlinkio/shlink/discussions/new?category=help-wanted
url: https://github.com/orgs/shlinkio/discussions/new?category=help-wanted

View File

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

View File

@@ -13,7 +13,8 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3', '8.4']
php-version: ['8.3', '8.4', '8.5']
continue-on-error: ${{ inputs.php-version == '8.5' }}
env:
LC_ALL: C
steps:

View File

@@ -8,3 +8,5 @@ on:
jobs:
build-docker-image:
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
with:
platforms: 'linux/arm64/v8,linux/amd64'

View File

@@ -13,7 +13,8 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3', '8.4']
php-version: ['8.3', '8.4', '8.5']
continue-on-error: ${{ inputs.php-version == '8.5' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:

View File

@@ -96,7 +96,7 @@ jobs:
- upload-coverage
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
- uses: geekyeggo/delete-artifact@v5
with:
name: |
coverage-*

View File

@@ -45,6 +45,6 @@ jobs:
needs: ['publish']
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
- uses: geekyeggo/delete-artifact@v5
with:
name: dist-files-*

View File

@@ -4,7 +4,181 @@ 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).
# [4.4.0] - 2024-12-27
## [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
* [#2438](https://github.com/shlinkio/shlink/issues/2438) Add `MERCURE_ENABLED` env var and corresponding config option, to more easily allow the mercure integration to be toggled.
For BC, if this env var is not present, we'll still consider the integration enabled if the `MERCURE_PUBLIC_HUB_URL` env var has a value. This is considered deprecated though, and next major version will rely only on `MERCURE_ENABLED`, so if you are using Mercure, make sure to set `MERCURE_ENABLED=true` to be ready.
* [#2387](https://github.com/shlinkio/shlink/issues/2387) Add `REAL_TIME_UPDATES_TOPICS` env var and corresponding config option, to granularly decide which real-time updates topics should be enabled.
* [#2418](https://github.com/shlinkio/shlink/issues/2418) Add more granular control over how Shlink handles CORS. It is now possible to customize the `Access-Control-Allow-Origin`, `Access-Control-Max-Age` and `Access-Control-Allow-Credentials` headers via env vars or config options.
* [#2386](https://github.com/shlinkio/shlink/issues/2386) Add new `any-value-query-param` and `valueless-query-param` redirect rule conditions.
These new rules expand the existing `query-param`, which requires both a specific non-empty value in order to match the condition.
The new conditions match as soon as a query param exists with any or no value (in the case of `any-value-query-param`), or if a query param exists with no value at all (in the case of `valueless-query-param`).
* [#2360](https://github.com/shlinkio/shlink/issues/2360) Add `TRUSTED_PROXIES` env var and corresponding config option, to configure a comma-separated list of all the proxies in front of Shlink, or simply the amount of trusted proxies in front of Shlink.
This is important to properly detect visitor's IP addresses instead of incorrectly matching one of the proxy's IP address, and if provided, it disables a workaround introduced in https://github.com/shlinkio/shlink/pull/2359.
* [#2274](https://github.com/shlinkio/shlink/issues/2274) Add more supported device types for the `device` redirect condition:
* `linux`: Will match desktop devices with Linux.
* `windows`: Will match desktop devices with Windows.
* `macos`: Will match desktop devices with MacOS.
* `chromeos`: Will match desktop devices with ChromeOS.
* `mobile`: Will match any mobile devices with either Android or iOS.
* [#2093](https://github.com/shlinkio/shlink/issues/2093) Add `REDIRECT_CACHE_LIFETIME` env var and corresponding config option, so that it is possible to set the `Cache-Control` visibility directive (`public` or `private`) when the `REDIRECT_STATUS_CODE` has been set to `301` or `308`.
* [#2323](https://github.com/shlinkio/shlink/issues/2323) Add `LOGS_FORMAT` env var and corresponding config option, to allow the logs generated by Shlink to be in console or JSON formats.
### Changed
* [#2406](https://github.com/shlinkio/shlink/issues/2406) Remove references to bootstrap from error templates, and instead inline the very minimum required styles.
### Deprecated
* [#2408](https://github.com/shlinkio/shlink/issues/2408) Generating QR codes via `/{short-code}/qr-code` is now deprecated and will be removed in Shlink 5.0. Use the equivalent capability from web clients instead.
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.4.6] - 2025-03-20
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2391](https://github.com/shlinkio/shlink/issues/2391) When sending visits to Matomo, send the country code, not the country name.
* Fix error with new option introduced by `endroid/qr-code` 6.0.4.
## [4.4.5] - 2025-03-01
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2373](https://github.com/shlinkio/shlink/issues/2373) Ensure deprecation warnings do not end up escalated to `ErrorException`s by `ProblemDetailsMiddleware`.
In order to do this, Shlink will entirely ignore deprecation warnings when running in production, as those do not mean something is not working, but only that something will break in future versions.
## [4.4.4] - 2025-02-19
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2366](https://github.com/shlinkio/shlink/issues/2366) Fix error "Cannot use 'SCRIPT' with redis-cluster" thrown when creating a lock while using a redis cluster.
* [#2368](https://github.com/shlinkio/shlink/issues/2368) Fix error when listing non-orphan visits using API key with `AUTHORED_SHORT_URLS` role.
## [4.4.3] - 2025-02-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2351](https://github.com/shlinkio/shlink/issues/2351) Fix visitor IP address resolution when Shlink is served behind more than one reverse proxy.
This regression was introduced due to a change in behavior in `akrabat/rka-ip-address-middleware`, that now picks the first address from the right after excluding all trusted proxies.
Since Shlink does not set trusted proxies, this means the first IP from the right is now picked instead of the first from the left, so we now reverse the list before trying to resolve the IP.
In the future, Shlink will allow you to define trusted proxies, to avoid other potential side effects because of this reversing of the list.
* [#2354](https://github.com/shlinkio/shlink/issues/2354) Fix error "NOSCRIPT No matching script. Please use EVAL" thrown when creating a lock in redis.
* [#2319](https://github.com/shlinkio/shlink/issues/2319) Fix unique index for `short_code` and `domain_id` in `short_urls` table not being used in Microsoft SQL engines for rows where `domain_id` is `null`.
## [4.4.2] - 2025-01-29
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2346](https://github.com/shlinkio/shlink/issues/2346) Get back docker images for ARM architectures.
## [4.4.1] - 2025-01-28
### Added
* [#2331](https://github.com/shlinkio/shlink/issues/2331) Add `ADDRESS` env var which allows to customize the IP address to which RoadRunner binds, when using the official docker image.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2341](https://github.com/shlinkio/shlink/issues/2341) Ensure all asynchronous jobs that interact with the database do not leave idle connections open.
* [#2334](https://github.com/shlinkio/shlink/issues/2334) Improve how page titles are encoded to UTF-8, falling back from mbstring to iconv if available, and ultimately using the original title in case of error, but never causing the short URL creation to fail.
## [4.4.0] - 2024-12-27
### Added
* [#2265](https://github.com/shlinkio/shlink/issues/2265) Add a new `REDIRECT_EXTRA_PATH_MODE` option that accepts three values:
@@ -40,7 +214,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* *Nothing*
# [4.3.1] - 2024-11-25
## [4.3.1] - 2024-11-25
### Added
* *Nothing*

View File

@@ -1,21 +1,22 @@
FROM php:8.4-alpine3.21 AS base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SHLINK_VERSION=${SHLINK_VERSION}
ARG SHLINK_RUNTIME=rr
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ENV SHLINK_RUNTIME=${SHLINK_RUNTIME}
ENV USER_ID '1001'
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
ENV LC_ALL 'C'
ENV USER_ID='1001'
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'
ENV LC_ALL='C'
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 sqlite-libs && \

View File

@@ -18,13 +18,13 @@
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.5",
"akrabat/ip-address-middleware": "^2.6",
"cakephp/chronos": "^3.1",
"doctrine/dbal": "^4.2",
"doctrine/migrations": "^3.8",
"doctrine/orm": "^3.3",
"donatj/phpuseragentparser": "^1.10",
"endroid/qr-code": "^6.0",
"endroid/qr-code": "^6.0.5",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.1",
"guzzlehttp/guzzle": "^7.9",
@@ -43,37 +43,37 @@
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "^6.6",
"shlinkio/shlink-config": "^3.4",
"shlinkio/shlink-event-dispatcher": "^4.1",
"shlinkio/shlink-importer": "^5.5",
"shlinkio/shlink-installer": "^9.4",
"shlinkio/shlink-ip-geolocation": "^4.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",
"spiral/roadrunner": "^2024.3",
"spiral/roadrunner-cli": "^2.6",
"spiral/roadrunner": "^2025.1",
"spiral/roadrunner-cli": "^2.7",
"spiral/roadrunner-http": "^3.5",
"spiral/roadrunner-jobs": "^4.6",
"symfony/console": "^7.2",
"symfony/filesystem": "^7.2",
"symfony/lock": "^7.2",
"symfony/process": "^7.2",
"symfony/string": "^7.2"
"symfony/console": "^7.3",
"symfony/filesystem": "^7.3",
"symfony/lock": "^7.3.2",
"symfony/process": "^7.3",
"symfony/string": "^7.3"
},
"require-dev": {
"devizzent/cebe-php-openapi": "^1.1.2",
"devster/ubench": "^2.1",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-phpunit": "^2.0.5",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/php-code-coverage": "^11.0",
"phpunit/phpcov": "^10.0",
"phpunit/phpunit": "^11.5",
"phpunit/php-code-coverage": "^12.0",
"phpunit/phpcov": "^11.0",
"phpunit/phpunit": "^12.0.10",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.4.0",
"shlinkio/shlink-test-utils": "^4.2",
"symfony/var-dumper": "^7.2",
"shlinkio/php-coding-standard": "~2.4.2",
"shlinkio/shlink-test-utils": "^4.3.1",
"symfony/var-dumper": "^7.3",
"veewee/composer-run-parallel": "^1.4"
},
"conflict": {
@@ -154,16 +154,8 @@
"@test:cli",
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
],
"openapi:validate": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\" vendor/bin/php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "@php -d error_reporting=\"E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\" vendor/bin/php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
"swagger:validate": [
"echo \"This command is deprecated. Use openapi:validate instead\"",
"@openapi:validate"
],
"swagger:inline": [
"echo \"This command is deprecated. Use openapi:inline instead\"",
"@openapi:inline"
],
"openapi:validate": "php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
},
"config": {

View File

@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
return [
'cors' => [
'max_age' => 3600,
],
];

View File

@@ -13,6 +13,7 @@ return [
'enabled_options' => [
Option\Server\RuntimeConfigOption::class,
Option\Server\MemoryLimitConfigOption::class,
Option\Server\LogsFormatConfigOption::class,
Option\Database\DatabaseDriverConfigOption::class,
Option\Database\DatabaseNameConfigOption::class,
Option\Database\DatabaseHostConfigOption::class,
@@ -41,6 +42,7 @@ return [
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\RedirectCacheVisibilityConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\ExtraPathModeConfigOption::class,
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
@@ -76,6 +78,11 @@ return [
Option\Matomo\MatomoBaseUrlConfigOption::class,
Option\Matomo\MatomoSiteIdConfigOption::class,
Option\Matomo\MatomoApiTokenConfigOption::class,
Option\RealTimeUpdates\RealTimeUpdatesTopicsConfigOption::class,
Option\Cors\CorsAllowOriginConfigOption::class,
Option\Cors\CorsAllowCredentialsConfigOption::class,
Option\Cors\CorsMaxAgeConfigOption::class,
Option\TrustedProxiesConfigOption::class,
],
'installation_commands' => [

View File

@@ -4,34 +4,58 @@ 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;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
return [
return (static function (): array {
$trustedProxies = EnvVars::TRUSTED_PROXIES->loadFromEnv();
$proxiesIsHopCount = is_numeric($trustedProxies);
// Configuration for RKA\Middleware\IpAddress
'rka' => [
'ip_address' => [
'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE,
'check_proxy_headers' => true,
'trusted_proxies' => [],
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
return [
// Configuration for RKA\Middleware\IpAddress
'rka' => [
'ip_address' => [
'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE,
'check_proxy_headers' => true,
// List of trusted proxies
'trusted_proxies' => $proxiesIsHopCount ? [] : splitByComma($trustedProxies),
// Amount of addresses to skip from the right, before finding the visitor IP address
'hop_count' => $proxiesIsHopCount ? (int) $trustedProxies : 0,
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
],
],
'dependencies' => [
'factories' => [
IpAddress::class => IpAddressFactory::class,
'dependencies' => [
'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

@@ -23,11 +23,16 @@ use function Shlinkio\Shlink\Config\runningInRoadRunner;
return (static function (): array {
$isDev = EnvVars::isDevEnv();
$common = [
$format = EnvVars::LOGS_FORMAT->loadFromEnv();
$buildCommonConfig = static fn (bool $addNewLine = false) => [
'level' => $isDev ? Level::Debug->value : Level::Info->value,
'processors' => [RequestIdMiddleware::class],
'line_format' =>
'[%datetime%] [%extra.' . RequestIdMiddleware::ATTRIBUTE . '%] %channel%.%level_name% - %message%',
'formatter' => [
'type' => $format,
'add_new_line' => $addNewLine,
'line_format' =>
'[%datetime%] [%extra.' . RequestIdMiddleware::ATTRIBUTE . '%] %channel%.%level_name% - %message%',
],
];
// In dev env or the docker container, stream Shlink logs to stderr, otherwise send them to a file
@@ -39,16 +44,15 @@ return (static function (): array {
'Shlink' => $useStreamForShlinkLogger ? [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
...$common,
...$buildCommonConfig(),
] : [
'type' => LoggerType::FILE->value,
...$common,
...$buildCommonConfig(),
],
'Access' => [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
'add_new_line' => ! runningInRoadRunner(),
...$common,
...$buildCommonConfig(! runningInRoadRunner()),
],
],

View File

@@ -12,6 +12,7 @@ return [
// This config is used by shlink-common. Do not delete
'mercure' => [
'enabled' => EnvVars::MERCURE_ENABLED->loadFromEnv(),
'public_hub_url' => EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv(),
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv(),
'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(),

View File

@@ -11,15 +11,47 @@ const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const DEFAULT_REDIRECT_CACHE_VISIBILITY = 'private';
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
const DEFAULT_QR_CODE_SIZE = 300;
const DEFAULT_QR_CODE_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White
const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';
/**
* List of ISO 3166-1 alpha-2 two-letter country codes https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
*/
const ISO_COUNTRY_CODES = [
'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ',
'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR',
'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC',
'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO',
'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF',
'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY',
'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM',
'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY',
'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX',
'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI',
'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH',
'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC',
'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS',
'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK',
'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU',
'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW',
];
/** @deprecated */
const DEFAULT_QR_CODE_SIZE = 300;
/** @deprecated */
const DEFAULT_QR_CODE_MARGIN = 0;
/** @deprecated */
const DEFAULT_QR_CODE_FORMAT = 'png';
/** @deprecated */
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
/** @deprecated */
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
/** @deprecated */
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
/** @deprecated */
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
/** @deprecated */
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White

View File

@@ -11,6 +11,7 @@ use function Shlinkio\Shlink\Core\enumValues;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
// Set current directory to the project's root directory
chdir(dirname(__DIR__));
require 'vendor/autoload.php';
@@ -21,7 +22,11 @@ loadEnvVarsFromConfig(
enumValues(EnvVars::class),
);
// This is one of the first files loaded. Configure the timezone and memory limit here
// This is one of the first files loaded. Set global configuration here
error_reporting(
// Set a less strict error reporting for prod, where deprecation warnings should be ignored
EnvVars::isProdEnv() ? E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED : E_ALL,
);
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv());
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv());

View File

@@ -58,6 +58,7 @@ return [
// EnvVars::MATOMO_API_TOKEN->value => ,
// Mercure
EnvVars::MERCURE_ENABLED->value => true,
EnvVars::MERCURE_PUBLIC_HUB_URL->value => 'http://localhost:8002',
EnvVars::MERCURE_INTERNAL_HUB_URL->value => 'http://shlink_mercure_proxy',
EnvVars::MERCURE_JWT_SECRET->value => 'mercure_jwt_key_long_enough_to_avoid_error',

View File

@@ -30,13 +30,17 @@ jobs:
prefetch: 10
logs:
encoding: console
mode: development
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: console
level: info
metrics:
encoding: console
level: debug
jobs:
encoding: console
level: debug

View File

@@ -35,15 +35,16 @@ jobs:
prefetch: 10
logs:
encoding: json
encoding: console
mode: development
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: json
encoding: console
level: info
metrics:
level: panic
jobs:
encoding: console
level: panic

View File

@@ -7,18 +7,20 @@ server:
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
http:
address: '0.0.0.0:${PORT:-8080}'
address: '${ADDRESS:-0.0.0.0}:${PORT:-8080}'
middleware: ['static']
static:
dir: '../../public'
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:
@@ -28,11 +30,14 @@ jobs:
prefetch: 10
logs:
encoding: ${LOGS_FORMAT:-console}
mode: production
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: ${LOGS_FORMAT:-console}
level: info
jobs:
encoding: ${LOGS_FORMAT:-console}
level: debug

View File

@@ -11,5 +11,11 @@ const ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (
. 'Chrome/109.0.5414.86 Mobile Safari/537.36';
const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 '
. '(KHTML, like Gecko) FxiOS/109.0 Mobile/15E148 Safari/605.1.15';
const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like '
. 'Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61';
const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/138.0.0.0 Safari/537.36 Edg/138.0.3351.95';
const LINUX_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'HeadlessChrome/81.0.4044.113 Safari/537.36';
const MACOS_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/605.1.15 (KHTML, like Gecko) '
. 'Version/18.4 Safari/605.1.15';
const CHROMEOS_USER_AGENT = 'Mozilla/5.0 (X11; CrOS x86_64 16181.61.0) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/134.0.6998.198 Safari/537.36';

View File

@@ -1,10 +1,10 @@
FROM php:8.4-fpm-alpine3.21
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.24
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
ENV APCU_VERSION='5.1.24'
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

View File

@@ -1,5 +1,4 @@
display_errors=On
error_reporting=-1
log_errors_max_len=0
zend.assertions=1
assert.exception=1

View File

@@ -1,9 +1,9 @@
FROM php:8.4-alpine3.21
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
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

View File

@@ -144,7 +144,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.15
image: dunglas/mercure:v0.18
ports:
- "3080:80"
environment:

View File

@@ -19,6 +19,8 @@
"device",
"language",
"query-param",
"any-value-query-param",
"valueless-query-param",
"ip-address",
"geolocation-country-code",
"geolocation-city-name"
@@ -29,7 +31,7 @@
"type": ["string", "null"]
},
"matchValue": {
"type": "string"
"type": ["string", "null"]
}
}
}

View File

@@ -1,11 +1,12 @@
{
"get": {
"deprecated": true,
"operationId": "shortUrlQrCode",
"tags": [
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
"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"

View File

@@ -4,20 +4,43 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
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\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
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)',
help: <<<HELP
The <info>%command.name%</info> command allows you to disable an existing API key, via its name or the
plain-text 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:
<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>
HELP,
)]
class DisableKeyCommand extends Command
{
public const string NAME = 'api-key:disable';
@@ -27,47 +50,9 @@ class DisableKeyCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$help = <<<HELP
The <info>%command.name%</info> command allows you to disable an existing API key, via its name or the
plain-text 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:
<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>
HELP;
$this
->setName(self::NAME)
->setDescription('Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)')
->addArgument(
'keyOrName',
InputArgument::OPTIONAL,
'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.',
)
->addOption(
'by-name',
mode: InputOption::VALUE_NONE,
description: 'Indicates the first argument is the API key name, not the plain-text key.',
)
->setHelp($help);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$keyOrName = $input->getArgument('keyOrName');
$keyOrName = $input->getArgument('key-or-name');
if ($keyOrName === null) {
$apiKeys = $this->apiKeyService->listKeys(enabledOnly: true);
@@ -76,20 +61,23 @@ class DisableKeyCommand extends Command
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('keyOrName', $name);
$input->setArgument('key-or-name', $name);
$input->setOption('by-name', true);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$keyOrName = $input->getArgument('keyOrName');
$byName = $input->getOption('by-name');
$io = new SymfonyStyle($input, $output);
if (! $keyOrName) {
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,
): int {
if ($keyOrName === null) {
$io->warning('An API key name was not provided.');
return ExitCode::EXIT_WARNING;
return Command::INVALID;
}
try {
@@ -99,10 +87,10 @@ class DisableKeyCommand extends Command
$this->apiKeyService->disableByKey($keyOrName);
}
$io->success(sprintf('API key "%s" properly disabled', $keyOrName));
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return Command::FAILURE;
}
}
}

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Role;
@@ -123,6 +122,6 @@ class GenerateKeyCommand extends Command
);
}
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -38,6 +37,6 @@ class InitialApiKeyCommand extends Command
$output->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
}
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
}

View File

@@ -4,21 +4,24 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\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_filter;
use function array_map;
use function implode;
use function sprintf;
#[AsCommand(
name: ListKeysCommand::NAME,
description: 'Lists all the available API keys.',
)]
class ListKeysCommand extends Command
{
private const string ERROR_STRING_PATTERN = '<fg=red>%s</>';
@@ -32,23 +35,14 @@ class ListKeysCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Lists all the available API keys.')
->addOption(
'enabled-only',
'e',
InputOption::VALUE_NONE,
'Tells if only enabled API keys should be returned.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$enabledOnly = $input->getOption('enabled-only');
public function __invoke(
SymfonyStyle $io,
#[Option(
description: 'Tells if only enabled API keys should be returned.',
shortcut: 'e',
)]
bool $enabledOnly = false,
): int {
$rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
$expiration = $apiKey->expirationDate;
$messagePattern = $this->determineMessagePattern($apiKey);
@@ -66,14 +60,14 @@ class ListKeysCommand extends Command
return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly));
ShlinkTable::withRowSeparators($output)->render(array_filter([
ShlinkTable::withRowSeparators($io)->render(array_filter([
'Name',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
'Roles',
]), $rows);
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
private function determineMessagePattern(ApiKey $apiKey): string

View File

@@ -4,19 +4,23 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\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;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
#[AsCommand(
name: RenameApiKeyCommand::NAME,
description: 'Renames an API key by name',
)]
class RenameApiKeyCommand extends Command
{
public const string NAME = 'api-key:rename';
@@ -26,20 +30,11 @@ class RenameApiKeyCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Renames an API key by name')
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the API key to rename')
->addArgument('newName', InputArgument::REQUIRED, 'New name to set to the API key');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName');
$oldName = $input->getArgument('old-name');
$newName = $input->getArgument('new-name');
if ($oldName === null) {
$apiKeys = $this->apiKeyService->listKeys();
@@ -48,7 +43,7 @@ class RenameApiKeyCommand extends Command
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('oldName', $requestedOldName);
$input->setArgument('old-name', $requestedOldName);
}
if ($newName === null) {
@@ -59,19 +54,18 @@ class RenameApiKeyCommand extends Command
: throw new InvalidArgumentException('The new name cannot be empty'),
);
$input->setArgument('newName', $requestedNewName);
$input->setArgument('new-name', $requestedNewName);
}
}
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(description: 'Current name of the API key to rename')] string $oldName,
#[Argument(description: 'New name to set to the API key')] string $newName,
): int {
$this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName));
$io->success('API key properly renamed');
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Config;
use Closure;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
@@ -63,6 +62,6 @@ class ReadEnvVarCommand extends Command
$envVar = $input->getArgument('envVar');
$output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
}

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -55,7 +54,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
if ($this->databaseTablesExist()) {
$io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
// Create database
@@ -63,7 +62,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
$this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]);
$io->success('Database properly created!');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
private function databaseTablesExist(): bool

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -31,6 +30,6 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand
$this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]);
$io->success('Database properly migrated!');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
@@ -109,6 +108,6 @@ class DomainRedirectsCommand extends Command
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
@@ -59,7 +58,7 @@ class ListDomainsCommand extends Command
}, $domains),
);
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Integration;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
@@ -84,7 +83,7 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
if (! $this->matomoEnabled) {
$this->io->warning('Matomo integration is not enabled in this Shlink instance');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
// TODO Validate provided date formats
@@ -103,7 +102,7 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
. 'you have verified only visits in the right date range are going to be sent.',
]);
if (! $this->io->confirm('Continue?', default: false)) {
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
}
@@ -122,7 +121,7 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
default => $this->io->info('There was no visits matching provided date range.'),
};
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
public function success(int $index): void

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\RedirectRule;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
@@ -52,7 +51,7 @@ class ManageRedirectRulesCommand extends Command
$shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier);
} catch (ShortUrlNotFoundException) {
$io->error(sprintf('Short URL for %s not found', $identifier->__toString()));
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
$rulesToSave = $this->ruleHandler->manageRules($io, $shortUrl, $this->ruleService->rulesForShortUrl($shortUrl));
@@ -61,6 +60,6 @@ class ManageRedirectRulesCommand extends Command
$io->success('Rules properly saved');
}
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
@@ -114,10 +113,10 @@ class CreateShortUrlCommand extends Command
sprintf('Processed long URL: <info>%s</info>', $result->shortUrl->getLongUrl()),
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
]);
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (NonUniqueSlugException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
use Symfony\Component\Console\Command\Command;
@@ -58,18 +57,18 @@ class DeleteExpiredShortUrlsCommand extends Command
'This action cannot be undone. Proceed at your own risk',
]);
if (! $io->confirm('Continue?', default: false)) {
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
}
if ($dryRun) {
$result = $this->deleteShortUrlService->countExpiredShortUrls($conditions);
$io->success(sprintf('There are %s expired short URLs matching provided conditions', $result));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
$result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions);
$io->success(sprintf('%s expired short URLs have been deleted', $result));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
@@ -55,10 +54,10 @@ class DeleteShortUrlCommand extends Command
try {
$this->runDelete($io, $identifier, $ignoreThreshold);
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (Exception\ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
} catch (Exception\DeleteShortUrlException $e) {
return $this->retry($io, $identifier, $e->getMessage());
}
@@ -75,7 +74,7 @@ class DeleteShortUrlCommand extends Command
$io->warning('Short URL was not deleted.');
}
return $forceDelete ? ExitCode::EXIT_SUCCESS : ExitCode::EXIT_WARNING;
return $forceDelete ? self::SUCCESS : self::INVALID;
}
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
@@ -44,10 +43,10 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
$result = $this->deleter->deleteShortUrlVisits($identifier);
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (ShortUrlNotFoundException) {
$io->warning(sprintf('Short URL not found for "%s"', $identifier->__toString()));
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
}

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
@@ -57,7 +56,7 @@ class EditShortUrlCommand extends Command
);
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error(sprintf('Short URL not found for "%s"', $identifier->__toString()));
@@ -65,7 +64,7 @@ class EditShortUrlCommand extends Command
$this->getApplication()?->renderThrowable($e, $io);
}
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
}

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
@@ -176,7 +175,7 @@ class ListShortUrlsCommand extends Command
$io->newLine();
$io->success('Short URLs properly listed');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
/**

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Command\Command;
@@ -59,10 +58,10 @@ class ResolveUrlCommand extends Command
try {
$url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input));
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -41,11 +40,11 @@ class DeleteTagsCommand extends Command
if (empty($tagNames)) {
$io->warning('You have to provide at least one tag name');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
$this->tagService->deleteTags($tagNames);
$io->success('Tags properly deleted');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
@@ -34,7 +33,7 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
private function getTagsRows(): array

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\Renaming;
@@ -42,10 +41,10 @@ class RenameTagCommand extends Command
try {
$this->tagService->renameTag(Renaming::fromNames($oldName, $newName));
$io->success('Tag properly renamed.');
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
} catch (TagNotFoundException | TagConflictException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return Command::FAILURE;
}
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -28,7 +27,7 @@ abstract class AbstractLockedCommand extends Command
$output->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
);
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
try {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -17,7 +16,7 @@ abstract class AbstractDeleteVisitsCommand extends Command
$io = new SymfonyStyle($input, $output);
if (! $this->confirm($io)) {
$io->info('Operation aborted');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
return $this->doExecute($input, $io);

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
@@ -43,7 +42,7 @@ abstract class AbstractVisitsListCommand extends Command
ShlinkTable::default($output)->render($headers, $rows);
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
/**

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -32,7 +31,7 @@ class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
$result = $this->deleter->deleteOrphanVisits();
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
protected function getWarningMessage(): string

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
@@ -48,17 +47,17 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
if ($result === GeolocationResult::LICENSE_MISSING) {
$this->io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
if ($result === GeolocationResult::MAX_ERRORS_REACHED) {
$this->io->warning('Max consecutive errors reached. Cannot retry for a couple of days.');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
if ($result === GeolocationResult::UPDATE_IN_PROGRESS) {
$this->io->warning('A geolocation db is already being downloaded by another process.');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
if ($this->progressBar === null) {
@@ -68,7 +67,7 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
$this->io->success('GeoLite2 db file properly downloaded.');
}
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (GeolocationDbUpdateFailedException $e) {
return $this->processGeoLiteUpdateError($e, $this->io);
}
@@ -90,7 +89,7 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
$this->getApplication()?->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE;
return $olderDbExists ? self::INVALID : self::FAILURE;
}
public function beforeDownload(bool $olderDbExists): void

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@@ -116,14 +115,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
}
$this->io->success('Finished locating visits');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (Throwable $e) {
$this->io->error($e->getMessage());
if ($this->io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $this->io);
}
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
@@ -171,7 +170,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
$exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io);
if ($exitCode === ExitCode::EXIT_FAILURE) {
if ($exitCode === self::FAILURE) {
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
}
}

View File

@@ -108,6 +108,12 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
$this->askMandatory('Query param name?', $io),
$this->askOptional('Query param value?', $io),
),
RedirectConditionType::ANY_VALUE_QUERY_PARAM => RedirectCondition::forAnyValueQueryParam(
$this->askMandatory('Query param name?', $io),
),
RedirectConditionType::VALUELESS_QUERY_PARAM => RedirectCondition::forValuelessQueryParam(
$this->askMandatory('Query param name?', $io),
),
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
),

View File

@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
final class ExitCode
{
public const int EXIT_SUCCESS = 0;
public const int EXIT_FAILURE = -1;
public const int EXIT_WARNING = 1;
}

View File

@@ -7,9 +7,9 @@ namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
use Symfony\Component\Console\Command\Command;
class CreateShortUrlTest extends CliTestCase
{
@@ -23,7 +23,7 @@ class CreateShortUrlTest extends CliTestCase
[CreateShortUrlCommand::NAME, 'https://example.com', '--domain', $defaultDomain, '--custom-slug', $slug],
);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output);
[$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]);

View File

@@ -6,8 +6,8 @@ namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
use Symfony\Component\Console\Command\Command;
class GenerateApiKeyTest extends CliTestCase
{
@@ -17,6 +17,6 @@ class GenerateApiKeyTest extends CliTestCase
[$output, $exitCode] = $this->exec([GenerateKeyCommand::NAME]);
self::assertStringContainsString('[OK] Generated API key', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
}
}

View File

@@ -8,8 +8,8 @@ use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
use Symfony\Component\Console\Command\Command;
class ListApiKeysTest extends CliTestCase
{
@@ -19,7 +19,7 @@ class ListApiKeysTest extends CliTestCase
[$output, $exitCode] = $this->exec([ListKeysCommand::NAME, ...$flags]);
self::assertEquals($expectedOutput, $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
}
public static function provideFlags(): iterable

View File

@@ -8,12 +8,12 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class DisableKeyCommandTest extends TestCase
@@ -35,12 +35,12 @@ class DisableKeyCommandTest extends TestCase
$this->apiKeyService->expects($this->never())->method('disableByName');
$exitCode = $this->commandTester->execute([
'keyOrName' => $apiKey,
'key-or-name' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
}
#[Test]
@@ -51,13 +51,13 @@ class DisableKeyCommandTest extends TestCase
$this->apiKeyService->expects($this->never())->method('disableByKey');
$exitCode = $this->commandTester->execute([
'keyOrName' => $name,
'key-or-name' => $name,
'--by-name' => true,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
}
#[Test]
@@ -71,12 +71,12 @@ class DisableKeyCommandTest extends TestCase
$this->apiKeyService->expects($this->never())->method('disableByName');
$exitCode = $this->commandTester->execute([
'keyOrName' => $apiKey,
'key-or-name' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($expectedMessage, $output);
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
self::assertEquals(Command::FAILURE, $exitCode);
}
#[Test]
@@ -90,13 +90,13 @@ class DisableKeyCommandTest extends TestCase
$this->apiKeyService->expects($this->never())->method('disableByKey');
$exitCode = $this->commandTester->execute([
'keyOrName' => $name,
'key-or-name' => $name,
'--by-name' => true,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString($expectedMessage, $output);
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
self::assertEquals(Command::FAILURE, $exitCode);
}
#[Test]
@@ -108,7 +108,7 @@ class DisableKeyCommandTest extends TestCase
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
self::assertEquals(Command::INVALID, $exitCode);
}
#[Test]
@@ -128,6 +128,6 @@ class DisableKeyCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
}
}

View File

@@ -10,12 +10,11 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase
@@ -27,7 +26,7 @@ class GenerateKeyCommandTest extends TestCase
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$roleResolver = $this->createMock(RoleResolverInterface::class);
$roleResolver->method('determineRoles')->with($this->isInstanceOf(InputInterface::class))->willReturn([]);
$roleResolver->method('determineRoles')->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService, $roleResolver);
$this->commandTester = CliTestUtils::testerForCommand($command);
@@ -69,6 +68,6 @@ class GenerateKeyCommandTest extends TestCase
'--name' => 'Alice',
]);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
}
}

View File

@@ -43,7 +43,7 @@ class RenameApiKeyCommandTest extends TestCase
$this->commandTester->setInputs([$oldName]);
$this->commandTester->execute([
'newName' => $newName,
'new-name' => $newName,
]);
}
@@ -60,7 +60,7 @@ class RenameApiKeyCommandTest extends TestCase
$this->commandTester->setInputs([$newName]);
$this->commandTester->execute([
'oldName' => $oldName,
'old-name' => $oldName,
]);
}
@@ -76,8 +76,8 @@ class RenameApiKeyCommandTest extends TestCase
);
$this->commandTester->execute([
'oldName' => $oldName,
'newName' => $newName,
'old-name' => $oldName,
'new-name' => $newName,
]);
}
}

View File

@@ -40,11 +40,11 @@ class CreateDatabaseCommandTest extends TestCase
{
$locker = $this->createMock(LockFactory::class);
$lock = $this->createMock(SharedLockInterface::class);
$lock->method('acquire')->withAnyParameters()->willReturn(true);
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
$lock->method('acquire')->willReturn(true);
$locker->method('createLock')->willReturn($lock);
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
$phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php');
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
$this->schemaManager = $this->createMock(AbstractSchemaManager::class);
@@ -60,7 +60,7 @@ class CreateDatabaseCommandTest extends TestCase
$em->method('getMetadataFactory')->willReturn($this->metadataFactory);
$noDbNameConn = $this->createMock(Connection::class);
$noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager);
$noDbNameConn->method('createSchemaManager')->willReturn($this->schemaManager);
$command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn);
$this->commandTester = CliTestUtils::testerForCommand($command);

View File

@@ -25,11 +25,11 @@ class MigrateDatabaseCommandTest extends TestCase
{
$locker = $this->createMock(LockFactory::class);
$lock = $this->createMock(SharedLockInterface::class);
$lock->method('acquire')->withAnyParameters()->willReturn(true);
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
$lock->method('acquire')->willReturn(true);
$locker->method('createLock')->willReturn($lock);
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
$phpExecutableFinder->method('find')->willReturn('/usr/local/bin/php');
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);

View File

@@ -9,13 +9,13 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Config\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class ListDomainsCommandTest extends TestCase
@@ -51,7 +51,7 @@ class ListDomainsCommandTest extends TestCase
$this->commandTester->execute($input);
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
}
public static function provideInputsAndOutputs(): iterable

View File

@@ -8,12 +8,12 @@ use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Integration\MatomoSendVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
class MatomoSendVisitsCommandTest extends TestCase
{
@@ -30,7 +30,7 @@ class MatomoSendVisitsCommandTest extends TestCase
[$output, $exitCode] = $this->executeCommand(matomoEnabled: false);
self::assertStringContainsString('Matomo integration is not enabled in this Shlink instance', $output);
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
self::assertEquals(Command::INVALID, $exitCode);
}
#[Test]
@@ -74,7 +74,7 @@ class MatomoSendVisitsCommandTest extends TestCase
[$output, $exitCode] = $this->executeCommand(['y']);
self::assertStringContainsString($expectedResultMessage, $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
}
#[Test]

View File

@@ -9,13 +9,13 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\RedirectRule\ManageRedirectRulesCommand;
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class ManageRedirectRulesCommandTest extends TestCase
@@ -51,7 +51,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
self::assertEquals(Command::FAILURE, $exitCode);
self::assertStringContainsString('Short URL for foo not found', $output);
}
@@ -70,7 +70,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
self::assertStringNotContainsString('Rules properly saved', $output);
}
@@ -89,7 +89,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
self::assertStringContainsString('Rules properly saved', $output);
}
}

View File

@@ -11,7 +11,6 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@@ -20,6 +19,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
@@ -59,7 +59,7 @@ class CreateShortUrlCommandTest extends TestCase
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString('stringified_short_url', $output);
self::assertStringNotContainsString('but the real-time updates cannot', $output);
}
@@ -70,12 +70,12 @@ class CreateShortUrlCommandTest extends TestCase
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
NonUniqueSlugException::fromSlug('my-slug'),
);
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertEquals(Command::FAILURE, $this->commandTester->getStatusCode());
self::assertStringContainsString('Provided slug "my-slug" is already in use', $output);
}
@@ -99,7 +99,7 @@ class CreateShortUrlCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString('stringified_short_url', $output);
}
@@ -112,12 +112,12 @@ class CreateShortUrlCommandTest extends TestCase
return true;
}),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($input);
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
}
public static function provideDomains(): iterable
@@ -139,7 +139,7 @@ class CreateShortUrlCommandTest extends TestCase
return true;
}),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$this->stringifier->method('stringify')->willReturn('');
$options['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options);

View File

@@ -9,10 +9,10 @@ use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteExpiredShortUrlsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class DeleteExpiredShortUrlsCommandTest extends TestCase
@@ -38,7 +38,7 @@ class DeleteExpiredShortUrlsCommandTest extends TestCase
$status = $this->commandTester->getStatusCode();
self::assertStringContainsString('Careful!', $output);
self::assertEquals(ExitCode::EXIT_WARNING, $status);
self::assertEquals(Command::INVALID, $status);
}
#[Test]
@@ -62,7 +62,7 @@ class DeleteExpiredShortUrlsCommandTest extends TestCase
self::assertStringNotContainsString('Careful!', $output);
}
self::assertStringContainsString('5 expired short URLs have been deleted', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $status);
self::assertEquals(Command::SUCCESS, $status);
}
#[Test]
@@ -77,7 +77,7 @@ class DeleteExpiredShortUrlsCommandTest extends TestCase
self::assertStringNotContainsString('Careful!', $output);
self::assertStringContainsString('There are 38 expired short URLs matching provided conditions', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $status);
self::assertEquals(Command::SUCCESS, $status);
}
#[Test]

View File

@@ -9,11 +9,11 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class DeleteShortUrlVisitsCommandTest extends TestCase
@@ -36,7 +36,7 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
self::assertStringContainsString('Operation aborted', $output);
}
@@ -58,7 +58,7 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
$exitCode = $this->commandTester->execute($args);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
self::assertEquals(Command::INVALID, $exitCode);
self::assertStringContainsString($expectedError, $output);
}
@@ -77,7 +77,7 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
self::assertStringContainsString('Successfully deleted 5 visits', $output);
}
}

View File

@@ -7,13 +7,13 @@ use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\EditShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
@@ -45,7 +45,7 @@ class EditShortUrlCommandTest extends TestCase
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString('Short URL "https://s.test/foo" properly edited', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
}
#[Test]
@@ -69,6 +69,6 @@ class EditShortUrlCommandTest extends TestCase
} else {
self::assertStringNotContainsString('Exception trace:', $output);
}
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
self::assertEquals(Command::FAILURE, $exitCode);
}
}

View File

@@ -8,10 +8,10 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\DeleteOrphanVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class DeleteOrphanVisitsCommandTest extends TestCase
@@ -34,7 +34,7 @@ class DeleteOrphanVisitsCommandTest extends TestCase
$exitCode = $this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertEquals(Command::SUCCESS, $exitCode);
self::assertStringContainsString('You are about to delete all orphan visits.', $output);
self::assertStringContainsString('Successfully deleted 5 visits', $output);
}

View File

@@ -10,12 +10,12 @@ use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
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 ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
use function sprintf;
@@ -65,12 +65,12 @@ class DownloadGeoLiteDbCommandTest extends TestCase
yield 'existing db' => [
true,
'[WARNING] GeoLite2 db file update failed. Visits will continue to be located',
ExitCode::EXIT_WARNING,
Command::INVALID,
];
yield 'not existing db' => [
false,
'[ERROR] GeoLite2 db file download failed. It will not be possible to locate',
ExitCode::EXIT_FAILURE,
Command::FAILURE,
];
}
@@ -87,7 +87,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString('[WARNING] ' . $expectedWarningMessage, $output);
self::assertSame(ExitCode::EXIT_WARNING, $exitCode);
self::assertSame(Command::INVALID, $exitCode);
}
#[Test, DataProvider('provideSuccessParams')]
@@ -102,7 +102,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString($expectedMessage, $output);
self::assertSame(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertSame(Command::SUCCESS, $exitCode);
}
public static function provideSuccessParams(): iterable

View File

@@ -10,7 +10,6 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@@ -47,7 +46,7 @@ class LocateVisitsCommandTest extends TestCase
$locker = $this->createMock(Lock\LockFactory::class);
$this->lock = $this->createMock(Lock\SharedLockInterface::class);
$locker->method('createLock')->with($this->isString(), 600.0, false)->willReturn($this->lock);
$locker->method('createLock')->willReturn($this->lock);
$command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker);
@@ -67,7 +66,7 @@ class LocateVisitsCommandTest extends TestCase
$location = VisitLocation::fromGeolocation(Location::empty());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->exactly($expectedUnlocatedCalls))
->method('locateUnlocatedVisits')
->withAnyParameters()
@@ -83,7 +82,7 @@ class LocateVisitsCommandTest extends TestCase
$this->visitToLocation->expects(
$this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls),
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance());
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute($args);
@@ -108,15 +107,15 @@ class LocateVisitsCommandTest extends TestCase
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::empty());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->once())
->method('locateUnlocatedVisits')
->withAnyParameters()
->willReturnCallback($this->invokeHelperMethods($visit, $location));
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException($e);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
@@ -137,7 +136,7 @@ class LocateVisitsCommandTest extends TestCase
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4'));
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->lock->method('acquire')->willReturn(true);
$this->visitService->expects($this->once())
->method('locateUnlocatedVisits')
->withAnyParameters()
@@ -145,7 +144,7 @@ class LocateVisitsCommandTest extends TestCase
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException(
IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')),
);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
@@ -165,11 +164,11 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function noActionIsPerformedIfLockIsAcquired(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(false);
$this->lock->method('acquire')->willReturn(false);
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
$this->visitToLocation->expects($this->never())->method('resolveVisitLocation');
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
@@ -183,8 +182,8 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function showsProperMessageWhenGeoLiteUpdateFails(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_FAILURE);
$this->lock->method('acquire')->willReturn(true);
$this->downloadDbCommand->method('run')->willReturn(Command::FAILURE);
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
$this->commandTester->execute([]);
@@ -196,8 +195,8 @@ class LocateVisitsCommandTest extends TestCase
#[Test]
public function providingAllFlagOnItsOwnDisplaysNotice(): void
{
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->lock->method('acquire')->willReturn(true);
$this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS);
$this->commandTester->execute(['--all' => true]);
$output = $this->commandTester->getDisplay();
@@ -208,7 +207,7 @@ class LocateVisitsCommandTest extends TestCase
#[Test, DataProvider('provideAbortInputs')]
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
{
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
$this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Execution aborted');

View File

@@ -71,6 +71,7 @@ class RedirectRuleHandlerTest extends TestCase
#[Test, DataProvider('provideExitActions')]
public function rulesAreDisplayedWhenRulesListIsEmpty(
RedirectRuleHandlerAction $action,
array|null $_,
): void {
$comment = fn (string $value) => sprintf('<comment>%s</comment>', $value);
@@ -161,6 +162,14 @@ class RedirectRuleHandlerTest extends TestCase
yield 'device' => [RedirectConditionType::DEVICE, [RedirectCondition::forDevice(DeviceType::ANDROID)]];
yield 'language' => [RedirectConditionType::LANGUAGE, [RedirectCondition::forLanguage('en-US')]];
yield 'query param' => [RedirectConditionType::QUERY_PARAM, [RedirectCondition::forQueryParam('foo', 'bar')]];
yield 'any value query param' => [
RedirectConditionType::ANY_VALUE_QUERY_PARAM,
[RedirectCondition::forAnyValueQueryParam('foo')],
];
yield 'valueless query param' => [
RedirectConditionType::VALUELESS_QUERY_PARAM,
[RedirectCondition::forValuelessQueryParam('foo')],
];
yield 'multiple query params' => [
RedirectConditionType::QUERY_PARAM,
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],

View File

@@ -25,11 +25,8 @@ class CliTestUtils
$command = $generator->testDouble(
Command::class,
mockObject: true,
markAsMockObject: true,
callOriginalConstructor: false,
callOriginalClone: false,
cloneArguments: false,
allowMockingUnknownTypes: false,
);
$command->method('getName')->willReturn($name);
$command->method('isEnabled')->willReturn(true);

View File

@@ -27,8 +27,8 @@ class ProcessRunnerTest extends TestCase
$this->helper = $this->createMock(ProcessHelper::class);
$this->formatter = $this->createMock(DebugFormatterHelper::class);
$helperSet = $this->createMock(HelperSet::class);
$helperSet->method('get')->with('debug_formatter')->willReturn($this->formatter);
$this->helper->method('getHelperSet')->with()->willReturn($helperSet);
$helperSet->method('get')->willReturn($this->formatter);
$this->helper->method('getHelperSet')->willReturn($helperSet);
$this->process = $this->createMock(Process::class);
$this->output = $this->createMock(OutputInterface::class);

View File

@@ -36,6 +36,8 @@ return [
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'],
Config\Options\CorsOptions::class => [Config\Options\CorsOptions::class, 'fromEnv'],
RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class,
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
@@ -227,6 +229,7 @@ return [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [
'httpClient',
Config\Options\UrlShortenerOptions::class,
'Logger_Shlink',
],
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [
Config\Options\TrackingOptions::class,

View File

@@ -39,5 +39,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
fieldWithUtf8Charset($builder->createField('matchValue', Types::STRING), $emConfig)
->columnName('match_value')
->length(512)
->nullable()
->build();
};

View File

@@ -73,6 +73,9 @@ return (static function (): array {
],
'delegators' => [
EventDispatcher\Matomo\SendVisitToMatomo::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
@@ -94,6 +97,9 @@ return (static function (): array {
EventDispatcher\LocateUnlocatedVisits::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\UpdateGeoLiteDb::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
],
],
@@ -104,18 +110,21 @@ return (static function (): array {
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Config\Options\RealTimeUpdatesOptions::class,
],
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Config\Options\RealTimeUpdatesOptions::class,
],
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Config\Options\RealTimeUpdatesOptions::class,
Config\Options\RabbitMqOptions::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
@@ -123,6 +132,7 @@ return (static function (): array {
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Config\Options\RealTimeUpdatesOptions::class,
Config\Options\RabbitMqOptions::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
@@ -130,6 +140,7 @@ return (static function (): array {
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Config\Options\RealTimeUpdatesOptions::class,
'config.redis.pub_sub_enabled',
],
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
@@ -137,6 +148,7 @@ return (static function (): array {
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Config\Options\RealTimeUpdatesOptions::class,
'config.redis.pub_sub_enabled',
],

View File

@@ -10,6 +10,11 @@ use function in_array;
use const ARRAY_FILTER_USE_KEY;
/**
* @template T
* @param T $value
* @param T[] $array
*/
function contains(mixed $value, array $array): bool
{
return in_array($value, $array, strict: true);

View File

@@ -143,6 +143,7 @@ function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0):
*/
function splitLocale(string $locale): array
{
/** @var string $lang */
[$lang, $countryCode] = array_pad(explode('-', $locale), length: 2, value: null);
return [$lang, $countryCode];
}
@@ -255,14 +256,36 @@ function toProblemDetailsType(string $errorCode): string
* @return string[]
*/
function enumValues(string $enum): array
{
return enumSide($enum, 'value');
}
/**
* @param class-string<BackedEnum> $enum
* @return string[]
*/
function enumNames(string $enum): array
{
return enumSide($enum, 'name');
}
/**
* @param class-string<BackedEnum> $enum
* @param 'name'|'value' $type
* @return string[]
*/
function enumSide(string $enum, string $type): array
{
static $cache;
if ($cache === null) {
$cache = [];
}
return $cache[$enum] ?? (
$cache[$enum] = array_map(static fn (BackedEnum $type) => (string) $type->value, $enum::cases())
return $cache[$type][$enum] ?? (
$cache[$type][$enum] = array_map(
static fn (BackedEnum $entry) => (string) ($type === 'name' ? $entry->name : $entry->value),
$enum::cases(),
)
);
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Fix an incorrectly generated unique index in Microsoft SQL, on short_urls table, for short_code + domain_id columns.
* The index was generated only for rows where both columns were not null, which is not the desired behavior, as
* domain_id can be null.
* This is due to a bug in doctrine/dbal: https://github.com/doctrine/dbal/issues/3671
*
* FIXME DO NOT DELETE THIS MIGRATION! IT IS NOT POSSIBLE TO DO THIS IN ENTITY CONFIG CODE WHILE THE BUG EXISTS
*/
final class Version20250215100756 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->skipIf(! $this->isMicrosoftSql());
// Drop the existing unique index
$shortUrls = $schema->getTable('short_urls');
$shortUrls->dropIndex('unique_short_code_plus_domain');
}
public function postUp(Schema $schema): void
{
// The only way to get the index properly generated is by hardcoding the SQL.
// Since this migration is run Microsoft SQL only, it is safe to use this approach.
$this->connection->executeStatement(
'CREATE UNIQUE INDEX unique_short_code_plus_domain ON short_urls (short_code, domain_id);',
);
}
private function isMicrosoftSql(): bool
{
return $this->connection->getDatabasePlatform() instanceof SQLServerPlatform;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Make the redirect_condition match_value column nullable, so that we can support new valueless-query-param and
* any-value-query-param conditions consistently.
*/
final class Version20250722060208 extends AbstractMigration
{
public function up(Schema $schema): void
{
$schema->getTable('redirect_conditions')->getColumn('match_value')->setNotnull(false);
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View File

@@ -28,6 +28,7 @@ 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;
@@ -38,6 +39,7 @@ final readonly class QrCodeParams
public int $size,
public int $margin,
public WriterInterface $writer,
public array $writerOptions,
public ErrorCorrectionLevel $errorCorrectionLevel,
public RoundBlockSizeMode $roundBlockSizeMode,
public ColorInterface $color,
@@ -49,11 +51,13 @@ final readonly class QrCodeParams
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: self::resolveWriter($query, $defaults),
writer: $writer,
writerOptions: $writerOptions,
errorCorrectionLevel: self::resolveErrorCorrection($query, $defaults),
roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults),
color: self::resolveColor($query, $defaults),
@@ -83,14 +87,17 @@ final readonly class QrCodeParams
return max($intMargin, 0);
}
private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface
/**
* @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(),
'svg' => [new SvgWriter(), []],
default => [new PngWriter(), [PngWriter::WRITER_OPTION_NUMBER_OF_COLORS => null]],
};
}

View File

@@ -19,6 +19,7 @@ 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(
@@ -45,6 +46,7 @@ readonly class QrCodeAction implements MiddlewareInterface
$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,

View File

@@ -42,6 +42,7 @@ enum EnvVars: string
case REDIS_SERVERS = 'REDIS_SERVERS';
case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED';
case MERCURE_ENABLED = 'MERCURE_ENABLED';
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL';
case MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET';
@@ -56,20 +57,12 @@ enum EnvVars: string
case MATOMO_BASE_URL = 'MATOMO_BASE_URL';
case MATOMO_SITE_ID = 'MATOMO_SITE_ID';
case MATOMO_API_TOKEN = 'MATOMO_API_TOKEN';
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS';
case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR';
case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR';
case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL';
case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';
case REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE';
case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME';
case REDIRECT_CACHE_VISIBILITY = 'REDIRECT_CACHE_VISIBILITY';
case BASE_PATH = 'BASE_PATH';
case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH';
case SHORT_URL_MODE = 'SHORT_URL_MODE';
@@ -93,8 +86,33 @@ enum EnvVars: string
case MEMORY_LIMIT = 'MEMORY_LIMIT';
case INITIAL_API_KEY = 'INITIAL_API_KEY';
case SKIP_INITIAL_GEOLITE_DOWNLOAD = 'SKIP_INITIAL_GEOLITE_DOWNLOAD';
case REAL_TIME_UPDATES_TOPICS = 'REAL_TIME_UPDATES_TOPICS';
case CORS_ALLOW_ORIGIN = 'CORS_ALLOW_ORIGIN';
case CORS_ALLOW_CREDENTIALS = 'CORS_ALLOW_CREDENTIALS';
case CORS_MAX_AGE = 'CORS_MAX_AGE';
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
{
@@ -150,6 +168,7 @@ enum EnvVars: string
},
self::DB_USE_ENCRYPTION => false,
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,
@@ -174,6 +193,12 @@ enum EnvVars: string
self::DISABLE_REFERRER_TRACKING,
self::DISABLE_UA_TRACKING => false,
self::CORS_ALLOW_ORIGIN => '*',
self::CORS_ALLOW_CREDENTIALS => false,
self::CORS_MAX_AGE => 3600,
self::LOGS_FORMAT => 'console',
default => null,
};
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Shlinkio\Shlink\Core\Config\Options;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\splitByComma;
use function strtolower;
final readonly class CorsOptions
{
private const string ORIGIN_PATTERN = '<origin>';
/** @var string[]|'*'|'<origin>' */
public string|array $allowOrigins;
public function __construct(
string $allowOrigins = '*',
public bool $allowCredentials = false,
public int $maxAge = 3600,
) {
$lowerCaseAllowOrigins = strtolower($allowOrigins);
$this->allowOrigins = $lowerCaseAllowOrigins === '*' || $lowerCaseAllowOrigins === self::ORIGIN_PATTERN
? $lowerCaseAllowOrigins
: splitByComma($lowerCaseAllowOrigins);
}
public static function fromEnv(): self
{
return new self(
allowOrigins: EnvVars::CORS_ALLOW_ORIGIN->loadFromEnv(),
allowCredentials: EnvVars::CORS_ALLOW_CREDENTIALS->loadFromEnv(),
maxAge: EnvVars::CORS_MAX_AGE->loadFromEnv(),
);
}
public function responseWithAllowOrigin(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
if ($this->allowOrigins === '*') {
return $response->withHeader('Access-Control-Allow-Origin', '*');
}
$requestOrigin = $request->getHeaderLine('Origin');
if (
// The special <origin> value means we should allow requests from the origin set in the request's Origin
// header
$this->allowOrigins === self::ORIGIN_PATTERN
// If an array of allowed hosts was provided, set Access-Control-Allow-Origin header only if request's
// Origin header matches one of them
|| contains($requestOrigin, $this->allowOrigins)
) {
return $response->withHeader('Access-Control-Allow-Origin', $requestOrigin);
}
return $response;
}
}

View File

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

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config\Options;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\EventDispatcher\Topic;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use function count;
use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function Shlinkio\Shlink\Core\splitByComma;
use function sprintf;
final readonly class RealTimeUpdatesOptions
{
/** @var string[] */
public array $enabledTopics;
public function __construct(array|null $enabledTopics = null)
{
$validTopics = Topic::allTopicNames();
$this->enabledTopics = $enabledTopics === null ? $validTopics : self::validateTopics(
$enabledTopics,
$validTopics,
);
}
public static function fromEnv(): self
{
$enabledTopics = splitByComma(EnvVars::REAL_TIME_UPDATES_TOPICS->loadFromEnv());
return new self(enabledTopics: count($enabledTopics) === 0 ? null : $enabledTopics);
}
/**
* @param string[] $validTopics
* @return string[]
*/
private static function validateTopics(array $providedTopics, array $validTopics): array
{
return map($providedTopics, function (string $topic) use ($validTopics): string {
if (contains($topic, $validTopics)) {
return $topic;
}
throw ValidationException::fromArray([
'topic' => sprintf(
'Real-time updates topic "%s" is not valid. Expected one of ["%s"].',
$topic,
implode('", "', $validTopics),
),
]);
});
}
public function isTopicEnabled(Topic $topic): bool
{
return contains($topic->name, $this->enabledTopics);
}
}

View File

@@ -4,26 +4,32 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config\Options;
use Fig\Http\Message\StatusCodeInterface;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Util\RedirectStatus;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_VISIBILITY;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
final readonly class RedirectOptions
{
public RedirectStatus $redirectStatusCode;
public int $redirectCacheLifetime;
/** @var 'public'|'private' */
public string $redirectCacheVisibility;
public function __construct(
int $redirectStatusCode = StatusCodeInterface::STATUS_FOUND,
int $redirectStatusCode = RedirectStatus::STATUS_302->value,
int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME,
string|null $redirectCacheVisibility = DEFAULT_REDIRECT_CACHE_VISIBILITY,
) {
$this->redirectStatusCode = RedirectStatus::tryFrom($redirectStatusCode) ?? DEFAULT_REDIRECT_STATUS_CODE;
$this->redirectCacheLifetime = $redirectCacheLifetime > 0
? $redirectCacheLifetime
: DEFAULT_REDIRECT_CACHE_LIFETIME;
$this->redirectCacheVisibility = $redirectCacheVisibility === 'public' || $redirectCacheVisibility === 'private'
? $redirectCacheVisibility
: DEFAULT_REDIRECT_CACHE_VISIBILITY;
}
public static function fromEnv(): self
@@ -31,6 +37,7 @@ final readonly class RedirectOptions
return new self(
redirectStatusCode: (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(),
redirectCacheLifetime: (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(),
redirectCacheVisibility: EnvVars::REDIRECT_CACHE_VISIBILITY->loadFromEnv(),
);
}
}

View File

@@ -7,8 +7,10 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Async;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Core\Config\Options\RealTimeUpdatesOptions;
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Topic;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Throwable;
@@ -19,6 +21,7 @@ abstract class AbstractNotifyNewShortUrlListener extends AbstractAsyncListener
private readonly PublishingUpdatesGeneratorInterface $updatesGenerator,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly RealTimeUpdatesOptions $realTimeUpdatesOptions,
) {
}
@@ -40,6 +43,10 @@ abstract class AbstractNotifyNewShortUrlListener extends AbstractAsyncListener
return;
}
if (! $this->realTimeUpdatesOptions->isTopicEnabled(Topic::NEW_SHORT_URL)) {
return;
}
try {
$this->publishingHelper->publishUpdate($this->updatesGenerator->newShortUrlUpdate($shortUrl));
} catch (Throwable $e) {

View File

@@ -8,8 +8,10 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
use Shlinkio\Shlink\Core\Config\Options\RealTimeUpdatesOptions;
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Topic;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Throwable;
@@ -22,6 +24,7 @@ abstract class AbstractNotifyVisitListener extends AbstractAsyncListener
private readonly PublishingUpdatesGeneratorInterface $updatesGenerator,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly RealTimeUpdatesOptions $realTimeUpdatesOptions,
) {
}
@@ -61,12 +64,19 @@ abstract class AbstractNotifyVisitListener extends AbstractAsyncListener
protected function determineUpdatesForVisit(Visit $visit): array
{
if ($visit->isOrphan()) {
return [$this->updatesGenerator->newOrphanVisitUpdate($visit)];
return $this->realTimeUpdatesOptions->isTopicEnabled(Topic::NEW_ORPHAN_VISIT)
? [$this->updatesGenerator->newOrphanVisitUpdate($visit)]
: [];
}
return [
$this->updatesGenerator->newShortUrlVisitUpdate($visit),
$this->updatesGenerator->newVisitUpdate($visit),
];
$topics = [];
if ($this->realTimeUpdatesOptions->isTopicEnabled(Topic::NEW_SHORT_URL_VISIT)) {
$topics[] = $this->updatesGenerator->newShortUrlVisitUpdate($visit);
}
if ($this->realTimeUpdatesOptions->isTopicEnabled(Topic::NEW_VISIT)) {
$topics[] = $this->updatesGenerator->newVisitUpdate($visit);
}
return $topics;
}
}

View File

@@ -11,7 +11,7 @@ class CloseDbConnectionEventListener
/** @var callable */
private $wrapped;
public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped)
public function __construct(private readonly ReopeningEntityManagerInterface $em, callable $wrapped)
{
$this->wrapped = $wrapped;
}

View File

@@ -34,7 +34,7 @@ readonly class EnabledListenerChecker implements EnabledListenerCheckerInterface
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => $this->redisPubSubEnabled,
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(),
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->enabled,
EventDispatcher\Matomo\SendVisitToMatomo::class => $this->matomoOptions->enabled,
EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(),
default => false, // Any unknown async listener should not be enabled by default

View File

@@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Core\Config\Options\RabbitMqOptions;
use Shlinkio\Shlink\Core\Config\Options\RealTimeUpdatesOptions;
use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyNewShortUrlListener;
use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
@@ -19,9 +20,10 @@ class NotifyNewShortUrlToRabbitMq extends AbstractNotifyNewShortUrlListener
PublishingUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger,
RealTimeUpdatesOptions $realTimeUpdatesOptions,
private readonly RabbitMqOptions $options,
) {
parent::__construct($rabbitMqHelper, $updatesGenerator, $em, $logger);
parent::__construct($rabbitMqHelper, $updatesGenerator, $em, $logger, $realTimeUpdatesOptions);
}
protected function isEnabled(): bool

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