mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 12:13:13 +08:00
Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d257dd42 | ||
|
|
613c7b7368 | ||
|
|
232f6e37c6 | ||
|
|
c818d5603d | ||
|
|
ff77d8b149 | ||
|
|
95be5a93fc | ||
|
|
20c41690da | ||
|
|
22b5fa5a83 | ||
|
|
0c4d1b6d2f | ||
|
|
4520ba50bf | ||
|
|
d2514b7555 | ||
|
|
2d5734fc8b | ||
|
|
478ac344ff | ||
|
|
e40b82618a | ||
|
|
51dd671174 | ||
|
|
5b5d0aae49 | ||
|
|
56df880a93 | ||
|
|
afa509613a | ||
|
|
3be49a25a0 | ||
|
|
8b259b364d | ||
|
|
13d9b7b0a7 | ||
|
|
2b33095392 | ||
|
|
3a1ce40a49 | ||
|
|
a68300f19a | ||
|
|
3318987d63 | ||
|
|
1f825797f6 | ||
|
|
650fafb7c4 | ||
|
|
978e24d6fa | ||
|
|
c3d3cc6288 | ||
|
|
223901324f | ||
|
|
47293be85c | ||
|
|
18c4c39fee | ||
|
|
e762d28b67 | ||
|
|
f5c6bc8204 | ||
|
|
3369afe22c | ||
|
|
1d96cc0279 | ||
|
|
cd4fcc9b0a | ||
|
|
834bc4ae20 | ||
|
|
92d7a44cee | ||
|
|
c8e3b3df0a | ||
|
|
77244b52c9 | ||
|
|
9e93e34e12 | ||
|
|
733b2e5647 | ||
|
|
26fef87f3b | ||
|
|
f4aaf02d55 | ||
|
|
314a99862d | ||
|
|
240d9df177 | ||
|
|
fb995f2bea | ||
|
|
436be1985c | ||
|
|
850e8574e9 | ||
|
|
c2743cb488 | ||
|
|
f1157aa177 | ||
|
|
497429e685 | ||
|
|
2cad5dd435 | ||
|
|
f38f1ae5da | ||
|
|
9c1db35d81 | ||
|
|
11b8943919 | ||
|
|
27d24a4f15 | ||
|
|
b2dbc4cf52 | ||
|
|
1a7a745f2e | ||
|
|
99bc1a21dd | ||
|
|
cea8a982e2 | ||
|
|
8bd1c6a79a | ||
|
|
71a3b993b1 | ||
|
|
6e25e3c31d | ||
|
|
b15e832cf4 | ||
|
|
851929ebef | ||
|
|
87d5f9bc75 | ||
|
|
c2649395f8 | ||
|
|
b7d9ba8258 | ||
|
|
6526cf8c44 | ||
|
|
a85afb2bee | ||
|
|
8b4067efbe | ||
|
|
c7c2272fab | ||
|
|
bc77750713 | ||
|
|
1ceb38f50b | ||
|
|
d273b56144 | ||
|
|
5cd7305666 | ||
|
|
3040a22c02 | ||
|
|
6991138812 | ||
|
|
5eb1808217 | ||
|
|
5eb14c5315 | ||
|
|
a18360a4d6 | ||
|
|
104b1e7d04 | ||
|
|
af2d67695b | ||
|
|
449a588796 | ||
|
|
7bbc938743 | ||
|
|
766758ff9b | ||
|
|
bee9f2a9cc | ||
|
|
63d943d59d | ||
|
|
053e1f3073 | ||
|
|
f3da345bf3 | ||
|
|
745255736a | ||
|
|
8fd53afe3f | ||
|
|
259635ea2a | ||
|
|
a1f2e6dc5c | ||
|
|
81e07bf08d | ||
|
|
c650a3e665 | ||
|
|
65c01034ff | ||
|
|
48f910aaaa | ||
|
|
e511e15a87 | ||
|
|
888dc84d3f | ||
|
|
ed09bf90eb | ||
|
|
0ddfcb75dd | ||
|
|
193be55f0c | ||
|
|
3ba7ad3839 | ||
|
|
7ffb64eee1 | ||
|
|
0a2cc554c6 | ||
|
|
7c2b918d5d | ||
|
|
af783dea57 | ||
|
|
a68a17f6b4 | ||
|
|
e9fe1ac5d4 | ||
|
|
88e97f18ad | ||
|
|
3372a2a9c8 | ||
|
|
f02a8c876c | ||
|
|
1549509eb8 | ||
|
|
62fde5a8e2 | ||
|
|
221e061ea6 | ||
|
|
9ad565f8c8 | ||
|
|
0a67f71b94 | ||
|
|
11fa28e489 | ||
|
|
d7e51b388e | ||
|
|
5ef2df3d53 | ||
|
|
9c251b3646 | ||
|
|
2807b9ce2f | ||
|
|
2f39aff2fe | ||
|
|
b8d7917691 | ||
|
|
d228c16f82 | ||
|
|
c34bfac6b1 | ||
|
|
4e7d09035a | ||
|
|
83570f5c25 | ||
|
|
6ad8b03850 | ||
|
|
736e09adfe | ||
|
|
e80af78e09 | ||
|
|
d533adf7ce | ||
|
|
509ef668e6 | ||
|
|
e715a0fb6f | ||
|
|
72a962ec6d | ||
|
|
853c50a819 | ||
|
|
f10a9d3972 | ||
|
|
a77e07f906 | ||
|
|
d4d97c3182 | ||
|
|
55724dbff6 | ||
|
|
9e34183901 | ||
|
|
88c283952c | ||
|
|
2ede615da8 | ||
|
|
84d12f6811 | ||
|
|
4f3c2c7d2d | ||
|
|
b8ac9f3673 | ||
|
|
06c0a94b31 | ||
|
|
5d12b1d952 | ||
|
|
85c4c09afa | ||
|
|
e7c83d0b38 | ||
|
|
58de998596 | ||
|
|
bfaab6c494 | ||
|
|
d83081f4e9 | ||
|
|
c65349d265 | ||
|
|
e74ee793a0 | ||
|
|
ede58efe96 | ||
|
|
3f30af4794 | ||
|
|
6331fa3ed3 | ||
|
|
d121d4d496 | ||
|
|
8499087a3b | ||
|
|
bb72c96ebb | ||
|
|
8d4f2bbd12 | ||
|
|
557c74286b | ||
|
|
67abe21716 | ||
|
|
33cea36b15 | ||
|
|
4e8f3f737a | ||
|
|
35b835ec7b | ||
|
|
eff4f1fca3 | ||
|
|
6f6388b2fc |
49
.github/DISCUSSION_TEMPLATE/help-wanted.yml
vendored
49
.github/DISCUSSION_TEMPLATE/help-wanted.yml
vendored
@@ -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. -->'
|
||||
|
||||
7
.github/ISSUE_TEMPLATE.md
vendored
7
.github/ISSUE_TEMPLATE.md
vendored
@@ -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.
|
||||
-->
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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
|
||||
|
||||
2
.github/actions/ci-setup/action.yml
vendored
2
.github/actions/ci-setup/action.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/ci-db-tests.yml
vendored
3
.github/workflows/ci-db-tests.yml
vendored
@@ -13,7 +13,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
php-version: ['8.3', '8.4', '8.5']
|
||||
continue-on-error: ${{ matrix.php-version == '8.5' }}
|
||||
env:
|
||||
LC_ALL: C
|
||||
steps:
|
||||
|
||||
2
.github/workflows/ci-docker-image-build.yml
vendored
2
.github/workflows/ci-docker-image-build.yml
vendored
@@ -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'
|
||||
|
||||
3
.github/workflows/ci-tests.yml
vendored
3
.github/workflows/ci-tests.yml
vendored
@@ -13,7 +13,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
php-version: ['8.3', '8.4', '8.5']
|
||||
continue-on-error: ${{ matrix.php-version == '8.5' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3']
|
||||
command: ['cs', 'stan', 'swagger:validate']
|
||||
command: ['cs', 'stan', 'openapi:validate']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: './.github/actions/ci-setup'
|
||||
@@ -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-*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish swagger spec
|
||||
name: Publish openapi spec
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2']
|
||||
php-version: ['8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Determine version
|
||||
@@ -20,10 +20,10 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
- run: composer swagger:inline
|
||||
extensions-cache-key: publish-openapi-spec-extensions-${{ matrix.php-version }}
|
||||
- run: composer openapi:inline
|
||||
- run: mkdir ${{ steps.determine_version.outputs.version }}
|
||||
- run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
|
||||
- run: mv docs/swagger/openapi-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
|
||||
- name: Publish spec
|
||||
uses: JamesIves/github-pages-deploy-action@v4
|
||||
with:
|
||||
4
.github/workflows/publish-release.yml
vendored
4
.github/workflows/publish-release.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
php-version: ['8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: './.github/actions/ci-setup'
|
||||
@@ -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-*
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,7 +10,6 @@ data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
data/GeoLite2-City.*
|
||||
data/infra/matomo
|
||||
docs/swagger-ui*
|
||||
docs/mercure.html
|
||||
.phpunit.result.cache
|
||||
docs/swagger/swagger-inlined.json
|
||||
docs/swagger/openapi-inlined.json
|
||||
|
||||
244
CHANGELOG.md
244
CHANGELOG.md
@@ -4,6 +4,250 @@ 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.5.2] - 2025-08-27
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2433](https://github.com/shlinkio/shlink/issues/2433) Try to mitigate memory leaks allowing RoadRunner to garbage collect memory after every request and every job, by setting `GC_COLLECT_CYCLES=true`.
|
||||
|
||||
|
||||
## [4.5.1] - 2025-08-24
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2433](https://github.com/shlinkio/shlink/issues/2433) Try to mitigate memory leaks by restarting job and http workers every 250 executions when using RoadRunner.
|
||||
|
||||
|
||||
## [4.5.0] - 2025-07-24
|
||||
### Added
|
||||
* [#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:
|
||||
|
||||
* `default`: Short URLs only match if the path matches their short code or custom slug.
|
||||
* `append`: Short URLs are matched as soon as the path starts with the short code or custom slug, and the extra path is appended to the long URL before redirecting.
|
||||
* `ignore`: Short URLs are matched as soon as the path starts with the short code or custom slug, and the extra path is ignored.
|
||||
|
||||
This option effectively replaces the old `REDIRECT_APPEND_EXTRA_PATH` option, which is now deprecated and will be removed in Shlink 5.0.0
|
||||
|
||||
* [#2156](https://github.com/shlinkio/shlink/issues/2156) Be less restrictive on what characters are disallowed in custom slugs.
|
||||
|
||||
All [URI-reserved characters](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) were disallowed up until now, but from now on, only the gen-delimiters are.
|
||||
|
||||
* [#2229](https://github.com/shlinkio/shlink/issues/2229) Add `logo=disabled` query param to dynamically disable the default logo on QR codes.
|
||||
* [#2206](https://github.com/shlinkio/shlink/issues/2206) Add new `DB_USE_ENCRYPTION` config option to enable SSL database connections trusting any server certificate.
|
||||
* [#2209](https://github.com/shlinkio/shlink/issues/2209) Redirect rules are now imported when importing short URLs from a Shlink >=4.0 instance.
|
||||
|
||||
### Changed
|
||||
* [#2281](https://github.com/shlinkio/shlink/issues/2281) Update docker image to PHP 8.4
|
||||
* [#2124](https://github.com/shlinkio/shlink/issues/2124) Improve how Shlink decides if a GeoLite db file needs to be downloaded, and reduces the chances for API limits to be reached.
|
||||
|
||||
Now Shlink tracks all download attempts, and knows which of them failed and succeeded. This lets it know when was the last error or success, how many consecutive errors have happened, etc.
|
||||
|
||||
It also tracks now the reason for a download to be attempted, and the error that happened when one fails.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#2247](https://github.com/shlinkio/shlink/issues/2247) Drop support for PHP 8.2
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [4.3.1] - 2024-11-25
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2285](https://github.com/shlinkio/shlink/issues/2285) Fix performance degradation when using Microsoft SQL due to incorrect order of columns in `unique_short_code_plus_domain` index.
|
||||
|
||||
|
||||
## [4.3.0] - 2024-11-24
|
||||
### Added
|
||||
* [#2159](https://github.com/shlinkio/shlink/issues/2159) Add support for PHP 8.4.
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -1,21 +1,22 @@
|
||||
FROM php:8.3-alpine3.20 AS base
|
||||
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 && \
|
||||
@@ -36,7 +37,7 @@ RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
|
||||
apk del .phpize-deps
|
||||
|
||||
# Install shlink
|
||||
FROM base as builder
|
||||
FROM base AS builder
|
||||
COPY . .
|
||||
COPY --from=composer:2 /usr/bin/composer ./composer.phar
|
||||
RUN apk add --no-cache git && \
|
||||
|
||||
@@ -36,7 +36,7 @@ The idea is that you can just generate a container using the image and provide t
|
||||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 8.2 or 8.3
|
||||
* PHP 8.3 or 8.4
|
||||
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
|
||||
* apcu extension is recommended if you don't plan to use RoadRunner.
|
||||
* xml extension is required if you want to generate QR codes in svg format.
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Mezzio\Application;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
|
||||
use Shlinkio\Shlink\EventDispatcher\RoadRunner\RoadRunnerTaskConsumerToListener;
|
||||
use Spiral\RoadRunner\Http\PSR7Worker;
|
||||
|
||||
use function gc_collect_cycles;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
(static function (): void {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/../config/container.php';
|
||||
$rrMode = env('RR_MODE');
|
||||
$gcCollectCycles = env('GC_COLLECT_CYCLES', default: false);
|
||||
|
||||
if ($rrMode === 'http') {
|
||||
// This was spin-up as a web worker
|
||||
@@ -25,6 +29,10 @@ use function Shlinkio\Shlink\Config\env;
|
||||
$worker->respond($app->handle($req));
|
||||
} catch (Throwable $e) {
|
||||
$worker->getWorker()->error((string) $e);
|
||||
} finally {
|
||||
if ($gcCollectCycles) {
|
||||
gc_collect_cycles();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
102
composer.json
102
composer.json
@@ -12,68 +12,68 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.3",
|
||||
"ext-curl": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-pdo": "*",
|
||||
"acelaya/crawler-detect": "^1.3",
|
||||
"acelaya/ip-address-middleware": "^2.4",
|
||||
"akrabat/ip-address-middleware": "^2.6",
|
||||
"cakephp/chronos": "^3.1",
|
||||
"doctrine/dbal": "^4.2",
|
||||
"doctrine/migrations": "^3.8",
|
||||
"doctrine/orm": "^3.3",
|
||||
"endroid/qr-code": "^6.0",
|
||||
"donatj/phpuseragentparser": "^1.10",
|
||||
"endroid/qr-code": "^6.0.5",
|
||||
"friendsofphp/proxy-manager-lts": "^1.0",
|
||||
"geoip2/geoip2": "^3.0",
|
||||
"geoip2/geoip2": "^3.1",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"hidehalo/nanoid-php": "^2.0",
|
||||
"laminas/laminas-config-aggregator": "^1.15",
|
||||
"jaybizzle/crawler-detect": "^1.3",
|
||||
"laminas/laminas-config-aggregator": "^1.17",
|
||||
"laminas/laminas-diactoros": "^3.5",
|
||||
"laminas/laminas-inputfilter": "^2.30",
|
||||
"laminas/laminas-servicemanager": "^3.22",
|
||||
"laminas/laminas-stdlib": "^3.19",
|
||||
"laminas/laminas-inputfilter": "^2.31",
|
||||
"laminas/laminas-servicemanager": "^3.23",
|
||||
"laminas/laminas-stdlib": "^3.20",
|
||||
"matomo/matomo-php-tracker": "^3.3",
|
||||
"mezzio/mezzio": "^3.20",
|
||||
"mezzio/mezzio-fastroute": "^3.12",
|
||||
"mezzio/mezzio-problem-details": "^1.15",
|
||||
"mlocati/ip-lib": "^1.18.1",
|
||||
"mobiledetect/mobiledetectlib": "4.8.x-dev#920c549 as 4.9",
|
||||
"pagerfanta/core": "^3.8",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/doctrine-specification": "^2.1.1",
|
||||
"shlinkio/shlink-common": "^6.6",
|
||||
"shlinkio/shlink-config": "^3.4",
|
||||
"shlinkio/shlink-event-dispatcher": "^4.1",
|
||||
"shlinkio/shlink-importer": "^5.3.2",
|
||||
"shlinkio/shlink-installer": "^9.3",
|
||||
"shlinkio/shlink-ip-geolocation": "^4.2",
|
||||
"shlinkio/shlink-json": "^1.1",
|
||||
"spiral/roadrunner": "^2024.1",
|
||||
"spiral/roadrunner-cli": "^2.6",
|
||||
"shlinkio/doctrine-specification": "^2.2",
|
||||
"shlinkio/shlink-common": "^7.1",
|
||||
"shlinkio/shlink-config": "^4.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^4.3",
|
||||
"shlinkio/shlink-importer": "^5.6",
|
||||
"shlinkio/shlink-installer": "^9.6",
|
||||
"shlinkio/shlink-ip-geolocation": "^4.3",
|
||||
"shlinkio/shlink-json": "^1.2",
|
||||
"spiral/roadrunner": "^2025.1",
|
||||
"spiral/roadrunner-cli": "^2.7",
|
||||
"spiral/roadrunner-http": "^3.5",
|
||||
"spiral/roadrunner-jobs": "^4.5",
|
||||
"symfony/console": "^7.1",
|
||||
"symfony/filesystem": "^7.1",
|
||||
"symfony/lock": "^7.1",
|
||||
"symfony/process": "^7.1",
|
||||
"symfony/string": "^7.1"
|
||||
"spiral/roadrunner-jobs": "^4.6",
|
||||
"symfony/console": "^7.3",
|
||||
"symfony/filesystem": "^7.3",
|
||||
"symfony/lock": "^7.3.2",
|
||||
"symfony/process": "^7.3",
|
||||
"symfony/string": "^7.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"devizzent/cebe-php-openapi": "^1.0.1",
|
||||
"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.4",
|
||||
"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.1",
|
||||
"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": {
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"ci": [
|
||||
"@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms",
|
||||
"@parallel cs stan openapi:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms",
|
||||
"@parallel test:api:ci test:cli:ci"
|
||||
],
|
||||
"cs": "phpcs -s",
|
||||
@@ -154,36 +154,10 @@
|
||||
"@test:cli",
|
||||
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
|
||||
],
|
||||
"swagger:validate": "php-openapi validate docs/swagger/swagger.json",
|
||||
"swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json",
|
||||
"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"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"swagger:validate\" and \"test:ci\"</>",
|
||||
"cs": "<fg=blue;options=bold>Checks coding styles</>",
|
||||
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
|
||||
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
|
||||
"test": "<fg=blue;options=bold>Runs all test suites</>",
|
||||
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
|
||||
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
|
||||
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
|
||||
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
|
||||
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
|
||||
"test:db:sqlite:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs</>",
|
||||
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
|
||||
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
|
||||
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
|
||||
"test:db:ms": "<fg=blue;options=bold>Runs database test suites on a Microsoft SQL Server database</>",
|
||||
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
|
||||
"test:api:ci": "<fg=blue;options=bold>Runs API test suites, and generates code coverage for CI</>",
|
||||
"test:api:pretty": "<fg=blue;options=bold>Runs API test suites, and generates code coverage in HTML format</>",
|
||||
"test:cli": "<fg=blue;options=bold>Runs CLI test suites</>",
|
||||
"test:cli:ci": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage for CI</>",
|
||||
"test:cli:pretty": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage in HTML format</>",
|
||||
"swagger:validate": "<fg=blue;options=bold>Validates the swagger docs, making sure they fulfil the spec</>",
|
||||
"swagger:inline": "<fg=blue;options=bold>Inlines swagger docs in a single file</>",
|
||||
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"platform-check": false,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'cors' => [
|
||||
'max_age' => 3600,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -12,9 +12,10 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
|
||||
return (static function (): array {
|
||||
$driver = EnvVars::DB_DRIVER->loadFromEnv();
|
||||
$useEncryption = (bool) EnvVars::DB_USE_ENCRYPTION->loadFromEnv();
|
||||
$isMysqlCompatible = contains($driver, ['maria', 'mysql']);
|
||||
|
||||
$resolveDriver = static fn () => match ($driver) {
|
||||
$doctrineDriver = match ($driver) {
|
||||
'postgres' => 'pdo_pgsql',
|
||||
'mssql' => 'pdo_sqlsrv',
|
||||
default => 'pdo_mysql',
|
||||
@@ -23,31 +24,40 @@ return (static function (): array {
|
||||
$value = $envVar->loadFromEnv();
|
||||
return $value === null ? null : (string) $value;
|
||||
};
|
||||
$resolveCharset = static fn () => match ($driver) {
|
||||
$charset = match ($driver) {
|
||||
// This does not determine charsets or collations in tables or columns, but the charset used in the data
|
||||
// flowing in the connection, so it has to match what has been set in the database.
|
||||
'maria', 'mysql' => 'utf8mb4',
|
||||
'postgres' => 'utf8',
|
||||
default => null,
|
||||
};
|
||||
|
||||
$resolveConnection = static fn () => match ($driver) {
|
||||
$driverOptions = match ($driver) {
|
||||
'mssql' => ['TrustServerCertificate' => 'true'],
|
||||
'maria', 'mysql' => ! $useEncryption ? [] : [
|
||||
1007 => true, // PDO::MYSQL_ATTR_SSL_KEY: Require using SSL
|
||||
1014 => false, // PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT: Trust any certificate
|
||||
],
|
||||
'postgres' => ! $useEncryption ? [] : [
|
||||
'sslmode' => 'require', // Require connections to be encrypted
|
||||
'sslrootcert' => '', // Allow any certificate
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
$connection = match ($driver) {
|
||||
null, 'sqlite' => [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => 'data/database.sqlite',
|
||||
],
|
||||
default => [
|
||||
'driver' => $resolveDriver(),
|
||||
'driver' => $doctrineDriver,
|
||||
'dbname' => EnvVars::DB_NAME->loadFromEnv(),
|
||||
'user' => $readCredentialAsString(EnvVars::DB_USER),
|
||||
'password' => $readCredentialAsString(EnvVars::DB_PASSWORD),
|
||||
'host' => EnvVars::DB_HOST->loadFromEnv(),
|
||||
'port' => EnvVars::DB_PORT->loadFromEnv(),
|
||||
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
|
||||
'charset' => $resolveCharset(),
|
||||
'driverOptions' => $driver !== 'mssql' ? [] : [
|
||||
'TrustServerCertificate' => 'true',
|
||||
],
|
||||
'charset' => $charset,
|
||||
'driverOptions' => $driverOptions,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -63,7 +73,7 @@ return (static function (): array {
|
||||
Events::postFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class],
|
||||
],
|
||||
],
|
||||
'connection' => $resolveConnection(),
|
||||
'connection' => $connection,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
@@ -20,6 +21,7 @@ return [
|
||||
Option\Database\DatabaseUserConfigOption::class,
|
||||
Option\Database\DatabasePasswordConfigOption::class,
|
||||
Option\Database\DatabaseUnixSocketConfigOption::class,
|
||||
Option\Database\DatabaseUseEncryptionConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainHostConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
|
||||
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
||||
@@ -40,8 +42,9 @@ return [
|
||||
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
|
||||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheVisibilityConfigOption::class,
|
||||
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
|
||||
Option\UrlShortener\AppendExtraPathConfigOption::class,
|
||||
Option\UrlShortener\ExtraPathModeConfigOption::class,
|
||||
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
|
||||
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
|
||||
Option\UrlShortener\ShortUrlModeConfigOption::class,
|
||||
@@ -75,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' => [
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
];
|
||||
})();
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM php:8.3-fpm-alpine3.20
|
||||
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
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
display_errors=On
|
||||
error_reporting=-1
|
||||
log_errors_max_len=0
|
||||
zend.assertions=1
|
||||
assert.exception=1
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM php:8.3-alpine3.20
|
||||
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -85,6 +86,16 @@
|
||||
"type": "string",
|
||||
"default": "#ffffff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "logo",
|
||||
"in": "query",
|
||||
"description": "Currently used to disable the logo that was set via configuration options. It may be used in future to dynamically choose from multiple logos.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["disable"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\CLI;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainService;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Core\Matomo;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService;
|
||||
use Shlinkio\Shlink\Core\ShortUrl;
|
||||
@@ -17,15 +17,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2ReaderFactory;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Symfony\Component\Console as SymfonyCli;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
|
||||
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
@@ -34,7 +30,6 @@ return [
|
||||
SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class,
|
||||
PhpExecutableFinder::class => InvokableFactory::class,
|
||||
|
||||
GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
|
||||
RedirectRule\RedirectRuleHandler::class => InvokableFactory::class,
|
||||
Util\ProcessRunner::class => ConfigAbstractFactory::class,
|
||||
|
||||
@@ -82,12 +77,6 @@ return [
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
GeoLite\GeolocationDbUpdater::class => [
|
||||
DbUpdater::class,
|
||||
GeoLite2ReaderFactory::class,
|
||||
LOCAL_LOCK_FACTORY,
|
||||
TrackingOptions::class,
|
||||
],
|
||||
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
|
||||
ApiKey\RoleResolver::class => [DomainService::class, UrlShortenerOptions::class],
|
||||
|
||||
@@ -107,7 +96,7 @@ return [
|
||||
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class],
|
||||
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => [ShortUrl\DeleteShortUrlService::class],
|
||||
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => [GeolocationDbUpdater::class],
|
||||
Command\Visit\LocateVisitsCommand::class => [
|
||||
Visit\Geolocation\VisitLocator::class,
|
||||
Visit\Geolocation\VisitToLocationHelper::class,
|
||||
|
||||
@@ -4,70 +4,55 @@ 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 NAME = 'api-key:disable';
|
||||
public const string NAME = 'api-key:disable';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -23,7 +22,7 @@ use function sprintf;
|
||||
|
||||
class GenerateKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:generate';
|
||||
public const string NAME = 'api-key:generate';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiKeyServiceInterface $apiKeyService,
|
||||
@@ -123,6 +122,6 @@ class GenerateKeyCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -13,7 +12,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class InitialApiKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:initial';
|
||||
public const string NAME = 'api-key:initial';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,51 +4,45 @@ 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 ERROR_STRING_PATTERN = '<fg=red>%s</>';
|
||||
private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
|
||||
private const WARNING_STRING_PATTERN = '<comment>%s</comment>';
|
||||
private const string ERROR_STRING_PATTERN = '<fg=red>%s</>';
|
||||
private const string SUCCESS_STRING_PATTERN = '<info>%s</info>';
|
||||
private const string WARNING_STRING_PATTERN = '<comment>%s</comment>';
|
||||
|
||||
public const NAME = 'api-key:list';
|
||||
public const string NAME = 'api-key:list';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
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
|
||||
|
||||
@@ -4,42 +4,37 @@ 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 NAME = 'api-key:rename';
|
||||
public const string NAME = 'api-key:rename';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -21,7 +20,7 @@ use function sprintf;
|
||||
|
||||
class ReadEnvVarCommand extends Command
|
||||
{
|
||||
public const NAME = 'env-var:read';
|
||||
public const string NAME = 'env-var:read';
|
||||
|
||||
/** @var Closure(string $envVar): mixed */
|
||||
private readonly Closure $loadEnvVar;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -24,9 +23,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
{
|
||||
private readonly Connection $regularConn;
|
||||
|
||||
public const NAME = 'db:create';
|
||||
public const DOCTRINE_SCRIPT = 'bin/doctrine';
|
||||
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
|
||||
public const string NAME = 'db:create';
|
||||
public const string DOCTRINE_SCRIPT = 'bin/doctrine';
|
||||
public const string DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
|
||||
|
||||
public function __construct(
|
||||
LockFactory $locker,
|
||||
@@ -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
|
||||
|
||||
@@ -4,16 +4,15 @@ 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;
|
||||
|
||||
class MigrateDatabaseCommand extends AbstractDatabaseCommand
|
||||
{
|
||||
public const NAME = 'db:migrate';
|
||||
public const DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
|
||||
public const DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate';
|
||||
public const string NAME = 'db:migrate';
|
||||
public const string DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
|
||||
public const string DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -21,7 +20,7 @@ use function str_contains;
|
||||
|
||||
class DomainRedirectsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:redirects';
|
||||
public const string NAME = 'domain:redirects';
|
||||
|
||||
public function __construct(private readonly DomainServiceInterface $domainService)
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
class GetDomainVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'domain:visits';
|
||||
public const string NAME = 'domain:visits';
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
|
||||
@@ -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;
|
||||
@@ -18,7 +17,7 @@ use function array_map;
|
||||
|
||||
class ListDomainsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:list';
|
||||
public const string NAME = 'domain:list';
|
||||
|
||||
public function __construct(private readonly DomainServiceInterface $domainService)
|
||||
{
|
||||
@@ -59,7 +58,7 @@ class ListDomainsCommand extends Command
|
||||
}, $domains),
|
||||
);
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
|
||||
|
||||
@@ -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;
|
||||
@@ -22,7 +21,7 @@ use function sprintf;
|
||||
|
||||
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
|
||||
{
|
||||
public const NAME = 'integration:matomo:send-visits';
|
||||
public const string NAME = 'integration:matomo:send-visits';
|
||||
|
||||
private readonly bool $matomoEnabled;
|
||||
private SymfonyStyle $io;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -19,7 +18,7 @@ use function sprintf;
|
||||
|
||||
class ManageRedirectRulesCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:manage-rules';
|
||||
public const string NAME = 'short-url:manage-rules';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -20,7 +19,7 @@ use function sprintf;
|
||||
|
||||
class CreateShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:create';
|
||||
public const string NAME = 'short-url:create';
|
||||
|
||||
private SymfonyStyle $io;
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -17,7 +16,7 @@ use function sprintf;
|
||||
|
||||
class DeleteExpiredShortUrlsCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:delete-expired';
|
||||
public const string NAME = 'short-url:delete-expired';
|
||||
|
||||
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -19,7 +18,7 @@ use function sprintf;
|
||||
|
||||
class DeleteShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:delete';
|
||||
public const string NAME = 'short-url:delete';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -16,7 +15,7 @@ use function sprintf;
|
||||
|
||||
class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
|
||||
{
|
||||
public const NAME = 'short-url:visits-delete';
|
||||
public const string NAME = 'short-url:visits-delete';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -19,7 +18,7 @@ use function sprintf;
|
||||
|
||||
class EditShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:edit';
|
||||
public const string NAME = 'short-url:edit';
|
||||
|
||||
private readonly ShortUrlDataInput $shortUrlDataInput;
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'short-url:visits';
|
||||
public const string NAME = 'short-url:visits';
|
||||
|
||||
private ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -33,7 +32,7 @@ use function sprintf;
|
||||
|
||||
class ListShortUrlsCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:list';
|
||||
public const string NAME = 'short-url:list';
|
||||
|
||||
private readonly StartDateOption $startDateOption;
|
||||
private readonly EndDateOption $endDateOption;
|
||||
@@ -176,7 +175,7 @@ class ListShortUrlsCommand extends Command
|
||||
$io->newLine();
|
||||
$io->success('Short URLs properly listed');
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -17,7 +16,7 @@ use function sprintf;
|
||||
|
||||
class ResolveUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:parse';
|
||||
public const string NAME = 'short-url:parse';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -14,9 +13,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class DeleteTagsCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:delete';
|
||||
public const string NAME = 'tag:delete';
|
||||
|
||||
public function __construct(private TagServiceInterface $tagService)
|
||||
public function __construct(private readonly TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
class GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'tag:visits';
|
||||
public const string NAME = 'tag:visits';
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
|
||||
@@ -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;
|
||||
@@ -17,7 +16,7 @@ use function array_map;
|
||||
|
||||
class ListTagsCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:list';
|
||||
public const string NAME = 'tag:list';
|
||||
|
||||
public function __construct(private readonly TagServiceInterface $tagService)
|
||||
{
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -17,7 +16,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class RenameTagCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:rename';
|
||||
public const string NAME = 'tag:rename';
|
||||
|
||||
public function __construct(private readonly TagServiceInterface $tagService)
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Util;
|
||||
|
||||
final class LockedCommandConfig
|
||||
{
|
||||
public const DEFAULT_TTL = 600.0; // 10 minutes
|
||||
public const float DEFAULT_TTL = 600.0; // 10 minutes
|
||||
|
||||
private function __construct(
|
||||
public readonly string $lockName,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -13,7 +12,7 @@ use function sprintf;
|
||||
|
||||
class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
|
||||
{
|
||||
public const NAME = 'visit:orphan-delete';
|
||||
public const string NAME = 'visit:orphan-delete';
|
||||
|
||||
public function __construct(private readonly VisitsDeleterInterface $deleter)
|
||||
{
|
||||
@@ -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
|
||||
|
||||
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
|
||||
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 Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -16,13 +16,14 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DownloadGeoLiteDbCommand extends Command
|
||||
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
|
||||
{
|
||||
public const NAME = 'visit:download-db';
|
||||
public const string NAME = 'visit:download-db';
|
||||
|
||||
private ProgressBar|null $progressBar = null;
|
||||
private SymfonyStyle $io;
|
||||
|
||||
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
|
||||
public function __construct(private readonly GeolocationDbUpdaterInterface $dbUpdater)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -39,38 +40,42 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$result = $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void {
|
||||
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
$this->progressBar = new ProgressBar($io);
|
||||
}, function (int $total, int $downloaded): void {
|
||||
$this->progressBar?->setMaxSteps($total);
|
||||
$this->progressBar?->setProgress($downloaded);
|
||||
});
|
||||
$result = $this->dbUpdater->checkDbUpdate($this);
|
||||
|
||||
if ($result === GeolocationResult::LICENSE_MISSING) {
|
||||
$io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
|
||||
return ExitCode::EXIT_WARNING;
|
||||
$this->io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
if ($result === GeolocationResult::MAX_ERRORS_REACHED) {
|
||||
$this->io->warning('Max consecutive errors reached. Cannot retry for a couple of days.');
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
if ($result === GeolocationResult::UPDATE_IN_PROGRESS) {
|
||||
$this->io->warning('A geolocation db is already being downloaded by another process.');
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
if ($this->progressBar === null) {
|
||||
$io->info('GeoLite2 db file is up to date.');
|
||||
$this->io->info('GeoLite2 db file is up to date.');
|
||||
} else {
|
||||
$this->progressBar->finish();
|
||||
$io->success('GeoLite2 db file properly downloaded.');
|
||||
$this->io->success('GeoLite2 db file properly downloaded.');
|
||||
}
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
return self::SUCCESS;
|
||||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
return $this->processGeoLiteUpdateError($e, $io);
|
||||
return $this->processGeoLiteUpdateError($e, $this->io);
|
||||
}
|
||||
}
|
||||
|
||||
private function processGeoLiteUpdateError(GeolocationDbUpdateFailedException $e, SymfonyStyle $io): int
|
||||
{
|
||||
$olderDbExists = $e->olderDbExists();
|
||||
$olderDbExists = $e->olderDbExists;
|
||||
|
||||
if ($olderDbExists) {
|
||||
$io->warning(
|
||||
@@ -84,6 +89,18 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
$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
|
||||
{
|
||||
$this->io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
$this->progressBar = new ProgressBar($this->io);
|
||||
}
|
||||
|
||||
public function handleProgress(int $total, int $downloaded, bool $olderDbExists): void
|
||||
{
|
||||
$this->progressBar?->setMaxSteps($total);
|
||||
$this->progressBar?->setProgress($downloaded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'visit:non-orphan';
|
||||
public const string NAME = 'visit:non-orphan';
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
|
||||
@@ -17,7 +17,7 @@ use function sprintf;
|
||||
|
||||
class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'visit:orphan';
|
||||
public const string NAME = 'visit:orphan';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -29,7 +28,7 @@ use function sprintf;
|
||||
|
||||
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
|
||||
{
|
||||
public const NAME = 'visit:locate';
|
||||
public const string NAME = 'visit:locate';
|
||||
|
||||
private SymfonyStyle $io;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
private bool $olderDbExists;
|
||||
|
||||
private function __construct(string $message, Throwable|null $previous = null)
|
||||
{
|
||||
parent::__construct($message, previous: $previous);
|
||||
}
|
||||
|
||||
public static function withOlderDb(Throwable|null $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
'An error occurred while updating geolocation database, but an older DB is already present.',
|
||||
$prev,
|
||||
);
|
||||
$e->olderDbExists = true;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
public static function withoutOlderDb(Throwable|null $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found.',
|
||||
$prev,
|
||||
);
|
||||
$e->olderDbExists = false;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
public static function withInvalidEpochInOldDb(mixed $buildEpoch): self
|
||||
{
|
||||
$e = new self(sprintf(
|
||||
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
|
||||
$buildEpoch,
|
||||
));
|
||||
$e->olderDbExists = true;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
public function olderDbExists(): bool
|
||||
{
|
||||
return $this->olderDbExists;
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\GeoLite;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Closure;
|
||||
use GeoIp2\Database\Reader;
|
||||
use MaxMind\Db\Reader\Metadata;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
|
||||
use function is_int;
|
||||
|
||||
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
{
|
||||
private const LOCK_NAME = 'geolocation-db-update';
|
||||
|
||||
/** @var Closure(): Reader */
|
||||
private readonly Closure $geoLiteDbReaderFactory;
|
||||
|
||||
/**
|
||||
* @param callable(): Reader $geoLiteDbReaderFactory
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly DbUpdaterInterface $dbUpdater,
|
||||
callable $geoLiteDbReaderFactory,
|
||||
private readonly LockFactory $locker,
|
||||
private readonly TrackingOptions $trackingOptions,
|
||||
) {
|
||||
$this->geoLiteDbReaderFactory = $geoLiteDbReaderFactory(...);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
public function checkDbUpdate(
|
||||
callable|null $beforeDownload = null,
|
||||
callable|null $handleProgress = null,
|
||||
): GeolocationResult {
|
||||
if (! $this->trackingOptions->isGeolocationRelevant()) {
|
||||
return GeolocationResult::CHECK_SKIPPED;
|
||||
}
|
||||
|
||||
$lock = $this->locker->createLock(self::LOCK_NAME);
|
||||
$lock->acquire(true); // Block until lock is released
|
||||
|
||||
try {
|
||||
return $this->downloadIfNeeded($beforeDownload, $handleProgress);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadIfNeeded(callable|null $beforeDownload, callable|null $handleProgress): GeolocationResult
|
||||
{
|
||||
if (! $this->dbUpdater->databaseFileExists()) {
|
||||
return $this->downloadNewDb(false, $beforeDownload, $handleProgress);
|
||||
}
|
||||
|
||||
$meta = ($this->geoLiteDbReaderFactory)()->metadata();
|
||||
if ($this->buildIsTooOld($meta)) {
|
||||
return $this->downloadNewDb(true, $beforeDownload, $handleProgress);
|
||||
}
|
||||
|
||||
return GeolocationResult::DB_IS_UP_TO_DATE;
|
||||
}
|
||||
|
||||
private function buildIsTooOld(Metadata $meta): bool
|
||||
{
|
||||
$buildTimestamp = $this->resolveBuildTimestamp($meta);
|
||||
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
|
||||
|
||||
return Chronos::now()->greaterThan($buildDate->addDays(35));
|
||||
}
|
||||
|
||||
private function resolveBuildTimestamp(Metadata $meta): int
|
||||
{
|
||||
// In theory the buildEpoch should be an int, but it has been reported to come as a string.
|
||||
// See https://github.com/shlinkio/shlink/issues/1002 for context
|
||||
|
||||
/** @var int|string $buildEpoch */
|
||||
$buildEpoch = $meta->buildEpoch;
|
||||
if (is_int($buildEpoch)) {
|
||||
return $buildEpoch;
|
||||
}
|
||||
|
||||
$intBuildEpoch = (int) $buildEpoch;
|
||||
if ($buildEpoch === (string) $intBuildEpoch) {
|
||||
return $intBuildEpoch;
|
||||
}
|
||||
|
||||
throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadNewDb(
|
||||
bool $olderDbExists,
|
||||
callable|null $beforeDownload,
|
||||
callable|null $handleProgress,
|
||||
): GeolocationResult {
|
||||
if ($beforeDownload !== null) {
|
||||
$beforeDownload($olderDbExists);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists));
|
||||
return $olderDbExists ? GeolocationResult::DB_UPDATED : GeolocationResult::DB_CREATED;
|
||||
} catch (MissingLicenseException) {
|
||||
return GeolocationResult::LICENSE_MISSING;
|
||||
} catch (DbUpdateException | WrongIpException $e) {
|
||||
throw $olderDbExists
|
||||
? GeolocationDbUpdateFailedException::withOlderDb($e)
|
||||
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function wrapHandleProgressCallback(callable|null $handleProgress, bool $olderDbExists): callable|null
|
||||
{
|
||||
if ($handleProgress === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\GeoLite;
|
||||
|
||||
enum GeolocationResult
|
||||
{
|
||||
case CHECK_SKIPPED;
|
||||
case LICENSE_MISSING;
|
||||
case DB_CREATED;
|
||||
case DB_UPDATED;
|
||||
case DB_IS_UP_TO_DATE;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Util;
|
||||
|
||||
final class ExitCode
|
||||
{
|
||||
public const EXIT_SUCCESS = 0;
|
||||
public const EXIT_FAILURE = -1;
|
||||
public const EXIT_WARNING = 1;
|
||||
}
|
||||
@@ -12,8 +12,8 @@ use function array_pop;
|
||||
|
||||
final class ShlinkTable
|
||||
{
|
||||
private const DEFAULT_STYLE_NAME = 'default';
|
||||
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
|
||||
private const string DEFAULT_STYLE_NAME = 'default';
|
||||
private const string TABLE_TITLE_STYLE = '<options=bold> %s </>';
|
||||
|
||||
private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators = false)
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -74,7 +74,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode);
|
||||
$this->service->expects($this->exactly($expectedDeleteCalls))->method('deleteByShortCode')->with(
|
||||
$identifier,
|
||||
$this->isType('bool'),
|
||||
$this->isBool(),
|
||||
)->willReturnCallback(function ($_, bool $ignoreThreshold) use ($shortCode): void {
|
||||
if (!$ignoreThreshold) {
|
||||
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
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\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
|
||||
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;
|
||||
@@ -36,9 +38,9 @@ class DownloadGeoLiteDbCommandTest extends TestCase
|
||||
int $expectedExitCode,
|
||||
): void {
|
||||
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback(
|
||||
function (callable $beforeDownload, callable $handleProgress) use ($olderDbExists): void {
|
||||
$beforeDownload($olderDbExists);
|
||||
$handleProgress(100, 50);
|
||||
function (GeolocationDownloadProgressHandlerInterface $handler) use ($olderDbExists): void {
|
||||
$handler->beforeDownload($olderDbExists);
|
||||
$handler->handleProgress(100, 50, $olderDbExists);
|
||||
|
||||
throw $olderDbExists
|
||||
? GeolocationDbUpdateFailedException::withOlderDb()
|
||||
@@ -63,28 +65,29 @@ 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,
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningIsPrintedWhenLicenseIsMissing(): void
|
||||
#[TestWith([GeolocationResult::LICENSE_MISSING, 'It was not possible to download GeoLite2 db'])]
|
||||
#[TestWith([GeolocationResult::MAX_ERRORS_REACHED, 'Max consecutive errors reached'])]
|
||||
#[TestWith([GeolocationResult::UPDATE_IN_PROGRESS, 'A geolocation db is already being downloaded'])]
|
||||
public function warningIsPrintedForSomeResults(GeolocationResult $result, string $expectedWarningMessage): void
|
||||
{
|
||||
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturn(
|
||||
GeolocationResult::LICENSE_MISSING,
|
||||
);
|
||||
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturn($result);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString('[WARNING] It was not possible to download GeoLite2 db', $output);
|
||||
self::assertSame(ExitCode::EXIT_WARNING, $exitCode);
|
||||
self::assertStringContainsString('[WARNING] ' . $expectedWarningMessage, $output);
|
||||
self::assertSame(Command::INVALID, $exitCode);
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideSuccessParams')]
|
||||
@@ -99,14 +102,14 @@ 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
|
||||
{
|
||||
yield 'up to date db' => [fn () => GeolocationResult::CHECK_SKIPPED, '[INFO] GeoLite2 db file is up to date.'];
|
||||
yield 'outdated db' => [function (callable $beforeDownload): GeolocationResult {
|
||||
$beforeDownload(true);
|
||||
yield 'outdated db' => [function (GeolocationDownloadProgressHandlerInterface $handler): GeolocationResult {
|
||||
$handler->beforeDownload(true);
|
||||
return GeolocationResult::DB_CREATED;
|
||||
}, '[OK] GeoLite2 db file properly downloaded.'];
|
||||
}
|
||||
|
||||
@@ -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->isType('string'), 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');
|
||||
|
||||
@@ -9,7 +9,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
|
||||
use Throwable;
|
||||
|
||||
class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
@@ -19,7 +19,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withOlderDb($prev);
|
||||
|
||||
self::assertTrue($e->olderDbExists());
|
||||
self::assertTrue($e->olderDbExists);
|
||||
self::assertEquals(
|
||||
'An error occurred while updating geolocation database, but an older DB is already present.',
|
||||
$e->getMessage(),
|
||||
@@ -33,7 +33,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withoutOlderDb($prev);
|
||||
|
||||
self::assertFalse($e->olderDbExists());
|
||||
self::assertFalse($e->olderDbExists);
|
||||
self::assertEquals(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found.',
|
||||
$e->getMessage(),
|
||||
@@ -48,16 +48,4 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
yield 'RuntimeException' => [new RuntimeException('prev')];
|
||||
yield 'Exception' => [new Exception('prev')];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function withInvalidEpochInOldDbBuildsException(): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withInvalidEpochInOldDb('foobar');
|
||||
|
||||
self::assertTrue($e->olderDbExists());
|
||||
self::assertEquals(
|
||||
'Build epoch with value "foobar" from existing geolocation database, could not be parsed to integer.',
|
||||
$e->getMessage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\GeoLite;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use GeoIp2\Database\Reader;
|
||||
use MaxMind\Db\Reader\Metadata;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
|
||||
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock;
|
||||
use Throwable;
|
||||
|
||||
use function array_map;
|
||||
use function range;
|
||||
|
||||
class GeolocationDbUpdaterTest extends TestCase
|
||||
{
|
||||
private MockObject & DbUpdaterInterface $dbUpdater;
|
||||
private MockObject & Reader $geoLiteDbReader;
|
||||
private MockObject & Lock\LockInterface $lock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dbUpdater = $this->createMock(DbUpdaterInterface::class);
|
||||
$this->geoLiteDbReader = $this->createMock(Reader::class);
|
||||
$this->lock = $this->createMock(Lock\SharedLockInterface::class);
|
||||
$this->lock->method('acquire')->with($this->isTrue())->willReturn(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function properResultIsReturnedWhenLicenseIsMissing(): void
|
||||
{
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
|
||||
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->willThrowException(
|
||||
new MissingLicenseException(''),
|
||||
);
|
||||
$this->geoLiteDbReader->expects($this->never())->method('metadata');
|
||||
|
||||
$isCalled = false;
|
||||
$result = $this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void {
|
||||
$isCalled = true;
|
||||
});
|
||||
|
||||
self::assertTrue($isCalled);
|
||||
self::assertEquals(GeolocationResult::LICENSE_MISSING, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
|
||||
{
|
||||
$prev = new DbUpdateException('');
|
||||
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
|
||||
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->with(
|
||||
$this->isNull(),
|
||||
)->willThrowException($prev);
|
||||
$this->geoLiteDbReader->expects($this->never())->method('metadata');
|
||||
|
||||
$isCalled = false;
|
||||
try {
|
||||
$this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void {
|
||||
$isCalled = true;
|
||||
});
|
||||
self::fail();
|
||||
} catch (Throwable $e) {
|
||||
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
self::assertSame($prev, $e->getPrevious());
|
||||
self::assertFalse($e->olderDbExists());
|
||||
self::assertTrue($isCalled);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideBigDays')]
|
||||
public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
|
||||
{
|
||||
$prev = new DbUpdateException('');
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
|
||||
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->with(
|
||||
$this->isNull(),
|
||||
)->willThrowException($prev);
|
||||
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
|
||||
$this->buildMetaWithBuildEpoch(Chronos::now()->subDays($days)->getTimestamp()),
|
||||
);
|
||||
|
||||
try {
|
||||
$this->geolocationDbUpdater()->checkDbUpdate();
|
||||
self::fail();
|
||||
} catch (Throwable $e) {
|
||||
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
self::assertSame($prev, $e->getPrevious());
|
||||
self::assertTrue($e->olderDbExists());
|
||||
}
|
||||
}
|
||||
|
||||
public static function provideBigDays(): iterable
|
||||
{
|
||||
yield [36];
|
||||
yield [50];
|
||||
yield [75];
|
||||
yield [100];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideSmallDays')]
|
||||
public function databaseIsNotUpdatedIfItIsNewEnough(string|int $buildEpoch): void
|
||||
{
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
|
||||
$this->dbUpdater->expects($this->never())->method('downloadFreshCopy');
|
||||
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
|
||||
$this->buildMetaWithBuildEpoch($buildEpoch),
|
||||
);
|
||||
|
||||
$result = $this->geolocationDbUpdater()->checkDbUpdate();
|
||||
|
||||
self::assertEquals(GeolocationResult::DB_IS_UP_TO_DATE, $result);
|
||||
}
|
||||
|
||||
public static function provideSmallDays(): iterable
|
||||
{
|
||||
$generateParamsWithTimestamp = static function (int $days) {
|
||||
$timestamp = Chronos::now()->subDays($days)->getTimestamp();
|
||||
return [$days % 2 === 0 ? $timestamp : (string) $timestamp];
|
||||
};
|
||||
|
||||
return array_map($generateParamsWithTimestamp, range(0, 34));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void
|
||||
{
|
||||
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
|
||||
$this->dbUpdater->expects($this->never())->method('downloadFreshCopy');
|
||||
$this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn(
|
||||
$this->buildMetaWithBuildEpoch('invalid'),
|
||||
);
|
||||
|
||||
$this->expectException(GeolocationDbUpdateFailedException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Build epoch with value "invalid" from existing geolocation database, could not be parsed to integer.',
|
||||
);
|
||||
|
||||
$this->geolocationDbUpdater()->checkDbUpdate();
|
||||
}
|
||||
|
||||
private function buildMetaWithBuildEpoch(string|int $buildEpoch): Metadata
|
||||
{
|
||||
return new Metadata([
|
||||
'binary_format_major_version' => '',
|
||||
'binary_format_minor_version' => '',
|
||||
'build_epoch' => $buildEpoch,
|
||||
'database_type' => '',
|
||||
'languages' => '',
|
||||
'description' => '',
|
||||
'ip_version' => '',
|
||||
'node_count' => 1,
|
||||
'record_size' => 4,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideTrackingOptions')]
|
||||
public function downloadDbIsSkippedIfTrackingIsDisabled(TrackingOptions $options): void
|
||||
{
|
||||
$result = $this->geolocationDbUpdater($options)->checkDbUpdate();
|
||||
$this->dbUpdater->expects($this->never())->method('databaseFileExists');
|
||||
$this->geoLiteDbReader->expects($this->never())->method('metadata');
|
||||
|
||||
self::assertEquals(GeolocationResult::CHECK_SKIPPED, $result);
|
||||
}
|
||||
|
||||
public static function provideTrackingOptions(): iterable
|
||||
{
|
||||
yield 'disableTracking' => [new TrackingOptions(disableTracking: true)];
|
||||
yield 'disableIpTracking' => [new TrackingOptions(disableIpTracking: true)];
|
||||
yield 'both' => [new TrackingOptions(disableTracking: true, disableIpTracking: true)];
|
||||
}
|
||||
|
||||
private function geolocationDbUpdater(TrackingOptions|null $options = null): GeolocationDbUpdater
|
||||
{
|
||||
$locker = $this->createMock(Lock\LockFactory::class);
|
||||
$locker->method('createLock')->with($this->isType('string'))->willReturn($this->lock);
|
||||
|
||||
return new GeolocationDbUpdater(
|
||||
$this->dbUpdater,
|
||||
fn () => $this->geoLiteDbReader,
|
||||
$locker,
|
||||
$options ?? new TrackingOptions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,14 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
#[Test, DataProvider('provideExitActions')]
|
||||
public function rulesAreDisplayedWhenRulesListIsEmpty(
|
||||
RedirectRuleHandlerAction $action,
|
||||
array|null $_,
|
||||
): void {
|
||||
$comment = fn (string $value) => sprintf('<comment>%s</comment>', $value);
|
||||
|
||||
$this->io->expects($this->once())->method('choice')->willReturn($action->value);
|
||||
$this->io->expects($this->never())->method('newLine');
|
||||
$this->io->expects($this->never())->method('text');
|
||||
$this->io->expects($this->once())->method('table')->with($this->isType('array'), [
|
||||
$this->io->expects($this->once())->method('table')->with($this->isArray(), [
|
||||
['1', $comment($this->cond1->toHumanFriendly()), 'https://example.com/one'],
|
||||
[
|
||||
'2',
|
||||
@@ -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')],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user