Compare commits

..

116 Commits

Author SHA1 Message Date
Alejandro Celaya
02e48ae665 Merge pull request #2237 from shlinkio/develop
Release 4.2.4
2024-10-27 08:48:05 +01:00
Alejandro Celaya
0d627ce808 Set user to 0 in database containers when running in CI 2024-10-27 08:45:11 +01:00
Alejandro Celaya
99639b9844 Depend on actual versions for shlink packages 2024-10-27 08:36:57 +01:00
Alejandro Celaya
0c3c7ff3b2 Add v4.2.4 to changelog 2024-10-27 08:23:38 +01:00
Alejandro Celaya
d7423585ff Build docker image in CI using reusable workflow 2024-10-26 10:25:11 +02:00
Alejandro Celaya
7de07a9cd4 Merge pull request #2236 from acelaya-forks/feature/normalize-composer-json
Feature/normalize composer json
2024-10-24 14:25:01 +02:00
Alejandro Celaya
2a734b5d89 Ensure proper env vars are promoted for dev and test envs 2024-10-24 14:20:49 +02:00
Alejandro Celaya
4520afb271 Normalize composer.json scripts with composer capabilities 2024-10-24 14:08:48 +02:00
Alejandro Celaya
e7a9ad1db0 Merge pull request #2224 from acelaya-forks/feature/dev-config-as-env
Migrate dev-specific configuration to env vars via .env file
2024-10-24 12:01:13 +02:00
Alejandro Celaya
84860539ce Ensure dev env files are not accidentally leaked to locally built docker images 2024-10-24 11:58:04 +02:00
Alejandro Celaya
2901fe8b7b Reduce duplication in CLI tests 2024-10-24 11:50:06 +02:00
Alejandro Celaya
f9694333c5 Add ADR for transition to env vars for dev configs 2024-10-24 11:44:05 +02:00
Alejandro Celaya
fc1f35ad59 Update CONTRIBUTING file removing references to old local config files 2024-10-24 10:12:34 +02:00
Alejandro Celaya
9a58748581 Get LC_ALL env var back to docker compose 2024-10-24 10:00:57 +02:00
Alejandro Celaya
45e108d21e Load dev env as a PHP array instead of an env file 2024-10-24 09:59:13 +02:00
Alejandro Celaya
f4da9c1fcc Update dependencies to stop using cuyz/valinor 2024-10-24 09:22:44 +02:00
Alejandro Celaya
a3ea8f56dd Remove app_options config 2024-10-24 08:49:58 +02:00
Alejandro Celaya
f3244b35e3 Remove remaining local config files 2024-10-23 10:53:09 +02:00
Alejandro Celaya
442eea0ea7 Add script to run CLI tests that loads and exports test env vars 2024-10-23 10:16:38 +02:00
Alejandro Celaya
46601443f5 Load specific env file when running API tests 2024-10-23 09:17:00 +02:00
Alejandro Celaya
c0200317dd Load dev env vars via roadrunner instead of docker compose 2024-10-22 15:31:53 +02:00
Alejandro Celaya
c8e5196aab Remove dependencies on url_shortener raw config 2024-10-22 15:15:41 +02:00
Alejandro Celaya
b991b1699e Define unique dev .env file 2024-10-22 15:15:41 +02:00
Alejandro Celaya
582033ceb3 Migrate dev-specific configuration to env vars via .env file 2024-10-22 15:15:41 +02:00
Alejandro Celaya
549a8d8837 Merge pull request #2233 from acelaya-forks/feature/endroid-qr-code-6
Update to endroid/qr-code 6.0
2024-10-22 09:06:30 +02:00
Alejandro Celaya
5fb6c8708c Update to endroid/qr-code 6.0 2024-10-22 09:02:32 +02:00
Alejandro Celaya
7ee757243a Merge pull request #2230 from acelaya-forks/feature/xdebug-coverage
Switch to xdebug for code coverage reports
2024-10-21 12:01:29 +02:00
Alejandro Celaya
044efe6ee4 Switch to xdebug for code coverage reports 2024-10-21 11:54:45 +02:00
Alejandro Celaya
9b16749737 Remove twitter badge from readme 2024-10-17 16:27:38 +02:00
Alejandro Celaya
6d51ff831f Merge pull request #2228 from acelaya-forks/feature/docker-signals
Feature/docker signals
2024-10-17 15:09:08 +02:00
Alejandro Celaya
0635615149 Run RoadRunner in docker with exec to ensure signals are properly handled 2024-10-17 15:03:55 +02:00
Alejandro Celaya
51de4b17c0 Merge pull request #2227 from shlinkio/develop
Release 4.2.3
2024-10-17 09:41:21 +02:00
Alejandro Celaya
615b443652 Merge pull request #2226 from acelaya-forks/feature/fix-qr-codes
Update to shlink-config 3.2.1, which fixes skipping config options with null value
2024-10-17 09:37:21 +02:00
Alejandro Celaya
4b7b530f49 Update to shlink-config 3.2.1, which fixes skipping config options with null value 2024-10-17 09:33:53 +02:00
Alejandro Celaya
fa7969c746 Merge pull request #2222 from shlinkio/develop
Release 4.2.2
2024-10-14 09:50:13 +02:00
Alejandro Celaya
aef04af4f0 Merge pull request #2220 from acelaya-forks/feature/env-var-command
Feature/env var command
2024-10-14 09:45:48 +02:00
Alejandro Celaya
f118ea252c Depend on shlink-config 3.2 2024-10-14 09:41:47 +02:00
Alejandro Celaya
d514f39a82 Update changelog 2024-10-14 09:41:46 +02:00
Alejandro Celaya
e17556a7ae Add ReadEnvVarCommand test 2024-10-14 09:41:22 +02:00
Alejandro Celaya
d79f11eeb8 Add missing default value for DEFAULT_QR_CODE_BG_COLOR env var 2024-10-14 09:41:22 +02:00
Alejandro Celaya
1ec950ee1e Fix tests not properly unsetting env vars 2024-10-14 09:41:22 +02:00
Alejandro Celaya
14ba9fd6a4 Create command to return the value of an env var for current env 2024-10-14 09:41:22 +02:00
Alejandro Celaya
83e8801827 Move env var default values to EnvVars enum 2024-10-14 09:41:22 +02:00
Alejandro Celaya
be822646e4 Update changelog 2024-10-13 09:49:34 +02:00
Alejandro Celaya
3a4a27a60c Merge pull request #2214 from acelaya-forks/feature/fix-query-params
Ensure query parameters are preserved verbatim when forwarded to long URL
2024-10-10 11:38:15 +02:00
Alejandro Celaya
1773e6ecae Ensure query parameters are preserved verbatim when forwarded to long URL 2024-10-10 11:35:29 +02:00
Alejandro Celaya
a8e4b2fceb Merge pull request #2211 from acelaya-forks/feature/explicit-env-from-config
Promote installer config options as env vars explicitly
2024-10-08 09:07:11 +02:00
Alejandro Celaya
15b53ef43c Update changelog 2024-10-08 09:04:30 +02:00
Alejandro Celaya
11a4702b10 Promote installer config options as env vars explicitly 2024-10-08 08:57:51 +02:00
Alejandro Celaya
6b15cd6d51 Merge pull request #2204 from shlinkio/develop
Release 4.2.1
2024-10-04 12:53:11 +02:00
Alejandro Celaya
00169a5729 Require shlink-common 6.3 2024-10-04 12:48:19 +02:00
Alejandro Celaya
94702791d9 Merge pull request #2203 from acelaya-forks/feature/fix-memory-limit
Fix `MEMORY_LIMIT` being ignored when provided as installer config option
2024-10-04 12:43:38 +02:00
Alejandro Celaya
447cccacdf Update changelog 2024-10-04 12:41:02 +02:00
Alejandro Celaya
0413399102 Make sure MEMORY_LIMIT env var is read after config options have been promoted to env vars 2024-10-04 12:33:27 +02:00
Alejandro Celaya
9afc7876c4 Merge pull request #2184 from acelaya-forks/feature/redis-db-index
Allow specifying the redis database index to be used
2024-08-26 10:00:05 +02:00
Alejandro Celaya
187c17319a Take all Postgres platform classes into consideration 2024-08-26 09:57:17 +02:00
Alejandro Celaya
7310ecd886 Allow specifying the redis database index to be used 2024-08-25 12:51:34 +02:00
Alejandro Celaya
620cd92d11 Merge pull request #2172 from shlinkio/develop
Release v4.2.0
2024-08-11 18:33:09 +02:00
Alejandro Celaya
f9658c8da1 Add v4.2.0 to changelog 2024-08-11 18:30:06 +02:00
Alejandro Celaya
613b1d3045 Update changelog 2024-08-06 10:13:55 +02:00
Alejandro Celaya
d39711ec51 Merge pull request #2170 from acelaya-forks/feature/testdox-summary
Add --testdox-summary flag to phpunit executions
2024-08-04 13:16:32 +02:00
Alejandro Celaya
69dcab96f8 Add --testdox-summary flag to phpunit executions 2024-08-04 13:13:03 +02:00
Alejandro Celaya
d76c96ad41 Fix coding standard 2024-08-01 08:38:49 +02:00
Alejandro Celaya
133efff2cd Improve PHPStan config 2024-07-31 19:53:05 +02:00
Alejandro Celaya
c10f0db170 Merge pull request #2168 from acelaya-forks/feature/update-common
Update to latest shlink-common and remove deprecation references
2024-07-29 20:47:04 +02:00
Alejandro Celaya
037cd8a389 Add missing generic tyoes annotations 2024-07-29 20:43:52 +02:00
Alejandro Celaya
1d24750f43 Fix phpstan checks 2024-07-29 19:59:46 +02:00
Alejandro Celaya
b52ceaff9a Update to latest shlink-common and remove deprecation references 2024-07-29 19:41:40 +02:00
Alejandro Celaya
6b0b52853c Improve repro steps description in bug issue template 2024-07-28 10:49:24 +02:00
Alejandro Celaya
64d7ac7093 Merge pull request #2166 from acelaya-forks/feature/options-enum
Reduce hardcoded options in ShortUrlDataInput
2024-07-27 09:15:16 +02:00
Alejandro Celaya
b9ba1246d4 Reduce hardcoded options in ShortUrlDataInput 2024-07-27 09:12:54 +02:00
Alejandro Celaya
7f9dc10f6a Merge pull request #2164 from acelaya-forks/feature/update-url-cli
Add command to update short URLs
2024-07-26 20:14:02 +02:00
Alejandro Celaya
a1afc90150 Fix sqlcmd path 2024-07-26 20:09:59 +02:00
Alejandro Celaya
df94c68e2e Add unit test for EditShortUrlCommand 2024-07-26 19:54:39 +02:00
Alejandro Celaya
65ea1e00a6 Prevent resetting of non-providen params in EditShortUrlCommand 2024-07-26 19:26:48 +02:00
Alejandro Celaya
5bccdded8a Create command to edit existing short URLs 2024-07-26 09:21:00 +02:00
Alejandro Celaya
8917ed5c2e Create command to edit existing short URLs 2024-07-26 00:01:40 +02:00
Alejandro Celaya
fabc752398 Extract reading and parsing of arguments for short URLs data in commands 2024-07-25 23:44:46 +02:00
Alejandro Celaya
38d8086516 Merge pull request #2161 from acelaya-forks/feature/php-8.4-ci
Add PHP 8.4 to CI
2024-07-23 20:06:09 +02:00
Alejandro Celaya
ae0ff5f23c Add PHP 8.4 to CI 2024-07-23 20:02:49 +02:00
Alejandro Celaya
7c659699f3 Merge pull request #2151 from acelaya-forks/feature/ip-dynamic-redirects
Add logic for IP-based dynamic redirects
2024-07-18 21:32:24 +02:00
Alejandro Celaya
9e6cdcb838 Update changelog 2024-07-18 21:26:28 +02:00
Alejandro Celaya
7e2f755dfd Validate IP address patterns when creating ip-address redirect conditions 2024-07-18 21:23:48 +02:00
Alejandro Celaya
ce2ed237c7 Add ip-address condition type to redirect rules API spec docs 2024-07-17 20:23:58 +02:00
Alejandro Celaya
626caa4afa Add API test for dynamic IP-based redirects 2024-07-17 20:13:46 +02:00
Alejandro Celaya
f4a7712ded Add InvalidIpFormatExceptionTest 2024-07-17 19:59:13 +02:00
Alejandro Celaya
bab6a3951e Add missing unit test 2024-07-17 19:56:53 +02:00
Alejandro Celaya
f49d98f2ea Add logic for IP-based dynamic redirects 2024-07-17 19:51:13 +02:00
Alejandro Celaya
1312ea61f4 Add new IP address redirect condition 2024-07-06 10:35:33 +02:00
Alejandro Celaya
8d90661d0a Extract logic to match IP address against list of groups 2024-07-06 10:12:05 +02:00
Alejandro Celaya
b6b2530cb6 Merge pull request #2149 from acelaya-forks/feature/robots-user-agents
Add option to customize user agents in robots.txt
2024-07-06 10:08:03 +02:00
Alejandro Celaya
e4f66b7ce6 Update installer 2024-07-05 09:41:26 +02:00
Alejandro Celaya
4b52c92e97 Add option to customize user agents in robots.txt 2024-07-05 08:54:54 +02:00
Alejandro Celaya
76c42bc17c Merge pull request #2148 from acelaya-forks/feature/roadrunner-2024
Update to RoadRunner 2024
2024-07-03 19:56:36 +02:00
Alejandro Celaya
c4f8da5f02 Fix phpstan error definition 2024-07-03 19:53:26 +02:00
Alejandro Celaya
80bdeb280a Update to RoadRunner 2024 2024-07-03 19:52:06 +02:00
Alejandro Celaya
99010b6eae Fix merge conflicts 2024-05-23 09:26:27 +02:00
Alejandro Celaya
1901964de1 Merge pull request #2135 from acelaya-forks/feature/non-utf8-titles
Convert encoding of resolved titles based on page encoding
2024-05-22 18:14:56 +02:00
Alejandro Celaya
80e9c2452b Convert encoding of resolved titles based on page encoding 2024-05-22 18:11:55 +02:00
Alejandro Celaya
5ad4b39160 Merge pull request #2132 from acelaya-forks/feature/update-phpstan
Update to latest phpstan
2024-05-21 19:05:39 +02:00
Alejandro Celaya
89b73a9cfa Update to latest phpstan 2024-05-21 18:09:45 +02:00
Alejandro Celaya
e2d8334d69 Merge pull request #2130 from marijnvandevoorde/nanoid
Replaces short-id by nano-id
2024-05-21 17:58:53 +02:00
Marijn Vandevoorde
9b16d7acc0 Replaces short-id by nano-id 2024-05-16 14:00:39 +02:00
Alejandro Celaya
6836840746 Merge pull request #2125 from acelaya-forks/feature/phpunit-11
Update to PHPUnit 11
2024-05-12 13:22:26 +02:00
Alejandro Celaya
4084d301ca Update to PHPUnit 11 2024-05-12 12:49:53 +02:00
Alejandro Celaya
added21b18 Merge pull request #2118 from shlinkio/revert-2117-feature/superfluous-distinct
Revert "Remove unneeded DISTINCT from list short URLs query"
2024-05-09 10:00:29 +02:00
Alejandro Celaya
8cd77391cc Revert "Remove unneeded DISTINCT from list short URLs query" 2024-05-09 09:43:55 +02:00
Alejandro Celaya
05ebfccc63 Merge pull request #2117 from acelaya-forks/feature/superfluous-distinct
Remove unneeded DISTINCT from list short URLs query
2024-05-06 18:54:01 +02:00
Alejandro Celaya
cb3a690294 Remove unneeded DISTINCT from list short URLs query 2024-05-06 18:50:10 +02:00
Alejandro Celaya
194a7b0e57 Merge pull request #2115 from acelaya-forks/feature/fix-oas-docs
Fix typo in OAS docs
2024-04-29 15:22:32 +02:00
Alejandro Celaya
98e4d01feb Fix typo in OAS docs 2024-04-29 15:18:54 +02:00
Alejandro Celaya
c22e3895b5 Allow more dev hosts in dev mercure 2024-04-29 08:52:18 +02:00
Alejandro Celaya
9a76c19615 Migrate to new docker-publish-image reusable workflow 2024-04-26 09:27:21 +02:00
Alejandro Celaya
59fa088975 Merge pull request #2107 from acelaya-forks/feature/robots-allow-all
Add option to allow all URLs to be crawlable via robots.txt
2024-04-22 09:23:34 +02:00
Alejandro Celaya
163244f40f Add option to allow all URLs to be crawlable via robots.txt 2024-04-22 09:16:44 +02:00
Alejandro Celaya
a89b53af4f Link crchived changelogs from main one 2024-04-21 16:46:24 +02:00
259 changed files with 2425 additions and 1357 deletions

View File

@@ -1,5 +1,6 @@
bin/rr
config/autoload/*local*
config/params/shlink_dev_env.*
data/infra
data/cache/*
data/log/*

1
.gitattributes vendored
View File

@@ -13,7 +13,6 @@
.travis.yml export-ignore
build.sh export-ignore
CHANGELOG.md export-ignore
docker-compose.override.yml.dist export-ignore
docker-compose.yml export-ignore
indocker export-ignore
phpcs.xml export-ignore

View File

@@ -61,7 +61,11 @@ body:
label: Minimum steps to reproduce
value: |
<!--
Emphasis in MINIMUM: What is the simplest way to reproduce the bug?
Avoid things like "Create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause and the bug may be closed as "not reproducible".
If you can provide a simple docker compose config, that's even better.
Simple but detailed way to reproduce the bug:
* Avoid things like "create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause.
* Avoid too vague steps or one-liners like "Update from v1 to v2".
* Providing the reproduction in the form of a self-contained docker-composer is desirable.
Failing in any of these will cause the issue to be closed as "not reproducible".
-->

View File

@@ -40,9 +40,8 @@ runs:
php-version: ${{ inputs.php-version }}
tools: composer
extensions: ${{ inputs.php-extensions }}
coverage: pcov
ini-values: pcov.directory=module
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.4' && '--ignore-platform-req=php' || '' }}
shell: bash

View File

@@ -10,10 +10,11 @@ on:
jobs:
db-tests:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3']
php-version: ['8.2', '8.3', '8.4']
continue-on-error: ${{ matrix.php-version == '8.4' }}
env:
LC_ALL: C
steps:
@@ -31,12 +32,12 @@ jobs:
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
- name: Create test database
if: ${{ inputs.platform == 'ms' }}
run: docker compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
run: docker compose exec -T shlink_db_ms /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- name: Run tests
run: composer test:db:${{ inputs.platform }}
- name: Upload code coverage
uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }}
if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |

View File

@@ -1,4 +1,4 @@
name: Build docker image
name: Test docker image build
on:
pull_request:
@@ -7,8 +7,4 @@ on:
jobs:
build-docker-image:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- run: docker build -t shlink-docker-image:temp .
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main

View File

@@ -10,10 +10,11 @@ on:
jobs:
tests:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3']
php-version: ['8.2', '8.3', '8.4']
continue-on-error: ${{ matrix.php-version == '8.4' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:
@@ -33,7 +34,7 @@ jobs:
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.2' }}
if: ${{ matrix.php-version == '8.3' }}
with:
name: coverage-${{ inputs.test-group }}
path: |

View File

@@ -24,10 +24,10 @@ on:
jobs:
static-analysis:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2']
php-version: ['8.3']
command: ['cs', 'stan', 'swagger:validate']
steps:
- uses: actions/checkout@v4
@@ -66,10 +66,10 @@ jobs:
- api-tests
- cli-tests
- db-tests
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2']
php-version: ['8.3']
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -94,7 +94,7 @@ jobs:
delete-artifacts:
needs:
- upload-coverage
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
with:

View File

@@ -7,10 +7,10 @@ on:
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3']
php-version: ['8.2', '8.3'] # TODO 8.4
steps:
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
@@ -26,7 +26,7 @@ jobs:
publish:
needs: ['build']
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
@@ -43,7 +43,7 @@ jobs:
delete-artifacts:
needs: ['publish']
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
with:

View File

@@ -7,7 +7,7 @@ on:
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2']

2
.gitignore vendored
View File

@@ -12,7 +12,5 @@ data/GeoLite2-City.*
data/infra/matomo
docs/swagger-ui*
docs/mercure.html
docker-compose.override.yml
.phpunit.result.cache
docs/swagger/swagger-inlined.json
phpcov*

View File

@@ -4,6 +4,104 @@ 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.2.4] - 2024-10-27
### Added
* *Nothing*
### Changed
* [#2231](https://github.com/shlinkio/shlink/issues/2231) Update to `endroid/qr-code` 6.0.
* [#2221](https://github.com/shlinkio/shlink/issues/2221) Switch to env vars to handle dev/local options.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2232](https://github.com/shlinkio/shlink/issues/2232) Run RoadRunner in docker with `exec` to ensure signals are properly handled.
## [4.2.3] - 2024-10-17
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2225](https://github.com/shlinkio/shlink/issues/2225) Fix regression introduced in v4.2.2, making config options with `null` value to be promoted as env vars with value `''`, instead of being skipped.
## [4.2.2] - 2024-10-14
### Added
* *Nothing*
### Changed
* [#2208](https://github.com/shlinkio/shlink/issues/2208) Explicitly promote installer config options as env vars, instead of as a side effect of loading the app config.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2213](https://github.com/shlinkio/shlink/issues/2213) Fix spaces being replaced with underscores in query parameter names, when forwarded from short URL to long URL.
* [#2217](https://github.com/shlinkio/shlink/issues/2217) Fix docker image tag suffix being leaked to the version set inside Shlink, producing invalid SemVer version patterns.
* [#2212](https://github.com/shlinkio/shlink/issues/2212) Fix env vars read in docker entry point not properly falling back to their `_FILE` suffixed counterpart.
## [4.2.1] - 2024-10-04
### Added
* [#2183](https://github.com/shlinkio/shlink/issues/2183) Redis database index to be used can now be specified in the connection URI path, and Shlink will honor it.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2201](https://github.com/shlinkio/shlink/issues/2201) Fix `MEMORY_LIMIT` option being ignored when provided via installer options.
## [4.2.0] - 2024-08-11
### Added
* [#2120](https://github.com/shlinkio/shlink/issues/2120) Add new IP address condition for the dynamic rules redirections system.
The conditions allow you to define IP addresses to match as static IP (1.2.3.4), CIDR block (192.168.1.0/24) or wildcard pattern (1.2.\*.\*).
* [#2018](https://github.com/shlinkio/shlink/issues/2018) Add option to allow all short URLs to be unconditionally crawlable in robots.txt, via `ROBOTS_ALLOW_ALL_SHORT_URLS=true` env var, or config option.
* [#2109](https://github.com/shlinkio/shlink/issues/2109) Add option to customize user agents robots.txt, via `ROBOTS_USER_AGENTS=foo,bar,baz` env var, or config option.
* [#2163](https://github.com/shlinkio/shlink/issues/2163) Add `short-urls:edit` command to edit existing short URLs.
This brings CLI and API interfaces capabilities closer, and solves an overlook since the feature was implemented years ago.
* [#2164](https://github.com/shlinkio/shlink/pull/2164) Add missing `--title` option to `short-url:create` and `short-url:edit` commands.
### Changed
* [#2096](https://github.com/shlinkio/shlink/issues/2096) Update to RoadRunner 2024.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.1.1] - 2024-05-23
### Added
* *Nothing*

View File

@@ -16,11 +16,14 @@ The first thing you need to do is fork the repository, and clone it in your loca
Then you will have to follow these steps:
* Copy all files with `.local.php.dist` extension from `config/autoload` by removing the dist extension.
* Copy the `config/params/shlink_dev_env.php.dist` in the same directory, but removing the `.dist` extension:
For example the `common.local.php.dist` file should be copied as `common.local.php`.
```
cp config/params/shlink_dev_env.php.dist config/params/shlink_dev_env.php
```
The `shlink_dev_env.php` file is gitignored, so you can customize it as you want. For example, by adding your own GeoLite license key.
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
* Start-up the project by running `docker compose up`.
The first time this command is run, it will create several containers that are used during development, so it may take some time.

View File

@@ -43,7 +43,7 @@ RUN apk add --no-cache git && \
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \
php composer.phar clear-cache && \
rm -r docker composer.* && \
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" module/Core/src/Config/Options/AppOptions.php
# Prepare final image
@@ -61,7 +61,6 @@ EXPOSE 8080
# Copy config specific for the image
COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
USER ${USER_ID}

View File

@@ -8,7 +8,6 @@
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social)
[![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.

View File

@@ -6,6 +6,8 @@ export TEST_RUNTIME="${TEST_RUNTIME:-"rr"}" # rr is the only runtime currently s
export DB_DRIVER="${DB_DRIVER:-"postgres"}"
export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}"
[ "$GENERATE_COVERAGE" != 'no' ] && export XDEBUG_MODE=coverage
# Reset logs
OUTPUT_LOGS=data/log/api-tests/output.log
rm -rf data/log/api-tests
@@ -22,7 +24,7 @@ echo 'Starting server...'
-o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" &
sleep 2 # Let's give the server a couple of seconds to start
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --testdox-summary $*
TESTS_EXIT_CODE=$?
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w .

14
bin/test/run-cli-tests.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env sh
export APP_ENV=test
export TEST_ENV=cli
export DB_DRIVER="${DB_DRIVER:-"maria"}"
export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}"
[ "$GENERATE_COVERAGE" != 'no' ] && export XDEBUG_MODE=coverage
vendor/bin/phpunit --order-by=random --testdox --testdox-summary -c phpunit-cli.xml $*
TESTS_EXIT_CODE=$?
# Exit this script with the same code as the tests. If tests failed, this script has to fail
exit $TESTS_EXIT_CODE

View File

@@ -35,8 +35,8 @@ ${composerBin} install --no-dev --prefer-dist --optimize-autoloader --no-progres
echo 'Deleting dev files...'
rm composer.*
# Update Shlink version in config
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Update Shlink version
sed -i "s/%SHLINK_VERSION%/${version}/g" module/Core/src/Config/Options/AppOptions.php
# Compressing file
echo 'Compressing files...'

View File

@@ -18,64 +18,64 @@
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^3.0.2",
"doctrine/dbal": "^4.0",
"doctrine/migrations": "^3.6",
"doctrine/orm": "^3.0",
"endroid/qr-code": "^5.0",
"akrabat/ip-address-middleware": "^2.3",
"cakephp/chronos": "^3.1",
"doctrine/dbal": "^4.2",
"doctrine/migrations": "^3.8",
"doctrine/orm": "^3.3",
"endroid/qr-code": "^6.0",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.0",
"guzzlehttp/guzzle": "^7.5",
"guzzlehttp/guzzle": "^7.9",
"hidehalo/nanoid-php": "^1.1",
"jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.8",
"laminas/laminas-config": "^3.9",
"laminas/laminas-config-aggregator": "^1.15",
"laminas/laminas-diactoros": "^3.3",
"laminas/laminas-inputfilter": "^2.27",
"laminas/laminas-servicemanager": "^3.21",
"laminas/laminas-stdlib": "^3.17",
"matomo/matomo-php-tracker": "^3.2",
"mezzio/mezzio": "^3.17",
"mezzio/mezzio-fastroute": "^3.11",
"mezzio/mezzio-problem-details": "^1.13",
"laminas/laminas-diactoros": "^3.5",
"laminas/laminas-inputfilter": "^2.30",
"laminas/laminas-servicemanager": "^3.22",
"laminas/laminas-stdlib": "^3.19",
"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",
"mobiledetect/mobiledetectlib": "^4.8",
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1",
"shlinkio/shlink-common": "^6.1",
"shlinkio/shlink-config": "^3.0",
"shlinkio/shlink-common": "^6.4",
"shlinkio/shlink-config": "^3.3",
"shlinkio/shlink-event-dispatcher": "^4.1",
"shlinkio/shlink-importer": "^5.3.2",
"shlinkio/shlink-installer": "^9.1",
"shlinkio/shlink-ip-geolocation": "^4.0",
"shlinkio/shlink-installer": "^9.2",
"shlinkio/shlink-ip-geolocation": "^4.1",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2023.3",
"spiral/roadrunner": "^2024.1",
"spiral/roadrunner-cli": "^2.6",
"spiral/roadrunner-http": "^3.3",
"spiral/roadrunner-jobs": "^4.3",
"symfony/console": "^7.0",
"symfony/filesystem": "^7.0",
"symfony/lock": "^7.0",
"symfony/process": "^7.0",
"symfony/string": "^7.0"
"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"
},
"require-dev": {
"devizzent/cebe-php-openapi": "^1.0.1",
"devster/ubench": "^2.1",
"phpstan/phpstan": "^1.11",
"phpstan/phpstan-doctrine": "^1.4",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-doctrine": "^1.5",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-symfony": "^1.4",
"phpunit/php-code-coverage": "^11.0",
"phpunit/phpcov": "^10.0",
"phpunit/phpunit": "^11.1",
"phpunit/phpunit": "^11.4",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^4.1",
"symfony/var-dumper": "^7.0",
"veewee/composer-run-parallel": "^1.3"
"shlinkio/shlink-test-utils": "^4.1.1",
"symfony/var-dumper": "^7.1",
"veewee/composer-run-parallel": "^1.4"
},
"conflict": {
"symfony/var-exporter": ">=6.3.9,<=6.4.0"
@@ -114,31 +114,47 @@
],
"cs": "phpcs -s",
"cs:fix": "phpcbf",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config module/*/migrations config docker/config --level=8",
"stan": ["@putenv APP_ENV=test", "phpstan analyse"],
"test": [
"@parallel test:unit test:db",
"@parallel test:api test:cli"
],
"test:unit": "COLUMNS=120 vendor/bin/phpunit --order-by=random --colors=always --testdox",
"test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov",
"test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html",
"test:unit": ["@putenv COLUMNS=120", "phpunit --order-by=random --testdox --testdox-summary"],
"test:unit:ci": ["@putenv XDEBUG_MODE=coverage", "@test:unit --coverage-php=build/coverage-unit.cov"],
"test:unit:pretty": ["@putenv XDEBUG_MODE=coverage", "@test:unit --coverage-html build/coverage-unit/coverage-html"],
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite -- $*",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite -- $*",
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite -- $*",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite -- $*",
"test:db:sqlite": ["@putenv APP_ENV=test", "phpunit --order-by=random --testdox --testdox-summary -c phpunit-db.xml"],
"test:db:sqlite:ci": ["@putenv XDEBUG_MODE=coverage", "@test:db:sqlite --coverage-php build/coverage-db.cov"],
"test:db:mysql": ["@putenv DB_DRIVER=mysql", "@test:db:sqlite"],
"test:db:maria": ["@putenv DB_DRIVER=maria", "@test:db:sqlite"],
"test:db:postgres": ["@putenv DB_DRIVER=postgres", "@test:db:sqlite"],
"test:db:ms": ["@putenv DB_DRIVER=mssql", "@test:db:sqlite"],
"test:api": "bin/test/run-api-tests.sh",
"test:api:sqlite": "DB_DRIVER=sqlite composer test:api -- $*",
"test:api:mysql": "DB_DRIVER=mysql composer test:api -- $*",
"test:api:maria": "DB_DRIVER=maria composer test:api -- $*",
"test:api:mssql": "DB_DRIVER=mssql composer test:api -- $*",
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov",
"test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov",
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml",
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov",
"test:cli:pretty": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov",
"test:api:sqlite": ["@putenv DB_DRIVER=sqlite", "@test:api"],
"test:api:mysql": ["@putenv DB_DRIVER=mysql", "@test:api"],
"test:api:maria": ["@putenv DB_DRIVER=maria", "@test:api"],
"test:api:mssql": ["@putenv DB_DRIVER=mssql", "@test:api"],
"test:api:ci": [
"@putenv GENERATE_COVERAGE=yes",
"@test:api",
"phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov"
],
"test:api:pretty": [
"@putenv GENERATE_COVERAGE=yes",
"@test:api",
"phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov"
],
"test:cli": "bin/test/run-cli-tests.sh",
"test:cli:ci": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
"vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
],
"test:cli:pretty": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
],
"swagger:validate": "php-openapi validate docs/swagger/swagger.json",
"swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json",
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"

View File

@@ -1,2 +0,0 @@
local.php
*.local.php

View File

@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
return [
'app_options' => [
'name' => 'Shlink',
'version' => '%SHLINK_VERSION%',
],
];

View File

@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
return [
'app_options' => [
'version' => 'latest',
],
];

View File

@@ -6,7 +6,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
$redisServers = EnvVars::REDIS_SERVERS->loadFromEnv();
$redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false)];
$redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv()];
$cacheRedisBlock = $redisServers === null ? [] : [
'redis' => [
'servers' => $redisServers,
@@ -16,7 +16,7 @@ return (static function (): array {
return [
'cache' => [
'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv('Shlink'),
'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv(),
...$cacheRedisBlock,
],
'redis' => $redis,

View File

@@ -3,13 +3,18 @@
declare(strict_types=1);
use Laminas\ConfigAggregator\ConfigAggregator;
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
return (function () {
$isDev = EnvVars::isDevEnv();
'debug' => false,
return [
// Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console
// commands don't generate a cache file that's then used by php-fpm web executions
ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli',
'debug' => $isDev,
];
// Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console
// commands don't generate a cache file that's then used by php-fpm web executions
ConfigAggregator::ENABLE_CACHE => ! $isDev && PHP_SAPI !== 'cli',
];
})();

View File

@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
use Laminas\ConfigAggregator\ConfigAggregator;
return [
'debug' => true,
ConfigAggregator::ENABLE_CACHE => false,
];

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
$threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD->loadFromEnv();
return [
'delete_short_urls' => [
'check_visits_threshold' => $threshold !== null,
'visits_threshold' => (int) ($threshold ?? DEFAULT_DELETE_SHORT_URL_THRESHOLD),
],
];
})();

View File

@@ -11,6 +11,7 @@ use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\WorkerInterface;
use Symfony\Component\Filesystem\Filesystem;
@@ -36,7 +37,7 @@ return [
'lazy_services' => [
'proxies_target_dir' => 'data/proxies',
'proxies_namespace' => 'ShlinkProxy',
'write_proxy_files' => true,
'write_proxy_files' => EnvVars::isProdEnv(),
],
],

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Psr\Log;
return [
'dependencies' => [
'lazy_services' => [
'write_proxy_files' => false,
],
'initializers' => [
function (ContainerInterface $container, $instance): void {
if ($instance instanceof Log\LoggerAwareInterface) {
$instance->setLogger($container->get(Log\LoggerInterface::class));
}
},
],
],
];

View File

@@ -23,11 +23,6 @@ return (static function (): array {
$value = $envVar->loadFromEnv();
return $value === null ? null : (string) $value;
};
$resolveDefaultPort = static fn () => match ($driver) {
'postgres' => '5432',
'mssql' => '1433',
default => '3306',
};
$resolveCharset = static fn () => 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.
@@ -43,11 +38,11 @@ return (static function (): array {
],
default => [
'driver' => $resolveDriver(),
'dbname' => EnvVars::DB_NAME->loadFromEnv('shlink'),
'dbname' => EnvVars::DB_NAME->loadFromEnv(),
'user' => $readCredentialAsString(EnvVars::DB_USER),
'password' => $readCredentialAsString(EnvVars::DB_PASSWORD),
'host' => EnvVars::DB_HOST->loadFromEnv(EnvVars::DB_UNIX_SOCKET->loadFromEnv()),
'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()),
'host' => EnvVars::DB_HOST->loadFromEnv(),
'port' => EnvVars::DB_PORT->loadFromEnv(),
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
'charset' => $resolveCharset(),
'driverOptions' => $driver !== 'mssql' ? [] : [

View File

@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
return [
'entity_manager' => [
'connection' => [
// MySQL
'user' => 'root',
'password' => 'root',
'driver' => 'pdo_mysql',
'host' => 'shlink_db_mysql',
'dbname' => 'shlink',
// 'dbname' => 'shlink_foo',
'charset' => 'utf8mb4',
// MariaDB
// 'user' => 'root',
// 'password' => 'root',
// 'driver' => 'pdo_mysql',
// 'host' => 'shlink_db_maria',
// 'dbname' => 'shlink_foo',
// 'charset' => 'utf8mb4',
// Postgres
// 'user' => 'postgres',
// 'password' => 'root',
// 'driver' => 'pdo_pgsql',
// 'host' => 'shlink_db_postgres',
// 'dbname' => 'shlink_foo',
// 'charset' => 'utf8',
// MSSQL
// 'user' => 'sa',
// 'password' => 'Passw0rd!',
// 'driver' => 'pdo_sqlsrv',
// 'host' => 'shlink_db_ms',
// 'dbname' => 'shlink_foo',
// 'driverOptions' => [
// 'TrustServerCertificate' => 'true',
// ],
],
],
];

View File

@@ -45,6 +45,8 @@ return [
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
Option\UrlShortener\ShortUrlModeConfigOption::class,
Option\Robots\RobotsAllowAllShortUrlsConfigOption::class,
Option\Robots\RobotsUserAgentsConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,

View File

@@ -14,23 +14,33 @@ use Shlinkio\Shlink\Common\Logger\LoggerFactory;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\EventDispatcher\Helper\RequestIdProvider;
use Shlinkio\Shlink\EventDispatcher\Util\RequestIdProviderInterface;
use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return (static function (): array {
$isDev = EnvVars::isDevEnv();
$common = [
'level' => Level::Info->value,
'level' => $isDev ? Level::Debug->value : Level::Info->value,
'processors' => [RequestIdMiddleware::class],
'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
$useStreamForShlinkLogger = $isDev || env('SHLINK_RUNTIME') !== null;
return [
'logger' => [
'Shlink' => [
'Shlink' => $useStreamForShlinkLogger ? [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
...$common,
] : [
'type' => LoggerType::FILE->value,
...$common,
],

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
use Monolog\Level;
use Shlinkio\Shlink\Common\Logger\LoggerType;
return [
'logger' => [
'Shlink' => [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
'level' => Level::Debug->value,
],
],
];

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'matomo' => [
'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false),
'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(),
'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(),
'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(),
],
];

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
/*
* Dev matomo instance needs to be manually configured once before enabling the configuration below.
*
* 1. Go to http://localhost:8003 and follow the installation instructions.
* 2. Open data/infra/matomo/config/config.ini.php and replace `trusted_hosts[] = "localhost"` with
* `trusted_hosts[] = "localhost:8003"` (see https://github.com/matomo-org/matomo/issues/9549)
* 3. Go to http://localhost:8003/index.php?module=SitesManager&action=index and paste the ID for the site you just
* created into the `site_id` field below.
* 4. Go to http://localhost:8003/index.php?module=UsersManager&action=userSecurity, scroll down, click
* "Create new token" and once generated, paste the token into the `api_token` field below.
*/
return [
'matomo' => [
// 'enabled' => true,
// 'base_url' => 'http://shlink_matomo',
// 'site_id' => '...',
// 'api_token' => '...',
],
];

View File

@@ -8,34 +8,31 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface;
return (static function (): array {
$publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv();
return [
return [
// This config is used by shlink-common. Do not delete
'mercure' => [
'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(),
'jwt_issuer' => 'Shlink',
],
'mercure' => [
'public_hub_url' => $publicUrl,
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv($publicUrl),
'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(),
'jwt_issuer' => 'Shlink',
],
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
],
Hub::class => [
LazyServiceFactory::class,
],
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
],
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Hub::class => HubInterface::class,
],
Hub::class => [
LazyServiceFactory::class,
],
],
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Hub::class => HubInterface::class,
],
],
],
];
})();
];

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
return [
'mercure' => [
'public_hub_url' => 'http://localhost:8002',
'internal_hub_url' => 'http://shlink_mercure_proxy',
'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error',
],
];

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
return [
'qr_codes' => [
'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE->loadFromEnv(DEFAULT_QR_CODE_SIZE),
'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(DEFAULT_QR_CODE_MARGIN),
'format' => EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(DEFAULT_QR_CODE_FORMAT),
'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv(
DEFAULT_QR_CODE_ERROR_CORRECTION,
),
'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(
DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
),
'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv(
DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
),
'color' => EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(DEFAULT_QR_CODE_COLOR),
'bg_color' => EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(DEFAULT_QR_CODE_BG_COLOR),
'logo_url' => EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(),
],
];

View File

@@ -6,14 +6,15 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
return [
// This config is used by shlink-common. Do not delete
'rabbitmq' => [
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(false),
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(),
'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(),
'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(false),
'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv('5672'),
'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(),
'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv(),
'user' => EnvVars::RABBITMQ_USER->loadFromEnv(),
'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(),
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'),
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv(),
],
];

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
return [
'rabbitmq' => [
'enabled' => true,
'host' => 'shlink_rabbitmq',
'port' => '5672',
'user' => 'rabbit',
'password' => 'rabbit',
],
];

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
return [
'not_found_redirects' => [
'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(),
'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(),
'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(),
],
'redirects' => [
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE->value),
'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(
DEFAULT_REDIRECT_CACHE_LIFETIME,
),
],
];

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
return [
'cache' => [
'redis' => [
'servers' => 'tcp://shlink_redis:6379',
// 'servers' => 'tcp://barbar@shlink_redis_acl:6379',
// 'servers' => 'tcp://foo:bar@shlink_redis_acl:6379',
],
],
'redis' => [
'pub_sub_enabled' => true,
],
'dependencies' => [
'aliases' => [
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
// 'lock_store' => 'redis_lock_store',
],
],
];

View File

@@ -8,12 +8,12 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'router' => [
'base_path' => EnvVars::BASE_PATH->loadFromEnv(''),
'base_path' => EnvVars::BASE_PATH->loadFromEnv(),
'fastroute' => [
// Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console
// commands don't generate a cache file that's then used by php-fpm web executions
FastRouteRouter::CONFIG_CACHE_ENABLED => PHP_SAPI !== 'cli',
FastRouteRouter::CONFIG_CACHE_ENABLED => EnvVars::isProdEnv() && PHP_SAPI !== 'cli',
FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php',
],
],

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
use Mezzio\Router\FastRouteRouter;
return [
'router' => [
// 'base_path' => '',
'fastroute' => [
FastRouteRouter::CONFIG_CACHE_ENABLED => false,
],
],
];

View File

@@ -19,9 +19,7 @@ use function sprintf;
return (static function (): array {
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
// TODO This should be based on config, not the env var
$shortUrlRouteSuffix = EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false) ? '[/]' : '';
$shortUrlRouteSuffix = EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv() ? '[/]' : '';
return [

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
/** @var string|null $disableTrackingFrom */
$disableTrackingFrom = EnvVars::DISABLE_TRACKING_FROM->loadFromEnv();
return [
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true),
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
// If true, visits will not be tracked at all
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false),
// If true, the referrer will not be tracked
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
// If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => $disableTrackingFrom === null
? []
: array_map(trim(...), explode(',', $disableTrackingFrom)),
],
];
})();

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
return (static function (): array {
$shortCodesLength = max(
(int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH),
MIN_SHORT_CODES_LENGTH,
);
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
$mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT;
return [
'url_shortener' => [
'domain' => [ // TODO Refactor this structure to url_shortener.schema and url_shortener.default_domain
'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED->loadFromEnv(true)) ? 'https' : 'http',
'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''),
],
'default_short_codes_length' => $shortCodesLength,
'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(true),
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false),
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false),
'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false),
'mode' => $mode,
],
];
})();

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => sprintf('localhost:%s', match (true) {
runningInRoadRunner() => '8800',
default => '8000',
}),
],
// 'multi_segment_slugs_enabled' => true,
// 'trailing_slash_enabled' => true,
],
];

View File

@@ -8,18 +8,10 @@ use Laminas\ConfigAggregator;
use Laminas\Diactoros;
use Mezzio;
use Mezzio\ProblemDetails;
use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider;
use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Core\enumValues;
$isTestEnv = env('APP_ENV') === 'test';
use Shlinkio\Shlink\Core\Config\EnvVars;
return (new ConfigAggregator\ConfigAggregator(
providers: [
! $isTestEnv
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
: new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
@@ -34,10 +26,10 @@ return (new ConfigAggregator\ConfigAggregator(
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'),
// Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests
new ConfigAggregator\PhpFileProvider(
$isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php',
),
// Test config should be loaded ONLY during tests
EnvVars::isTestEnv()
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
: new ConfigAggregator\ArrayProvider([]),
// Routes have to be loaded last
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
],

View File

@@ -6,16 +6,24 @@ use Laminas\ServiceManager\ServiceManager;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Lock;
use function Shlinkio\Shlink\Config\loadEnvVarsFromConfig;
use function Shlinkio\Shlink\Core\enumValues;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// Set a default memory limit, but allow custom values
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv('512M'));
// This is one of the first files loaded. Configure the timezone here
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
// Promote env vars from installer, dev config or test config
loadEnvVarsFromConfig(
EnvVars::isTestEnv() ? 'config/test/shlink_test_env.php' : 'config/params/*.php',
enumValues(EnvVars::class),
);
// This is one of the first files loaded. Configure the timezone and memory limit here
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv());
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv());
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
// It needs to be placed here as individual config files will not be loaded once config is cached

View File

@@ -1,2 +1,3 @@
*
!.gitignore
!*.dist

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
EnvVars::APP_ENV->value => 'dev',
// EnvVars::GEOLITE_LICENSE_KEY->value => '',
// URL shortener
EnvVars::DEFAULT_DOMAIN->value => 'localhost:8800',
EnvVars::IS_HTTPS_ENABLED->value => false,
// Database - MySQL
EnvVars::DB_DRIVER->value => 'mysql',
EnvVars::DB_USER->value => 'root',
EnvVars::DB_PASSWORD->value => 'root',
EnvVars::DB_NAME->value => 'shlink',
// EnvVars::DB_NAME->value => 'shlink_foo',
EnvVars::DB_HOST->value => 'shlink_db_mysql',
// Database - Maria
// EnvVars::DB_DRIVER->value => 'maria',
// EnvVars::DB_USER->value => 'root',
// EnvVars::DB_PASSWORD->value => 'root',
// EnvVars::DB_NAME->value => 'shlink_foo',
// EnvVars::DB_HOST->value => 'shlink_db_maria',
// Database - Postgres
// EnvVars::DB_DRIVER->value => 'postgres',
// EnvVars::DB_USER->value => 'postgres',
// EnvVars::DB_PASSWORD->value => 'root',
// EnvVars::DB_NAME->value => 'shlink_foo',
// EnvVars::DB_HOST->value => 'shlink_db_postgres',
// Database - MSSQL
// EnvVars::DB_DRIVER->value => 'mssql',
// EnvVars::DB_USER->value => 'sa',
// EnvVars::DB_PASSWORD->value => 'Passw0rd!',
// EnvVars::DB_NAME->value => 'shlink_foo',
// EnvVars::DB_HOST->value => 'shlink_db_ms',
// Matomo
// Dev matomo instance needs to be manually configured once before enabling the configuration below:
// 1. Go to http://localhost:8003 and follow the installation instructions.
// 2. Open data/infra/matomo/config/config.ini.php and replace `trusted_hosts[] = "localhost"` with
// `trusted_hosts[] = "localhost:8003"` (see https://github.com/matomo-org/matomo/issues/9549)
// 3. Go to http://localhost:8003/index.php?module=SitesManager&action=index and paste the ID for the site you just
// created into the `MATOMO_SITE_ID` var below.
// 4. Go to http://localhost:8003/index.php?module=UsersManager&action=userSecurity, scroll down, click
// "Create new token" and once generated, paste the token into the `MATOMO_API_TOKEN` var below.
// 5. Copy the config below and paste it in a new shlink-dev.local.env file.
EnvVars::MATOMO_ENABLED->value => false,
EnvVars::MATOMO_BASE_URL->value => 'http://shlink_matomo',
// EnvVars::MATOMO_SITE_ID->value => ,
// EnvVars::MATOMO_API_TOKEN->value => ,
// Mercure
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',
// RabbitMQ
EnvVars::RABBITMQ_ENABLED->value => true,
EnvVars::RABBITMQ_HOST->value => 'shlink_rabbitmq',
EnvVars::RABBITMQ_PORT->value => 5672,
EnvVars::RABBITMQ_USER->value => 'rabbit',
EnvVars::RABBITMQ_PASSWORD->value => 'rabbit',
// Redis
EnvVars::REDIS_PUB_SUB_ENABLED->value => true,
EnvVars::REDIS_SERVERS->value => 'tcp://shlink_redis:6379',
];

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
EnvVars::APP_ENV->value => 'test',
// URL shortener
EnvVars::DEFAULT_DOMAIN->value => 's.test',
EnvVars::IS_HTTPS_ENABLED->value => false,
];

View File

@@ -93,13 +93,6 @@ return [
ConfigAggregator::ENABLE_CACHE => false,
FastRouteRouter::CONFIG_CACHE_ENABLED => false,
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => 's.test',
],
],
'routes' => [
// This route is used to test that title resolution is skipped if the long URL times out
[
@@ -120,13 +113,6 @@ return [
],
],
// Disable mercure integration during E2E tests
'mercure' => [
'public_hub_url' => null,
'internal_hub_url' => null,
'jwt_secret' => null,
],
'dependencies' => [
'services' => [
'shlink_test_api_client' => new Client([

View File

@@ -46,12 +46,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install pcov and sqlsrv driver
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
# Install xdebug and sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
docker-php-ext-enable pdo_sqlsrv xdebug && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk

View File

@@ -3,5 +3,3 @@ error_reporting=-1
log_errors_max_len=0
zend.assertions=1
assert.exception=1
pcov.enabled=1
pcov.directory=module

View File

@@ -46,12 +46,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install pcov and sqlsrv driver
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
# Install xdebug and sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
docker-php-ext-enable pdo_sqlsrv xdebug && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
@@ -72,5 +73,7 @@ CMD \
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
# Download roadrunner binary
if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; fi && \
# This forces the app to be started every second until the exit code is 0
until ./bin/rr serve -c config/roadrunner/.rr.dev.yml; do sleep 1 ; done
# Create env file if it does not exist yet
if [[ ! -f "./config/params/shlink_dev_env.php" ]]; then cp ./config/params/shlink_dev_env.php.dist ./config/params/shlink_dev_env.php ; fi && \
# Run with `exec` so that signals are properly handled
exec ./bin/rr serve -c config/roadrunner/.rr.dev.yml

View File

@@ -1,14 +1,15 @@
version: '3'
services:
shlink_db_mysql:
user: '0'
environment:
MYSQL_DATABASE: shlink_test
shlink_db_postgres:
user: '0'
environment:
POSTGRES_DB: shlink_test
shlink_db_maria:
user: '0'
environment:
MYSQL_DATABASE: shlink_test

View File

@@ -1,32 +0,0 @@
version: '3'
services:
shlink_php:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_roadrunner:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_mysql:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_postgres:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_maria:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro

View File

@@ -1,5 +1,3 @@
version: '3'
services:
shlink_nginx:
container_name: shlink_nginx
@@ -15,6 +13,7 @@ services:
shlink_php:
container_name: shlink_php
user: 1000:1000
build:
context: .
dockerfile: ./data/infra/php.Dockerfile
@@ -36,11 +35,13 @@ services:
- shlink_matomo
environment:
LC_ALL: C
DEFAULT_DOMAIN: localhost:8000
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_roadrunner:
container_name: shlink_roadrunner
user: 1000:1000
build:
context: .
dockerfile: ./data/infra/roadrunner.Dockerfile
@@ -67,6 +68,7 @@ services:
shlink_db_mysql:
container_name: shlink_db_mysql
user: 1000:1000
image: mysql:8.0
ports:
- "3307:3306"
@@ -79,7 +81,8 @@ services:
shlink_db_postgres:
container_name: shlink_db_postgres
image: postgres:12.2-alpine
user: 1000:1000
image: postgres:16.3-alpine
ports:
- "5434:5432"
volumes:
@@ -92,6 +95,7 @@ services:
shlink_db_maria:
container_name: shlink_db_maria
user: 1000:1000
image: mariadb:10.7
ports:
- "3308:3306"
@@ -147,7 +151,7 @@ services:
SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000"
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000 http://localhost:3002 http://127.0.0.1:3002 http://localhost:3005 http://127.0.0.1:3005"
shlink_rabbitmq:
container_name: shlink_rabbitmq

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Shlinkio\Shlink\Common\Logger\LoggerType;
return [
'logger' => [
'Shlink' => [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
],
],
];

View File

@@ -8,18 +8,24 @@ mkdir -p data/cache data/locks data/log data/proxies
flags="--no-interaction --clear-db-cache"
# Read env vars through Shlink command, so that it applies the `_FILE` env var fallback logic
geolite_license_key=$(bin/cli env-var:read GEOLITE_LICENSE_KEY)
skip_initial_geolite_download=$(bin/cli env-var:read SKIP_INITIAL_GEOLITE_DOWNLOAD)
initial_api_key=$(bin/cli env-var:read INITIAL_API_KEY)
# Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set
if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" = "true" ]; then
if [ -z "${geolite_license_key}" ] || [ "${skip_initial_geolite_download}" = "true" ]; then
flags="${flags} --skip-download-geolite"
fi
# If INITIAL_API_KEY was provided, create an initial API key
if [ -n "${INITIAL_API_KEY}" ]; then
flags="${flags} --initial-api-key=${INITIAL_API_KEY}"
if [ -n "${initial_api_key}" ]; then
flags="${flags} --initial-api-key=${initial_api_key}"
fi
php vendor/bin/shlink-installer init ${flags}
if [ "$SHLINK_RUNTIME" = 'rr' ]; then
./bin/rr serve -c config/roadrunner/.rr.yml
# Run with `exec` so that signals are properly handled
exec ./bin/rr serve -c config/roadrunner/.rr.yml
fi

View File

@@ -0,0 +1,61 @@
# Handle dev and tests config via env vars instead of local config files
* Status: Accepted
* Date: 2024-10-24
## Context and problem statement
Due to the tools used by Shlink (Zend Expressive first and Mezzio later), configuration has always been handled via the config aggregator, which is a package that continues with Zend Framework 2 config management philosophy:
1. Define multiple config files, scoped to their own context, that are merge at runtime.
2. Overwrite with so-called "local" config files, which define values used only during development, and should not be shipped to production.
However, since Shlink started to support other runtimes and added an official docker image, env vars have started to become a central part of the config definition system.
That has evolved into a system where production config can be read from env vars, but dev config is expected to be defined via local config files, forcing to maintain two approaches to load config that need to coexist.
On top of that, keeping dev configs in multiple files makes it harder to keep track of everything.
Because of that, I'm proposing to switch to an env-var-based approach for dev custom configs, and get rid of local config files.
## Considered options
1. Define dev env vars in a single `.env` file which is loaded to containers via docker compose `env-file` option.
2. Define dev env vars in a single `.env` file which is loaded via RoadRunner config.
3. Define dev env vars in a single PHP file returning a map that's then loaded with `loadEnvVarsFromConfig`.
4. Keep local config files and don't change anything.
## Decision outcome
Defining env vars in a PHP file has the benefit that any change will take effect immediately, so the decision is to go with option 3.
## Pros and Cons of the Options
### 1 - .env file via docker compose
* Good: because it does not require any special mechanism to feed the env vars into the app.
* Good: because it's a standard format known by many.
* Bad: because dev config gets leaked to tests when run inside the container, breaking some existing ones, and forcing to remember this for future tests.
* Bad: because any change to the env file requires the containers to be manually restarted, or putting some new mechanism in place to restart them automatically.
### 2 - .env file via RoadRunner
* Good: because it does not require any special mechanism to feed the env vars into the app.
* Good: because it's a standard format known by many.
* Good: because dev config does not get leaked into tests.
* Bad: because any change to the env file requires the containers to be manually restarted, or putting some new mechanism in place to restart them automatically.
### 3 - PHP file via `loadEnvVarsFromConfig`
* Good: because the existing call to `loadEnvVarsFromConfig` can be reused by tweaking a bit the glob pattern, so no new dependencies are needed.
* Good: because dev config does not get leaked into tests, and test-specific env vars can be fed using the same mechanism.
* Good: because changes are picked up instantly by both RoadRunner and php-fpm.
* Good: because env vars can be imported from `EnvVars` class, removing the chances of human mistakes and typos.
* Bad: because people not familiar with the project may not expect env vars to be defined in that format.
### 4 - keep local config
* Good: because no changes are needed in the project.
* Bad: because managing multiple local config files makes things harder to maintain.
* Bad: because setting-up the project from scratch requires more steps, or an external package to handle config files.
* Bad: because the project needs to keep two ways to load dev configs, and reading an env var does not warranty you are getting the single source of truth.

View File

@@ -2,7 +2,8 @@
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
* [2023-07-09Build `latest` docker image only for actual releases](2023-07-09-build-latest-docker-image-only-for-actual-releases.md)
* [2024-10-24 Handle dev and tests config via env vars instead of local config files](2024-10-24-handle-dev-and-tests-config-via-env-vars-instead-of-local-config-files.md)
* [2023-07-09 Build `latest` docker image only for actual releases](2023-07-09-build-latest-docker-image-only-for-actual-releases.md)
* [2023-01-06 Support any HTTP method in short URLs](2023-01-06-support-any-http-method-in-short-urls.md)
* [2022-08-05 Support multi-segment custom slugs](2022-08-05-support-multi-segment-custom-slugs.md)
* [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md)

View File

@@ -15,8 +15,8 @@
"properties": {
"type": {
"type": "string",
"enum": ["device", "language", "query-param"],
"description": "The type of the condition, which will condition the logic used to match it"
"enum": ["device", "language", "query-param", "ip-address"],
"description": "The type of the condition, which will determine the logic used to match it"
},
"matchKey": {
"type": ["string", "null"]

View File

@@ -9,6 +9,7 @@ return [
'cli' => [
'commands' => [
Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class,
Command\ShortUrl\EditShortUrlCommand::NAME => Command\ShortUrl\EditShortUrlCommand::class,
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
@@ -44,6 +45,8 @@ return [
Command\RedirectRule\ManageRedirectRulesCommand::class,
Command\Integration\MatomoSendVisitsCommand::NAME => Command\Integration\MatomoSendVisitsCommand::class,
Command\Config\ReadEnvVarCommand::NAME => Command\Config\ReadEnvVarCommand::class,
],
],

View File

@@ -7,10 +7,10 @@ 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\Matomo;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService;
use Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
@@ -41,6 +41,7 @@ return [
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\EditShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
@@ -74,6 +75,8 @@ return [
Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class,
Command\Integration\MatomoSendVisitsCommand::class => ConfigAbstractFactory::class,
Command\Config\ReadEnvVarCommand::class => InvokableFactory::class,
],
],
@@ -85,13 +88,14 @@ return [
TrackingOptions::class,
],
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
ApiKey\RoleResolver::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
ApiKey\RoleResolver::class => [DomainService::class, UrlShortenerOptions::class],
Command\ShortUrl\CreateShortUrlCommand::class => [
ShortUrl\UrlShortener::class,
ShortUrlStringifier::class,
UrlShortenerOptions::class,
],
Command\ShortUrl\EditShortUrlCommand::class => [ShortUrl\ShortUrlService::class, ShortUrlStringifier::class],
Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [
ShortUrl\ShortUrlListService::class,

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\ApiKey\Role;
@@ -12,11 +13,11 @@ use Symfony\Component\Console\Input\InputInterface;
use function is_string;
class RoleResolver implements RoleResolverInterface
readonly class RoleResolver implements RoleResolverInterface
{
public function __construct(
private readonly DomainServiceInterface $domainService,
private readonly string $defaultDomain,
private DomainServiceInterface $domainService,
private UrlShortenerOptions $urlShortenerOptions,
) {
}
@@ -39,7 +40,7 @@ class RoleResolver implements RoleResolverInterface
private function resolveRoleForAuthority(string $domainAuthority): RoleDefinition
{
if ($domainAuthority === $this->defaultDomain) {
if ($domainAuthority === $this->urlShortenerOptions->defaultDomain) {
throw InvalidRoleConfigException::forDomainOnlyWithDefaultDomain();
}

View File

@@ -0,0 +1,68 @@
<?php
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;
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\Config\formatEnvVarValue;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
use function sprintf;
class ReadEnvVarCommand extends Command
{
public const NAME = 'env-var:read';
/** @var Closure(string $envVar): mixed */
private readonly Closure $loadEnvVar;
public function __construct(?Closure $loadEnvVar = null)
{
$this->loadEnvVar = $loadEnvVar ?? static fn (string $envVar) => EnvVars::from($envVar)->loadFromEnv();
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setHidden()
->setDescription('Display current value for an env var')
->addArgument('envVar', InputArgument::REQUIRED, 'The env var to read');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$envVar = $input->getArgument('envVar');
$validEnvVars = enumValues(EnvVars::class);
if ($envVar === null) {
$envVar = $io->choice('Select the env var to read', $validEnvVars);
}
if (! contains($envVar, $validEnvVars)) {
throw new InvalidArgumentException(sprintf('%s is not a valid Shlink environment variable', $envVar));
}
$input->setArgument('envVar', $envVar);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$envVar = $input->getArgument('envVar');
$output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
return ExitCode::EXIT_SUCCESS;
}
}

View File

@@ -17,7 +17,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
public function __construct(
LockFactory $locker,
private ProcessRunnerInterface $processRunner,
private readonly ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
) {
parent::__construct($locker);

View File

@@ -33,6 +33,9 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$domain = $input->getArgument('domain');

View File

@@ -4,24 +4,18 @@ 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\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
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 array_map;
use function array_unique;
use function explode;
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function sprintf;
class CreateShortUrlCommand extends Command
@@ -29,6 +23,7 @@ class CreateShortUrlCommand extends Command
public const NAME = 'short-url:create';
private ?SymfonyStyle $io;
private readonly ShortUrlDataInput $shortUrlDataInput;
public function __construct(
private readonly UrlShortenerInterface $urlShortener,
@@ -36,6 +31,7 @@ class CreateShortUrlCommand extends Command
private readonly UrlShortenerOptions $options,
) {
parent::__construct();
$this->shortUrlDataInput = new ShortUrlDataInput($this);
}
protected function configure(): void
@@ -43,26 +39,11 @@ class CreateShortUrlCommand extends Command
$this
->setName(self::NAME)
->setDescription('Generates a short URL for provided long URL and returns it')
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse')
->addOption(
'tags',
't',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Tags to apply to the new short URL',
)
->addOption(
'valid-since',
's',
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.',
)
->addOption(
'valid-until',
'u',
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.',
'The domain to which this short URL will be attached.',
)
->addOption(
'custom-slug',
@@ -70,30 +51,6 @@ class CreateShortUrlCommand extends Command
InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code',
)
->addOption(
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption(
'max-visits',
'm',
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.',
)
->addOption(
'find-if-exists',
'f',
InputOption::VALUE_NONE,
'This will force existing matching URL to be returned if found, instead of creating a new one.',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The domain to which this short URL will be attached.',
)
->addOption(
'short-code-length',
'l',
@@ -101,16 +58,16 @@ class CreateShortUrlCommand extends Command
'The length for generated short code (it will be ignored if --custom-slug was provided).',
)
->addOption(
'crawlable',
'r',
InputOption::VALUE_NONE,
'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.',
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption(
'no-forward-query',
'w',
'find-if-exists',
'f',
InputOption::VALUE_NONE,
'Disables the forwarding of the query string to the long URL, when the new short URL is visited.',
'This will force existing matching URL to be returned if found, instead of creating a new one.',
);
}
@@ -136,32 +93,17 @@ class CreateShortUrlCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = $this->getIO($input, $output);
$longUrl = $input->getArgument('longUrl');
if (empty($longUrl)) {
$io->error('A URL was not provided!');
return ExitCode::EXIT_FAILURE;
}
$explodeWithComma = static fn (string $tag) => explode(',', $tag);
$tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
try {
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'),
ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
], $this->options));
$result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation(
$input,
$this->options,
customSlugField: 'custom-slug',
shortCodeLengthField: 'short-code-length',
pathPrefixField: 'path-prefix',
findIfExistsField: 'find-if-exists',
domainField: 'domain',
));
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
'Short URL properly created, but the real-time updates cannot be notified when generating the '
@@ -169,7 +111,7 @@ class CreateShortUrlCommand extends Command
));
$io->writeln([
sprintf('Processed long URL: <info>%s</info>', $longUrl),
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;
@@ -181,6 +123,6 @@ class CreateShortUrlCommand extends Command
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
{
return $this->io ?? ($this->io = new SymfonyStyle($input, $output));
return $this->io ??= new SymfonyStyle($input, $output);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class EditShortUrlCommand extends Command
{
public const NAME = 'short-url:edit';
private readonly ShortUrlDataInput $shortUrlDataInput;
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(
private readonly ShortUrlServiceInterface $shortUrlService,
private readonly ShortUrlStringifierInterface $stringifier,
) {
parent::__construct();
$this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true);
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code to edit',
domainDesc: 'The domain to which the short URL is attached.',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Edit an existing short URL');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
try {
$shortUrl = $this->shortUrlService->updateShortUrl(
$identifier,
$this->shortUrlDataInput->toShortUrlEdition($input),
);
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));
return ExitCode::EXIT_SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error(sprintf('Short URL not found for "%s"', $identifier->__toString()));
if ($io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $io);
}
return ExitCode::EXIT_FAILURE;
}
}
}

View File

@@ -46,6 +46,9 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
}
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);

View File

@@ -9,14 +9,14 @@ 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\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -32,8 +32,6 @@ use function sprintf;
class ListShortUrlsCommand extends Command
{
use PagerfantaUtilsTrait;
public const NAME = 'short-url:list';
private readonly StartDateOption $startDateOption;
@@ -41,7 +39,7 @@ class ListShortUrlsCommand extends Command
public function __construct(
private readonly ShortUrlListServiceInterface $shortUrlService,
private readonly DataTransformerInterface $transformer,
private readonly ShortUrlDataTransformerInterface $transformer,
) {
parent::__construct();
$this->startDateOption = new StartDateOption($this, 'short URLs');
@@ -179,6 +177,7 @@ class ListShortUrlsCommand extends Command
/**
* @param array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string> $columnsMap
* @return Paginator<ShortUrlWithVisitsSummary>
*/
private function renderPage(
OutputInterface $output,
@@ -196,7 +195,7 @@ class ListShortUrlsCommand extends Command
ShlinkTable::default($output)->render(
array_keys($columnsMap),
$rows,
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
$all ? null : PagerfantaUtils::formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
);
return $shortUrls;

View File

@@ -33,6 +33,9 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');

View File

@@ -46,6 +46,9 @@ abstract class AbstractVisitsListCommand extends Command
return ExitCode::EXIT_SUCCESS;
}
/**
* @param Paginator<Visit> $paginator
*/
private function resolveRowsAndHeaders(Paginator $paginator): array
{
$extraKeys = [];
@@ -74,6 +77,9 @@ abstract class AbstractVisitsListCommand extends Command
];
}
/**
* @return Paginator<Visit>
*/
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
/**

View File

@@ -30,6 +30,9 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
->setDescription('Returns the list of non-orphan visits.');
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));

View File

@@ -30,6 +30,9 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
));
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$rawType = $input->getOption('type');

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Factory;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Config\Options\AppOptions;
use Symfony\Component\Console\Application as CliApp;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;

View File

@@ -9,7 +9,7 @@ use Closure;
use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
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;

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use function array_map;
use function array_unique;
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function Shlinkio\Shlink\Core\splitByComma;
readonly final class ShortUrlDataInput
{
public function __construct(Command $command, private bool $longUrlAsOption = false)
{
if ($longUrlAsOption) {
$command->addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set');
} else {
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
}
$command
->addOption(
ShortUrlDataOption::TAGS->value,
ShortUrlDataOption::TAGS->shortcut(),
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Tags to apply to the short URL',
)
->addOption(
ShortUrlDataOption::VALID_SINCE->value,
ShortUrlDataOption::VALID_SINCE->shortcut(),
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.',
)
->addOption(
ShortUrlDataOption::VALID_UNTIL->value,
ShortUrlDataOption::VALID_UNTIL->shortcut(),
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.',
)
->addOption(
ShortUrlDataOption::MAX_VISITS->value,
ShortUrlDataOption::MAX_VISITS->shortcut(),
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.',
)
->addOption(
ShortUrlDataOption::TITLE->value,
ShortUrlDataOption::TITLE->shortcut(),
InputOption::VALUE_REQUIRED,
'A descriptive title for the short URL.',
)
->addOption(
ShortUrlDataOption::CRAWLABLE->value,
ShortUrlDataOption::CRAWLABLE->shortcut(),
InputOption::VALUE_NONE,
'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.',
)
->addOption(
ShortUrlDataOption::NO_FORWARD_QUERY->value,
ShortUrlDataOption::NO_FORWARD_QUERY->shortcut(),
InputOption::VALUE_NONE,
'Disables the forwarding of the query string to the long URL, when the short URL is visited.',
);
}
public function toShortUrlEdition(InputInterface $input): ShortUrlEdition
{
return ShortUrlEdition::fromRawData($this->getCommonData($input));
}
public function toShortUrlCreation(
InputInterface $input,
UrlShortenerOptions $options,
string $customSlugField,
string $shortCodeLengthField,
string $pathPrefixField,
string $findIfExistsField,
string $domainField,
): ShortUrlCreation {
$shortCodeLength = $input->getOption($shortCodeLengthField) ?? $options->defaultShortCodesLength;
return ShortUrlCreation::fromRawData([
...$this->getCommonData($input),
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption($customSlugField),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::PATH_PREFIX => $input->getOption($pathPrefixField),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption($findIfExistsField),
ShortUrlInputFilter::DOMAIN => $input->getOption($domainField),
], $options);
}
private function getCommonData(InputInterface $input): array
{
$longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl');
$data = [ShortUrlInputFilter::LONG_URL => $longUrl];
// Avoid setting arguments that were not explicitly provided.
// This is important when editing short URLs and should not make a difference when creating.
if (ShortUrlDataOption::VALID_SINCE->wasProvided($input)) {
$data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since');
}
if (ShortUrlDataOption::VALID_UNTIL->wasProvided($input)) {
$data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until');
}
if (ShortUrlDataOption::MAX_VISITS->wasProvided($input)) {
$maxVisits = $input->getOption('max-visits');
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
}
if (ShortUrlDataOption::TAGS->wasProvided($input)) {
$tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
$data[ShortUrlInputFilter::TAGS] = $tags;
}
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
}
if (ShortUrlDataOption::CRAWLABLE->wasProvided($input)) {
$data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable');
}
if (ShortUrlDataOption::NO_FORWARD_QUERY->wasProvided($input)) {
$data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query');
}
return $data;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
enum ShortUrlDataOption: string
{
case TAGS = 'tags';
case VALID_SINCE = 'valid-since';
case VALID_UNTIL = 'valid-until';
case MAX_VISITS = 'max-visits';
case TITLE = 'title';
case CRAWLABLE = 'crawlable';
case NO_FORWARD_QUERY = 'no-forward-query';
public function shortcut(): ?string
{
return match ($this) {
self::TAGS => 't',
self::VALID_SINCE => 's',
self::VALID_UNTIL => 'u',
self::MAX_VISITS => 'm',
self::TITLE => null,
self::CRAWLABLE => 'r',
self::NO_FORWARD_QUERY => 'w',
};
}
public function wasProvided(InputInterface $input): bool
{
$option = sprintf('--%s', $this->value);
$shortcut = $this->shortcut();
return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]);
}
}

View File

@@ -108,6 +108,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
$this->askMandatory('Query param name?', $io),
$this->askOptional('Query param value?', $io),
),
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
),
};
$continue = $io->confirm('Do you want to add another condition?');

View File

@@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
@@ -24,7 +25,7 @@ class RoleResolverTest extends TestCase
protected function setUp(): void
{
$this->domainService = $this->createMock(DomainServiceInterface::class);
$this->resolver = new RoleResolver($this->domainService, 'default.com');
$this->resolver = new RoleResolver($this->domainService, new UrlShortenerOptions('default.com'));
}
#[Test, DataProvider('provideRoles')]

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Config;
use Monolog\Test\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\Config\ReadEnvVarCommand;
use Shlinkio\Shlink\Core\Config\EnvVars;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Tester\CommandTester;
class ReadEnvVarCommandTest extends TestCase
{
private CommandTester $commandTester;
private string $envVarValue = 'the_env_var_value';
protected function setUp(): void
{
$this->commandTester = CliTestUtils::testerForCommand(new ReadEnvVarCommand(fn () => $this->envVarValue));
}
#[Test]
public function errorIsThrownIfProvidedEnvVarIsInvalid(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('foo is not a valid Shlink environment variable');
$this->commandTester->execute(['envVar' => 'foo']);
}
#[Test]
public function valueIsPrintedIfProvidedEnvVarIsValid(): void
{
$this->commandTester->execute(['envVar' => EnvVars::BASE_PATH->value]);
$output = $this->commandTester->getDisplay();
self::assertStringNotContainsString('Select the env var to read', $output);
self::assertStringContainsString($this->envVarValue, $output);
}
#[Test]
public function envVarNameIsRequestedIfArgumentIsMissing(): void
{
$this->commandTester->setInputs([EnvVars::BASE_PATH->value]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Select the env var to read', $output);
self::assertStringContainsString($this->envVarValue, $output);
}
}

View File

@@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
@@ -31,6 +32,7 @@ class CreateDatabaseCommandTest extends TestCase
private MockObject & ProcessRunnerInterface $processHelper;
private MockObject & Connection $regularConn;
private MockObject & ClassMetadataFactory $metadataFactory;
/** @var MockObject&AbstractSchemaManager<SQLitePlatform> */
private MockObject & AbstractSchemaManager $schemaManager;
private MockObject & Driver $driver;

View File

@@ -10,10 +10,10 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand;
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 Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;

View File

@@ -11,10 +11,10 @@ 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 Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;

View File

@@ -12,8 +12,8 @@ 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\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
@@ -37,10 +37,7 @@ class CreateShortUrlCommandTest extends TestCase
$command = new CreateShortUrlCommand(
$this->urlShortener,
$this->stringifier,
new UrlShortenerOptions(
domain: ['hostname' => 'example.com', 'schema' => ''],
defaultShortCodesLength: 5,
),
new UrlShortenerOptions(defaultDomain: 'example.com', defaultShortCodesLength: 5),
);
$this->commandTester = CliTestUtils::testerForCommand($command);
}

View File

@@ -0,0 +1,74 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Attributes\Test;
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\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class EditShortUrlCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & ShortUrlServiceInterface $shortUrlService;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->shortUrlService = $this->createMock(ShortUrlServiceInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$command = new EditShortUrlCommand($this->shortUrlService, $this->stringifier);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
#[Test]
public function successMessageIsPrintedIfNoErrorOccurs(): void
{
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(
ShortUrl::createFake(),
);
$this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo');
$this->commandTester->execute(['shortCode' => 'foobar']);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString('Short URL "https://s.test/foo" properly edited', $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}
#[Test]
#[TestWith([OutputInterface::VERBOSITY_NORMAL])]
#[TestWith([OutputInterface::VERBOSITY_VERBOSE])]
#[TestWith([OutputInterface::VERBOSITY_VERY_VERBOSE])]
#[TestWith([OutputInterface::VERBOSITY_DEBUG])]
public function errorIsPrintedInCaseOfFailure(int $verbosity): void
{
$e = ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('foo'));
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e);
$this->stringifier->expects($this->never())->method('stringify');
$this->commandTester->execute(['shortCode' => 'foo'], ['verbosity' => $verbosity]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString('Short URL not found for "foo"', $output);
if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) {
self::assertStringContainsString('Exception trace:', $output);
} else {
self::assertStringNotContainsString('Exception trace:', $output);
}
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
}
}

View File

@@ -37,7 +37,7 @@ class ListShortUrlsCommandTest extends TestCase
{
$this->shortUrlService = $this->createMock(ShortUrlListServiceInterface::class);
$command = new ListShortUrlsCommand($this->shortUrlService, new ShortUrlDataTransformer(
new ShortUrlStringifier([]),
new ShortUrlStringifier(),
));
$this->commandTester = CliTestUtils::testerForCommand($command);
}

View File

@@ -8,7 +8,7 @@ use Laminas\ServiceManager\ServiceManager;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Config\Options\AppOptions;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
class ApplicationFactoryTest extends TestCase

View File

@@ -14,7 +14,7 @@ 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\Options\TrackingOptions;
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;

View File

@@ -116,6 +116,7 @@ class RedirectRuleHandlerTest extends TestCase
'Language to match?' => 'en-US',
'Query param name?' => 'foo',
'Query param value?' => 'bar',
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
default => '',
},
);
@@ -163,6 +164,7 @@ class RedirectRuleHandlerTest extends TestCase
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
true,
];
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
}
#[Test]

View File

@@ -8,8 +8,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory;
use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Config\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
@@ -24,14 +23,15 @@ return [
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
Options\AppOptions::class => [ValinorConfigFactory::class, 'config.app_options'],
Options\DeleteShortUrlsOptions::class => [ValinorConfigFactory::class, 'config.delete_short_urls'],
Options\NotFoundRedirectOptions::class => [ValinorConfigFactory::class, 'config.not_found_redirects'],
Options\RedirectOptions::class => [ValinorConfigFactory::class, 'config.redirects'],
Options\UrlShortenerOptions::class => [ValinorConfigFactory::class, 'config.url_shortener'],
Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'],
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
Config\Options\AppOptions::class => [Config\Options\AppOptions::class, 'fromEnv'],
Config\Options\DeleteShortUrlsOptions::class => [Config\Options\DeleteShortUrlsOptions::class, 'fromEnv'],
Config\Options\NotFoundRedirectOptions::class => [Config\Options\NotFoundRedirectOptions::class, 'fromEnv'],
Config\Options\RedirectOptions::class => [Config\Options\RedirectOptions::class, 'fromEnv'],
Config\Options\UrlShortenerOptions::class => [Config\Options\UrlShortenerOptions::class, 'fromEnv'],
Config\Options\TrackingOptions::class => [Config\Options\TrackingOptions::class, 'fromEnv'],
Config\Options\QrCodeOptions::class => [Config\Options\QrCodeOptions::class, 'fromEnv'],
Config\Options\RabbitMqOptions::class => [Config\Options\RabbitMqOptions::class, 'fromEnv'],
Config\Options\RobotsOptions::class => [Config\Options\RobotsOptions::class, 'fromEnv'],
RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class,
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
@@ -100,7 +100,7 @@ return [
Crawling\CrawlingHelper::class => ConfigAbstractFactory::class,
Matomo\MatomoOptions::class => [ValinorConfigFactory::class, 'config.matomo'],
Matomo\MatomoOptions::class => [Matomo\MatomoOptions::class, 'fromEnv'],
Matomo\MatomoTrackerBuilder::class => ConfigAbstractFactory::class,
Matomo\MatomoVisitSender::class => ConfigAbstractFactory::class,
],
@@ -136,9 +136,9 @@ return [
Visit\VisitsTracker::class => [
'em',
EventDispatcherInterface::class,
Options\TrackingOptions::class,
Config\Options\TrackingOptions::class,
],
Visit\RequestTracker::class => [Visit\VisitsTracker::class, Options\TrackingOptions::class],
Visit\RequestTracker::class => [Visit\VisitsTracker::class, Config\Options\TrackingOptions::class],
Visit\VisitsDeleter::class => [Visit\Repository\VisitDeleterRepository::class],
ShortUrl\ShortUrlService::class => [
'em',
@@ -148,7 +148,7 @@ return [
],
ShortUrl\ShortUrlListService::class => [
ShortUrl\Repository\ShortUrlListRepository::class,
Options\UrlShortenerOptions::class,
Config\Options\UrlShortenerOptions::class,
],
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class],
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
@@ -156,20 +156,20 @@ return [
Tag\TagService::class => ['em'],
ShortUrl\DeleteShortUrlService::class => [
'em',
Options\DeleteShortUrlsOptions::class,
Config\Options\DeleteShortUrlsOptions::class,
ShortUrl\ShortUrlResolver::class,
ShortUrl\Repository\ExpiredShortUrlsRepository::class,
],
ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class],
ShortUrl\ShortUrlResolver::class => ['em', Config\Options\UrlShortenerOptions::class],
ShortUrl\ShortUrlVisitsDeleter::class => [
Visit\Repository\VisitDeleterRepository::class,
ShortUrl\ShortUrlResolver::class,
],
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class],
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Config\Options\UrlShortenerOptions::class],
Domain\DomainService::class => ['em', Config\Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],
Util\RedirectResponseHelper::class => [Options\RedirectOptions::class],
Util\RedirectResponseHelper::class => [Config\Options\RedirectOptions::class],
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
@@ -187,19 +187,25 @@ return [
ShortUrl\ShortUrlResolver::class,
ShortUrl\Helper\ShortUrlStringifier::class,
'Logger_Shlink',
Options\QrCodeOptions::class,
Config\Options\QrCodeOptions::class,
],
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
Action\RobotsAction::class => [Crawling\CrawlingHelper::class, Config\Options\RobotsOptions::class],
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [
'em',
Options\UrlShortenerOptions::class,
Config\Options\UrlShortenerOptions::class,
Lock\LockFactory::class,
],
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ['httpClient', Options\UrlShortenerOptions::class],
ShortUrl\Helper\ShortUrlStringifier::class => [
Config\Options\UrlShortenerOptions::class,
'config.router.base_path',
],
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [
'httpClient',
Config\Options\UrlShortenerOptions::class,
],
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [
Options\TrackingOptions::class,
Config\Options\TrackingOptions::class,
RedirectRule\ShortUrlRedirectionResolver::class,
],
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
@@ -208,9 +214,9 @@ return [
Visit\RequestTracker::class,
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
Util\RedirectResponseHelper::class,
Options\UrlShortenerOptions::class,
Config\Options\UrlShortenerOptions::class,
],
ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => [Options\UrlShortenerOptions::class],
ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => [Config\Options\UrlShortenerOptions::class],
EventDispatcher\PublishingUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class],

View File

@@ -129,14 +129,14 @@ return (static function (): array {
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Options\RabbitMqOptions::class,
Config\Options\RabbitMqOptions::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Options\RabbitMqOptions::class,
Config\Options\RabbitMqOptions::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
RedisPublishingHelper::class,
@@ -167,7 +167,7 @@ return (static function (): array {
],
EventDispatcher\Helper\EnabledListenerChecker::class => [
Options\RabbitMqOptions::class,
Config\Options\RabbitMqOptions::class,
'config.redis.pub_sub_enabled',
MercureOptions::class,
GeoLite2Options::class,

View File

@@ -14,6 +14,8 @@ use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\Filter\Word\CamelCaseToSeparator;
use Laminas\Filter\Word\CamelCaseToUnderscore;
use Laminas\InputFilter\InputFilter;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
@@ -107,7 +109,6 @@ function normalizeLocale(string $locale): string
* minimum quality
*
* @param non-empty-string $acceptLanguage
* @param float<0, 1> $minQuality
* @return iterable<string>;
*/
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
@@ -140,21 +141,31 @@ function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0):
*/
function splitLocale(string $locale): array
{
return array_pad(explode('-', $locale), length: 2, value: null);
[$lang, $countryCode] = array_pad(explode('-', $locale), length: 2, value: null);
return [$lang, $countryCode];
}
/**
* @param InputFilter<mixed> $inputFilter
*/
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (int) $value : null;
}
/**
* @param InputFilter<mixed> $inputFilter
*/
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (bool) $value : null;
}
/**
* @param InputFilter<mixed> $inputFilter
*/
function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, string $fieldName): mixed
{
$value = $inputFilter->getValue($fieldName);
@@ -260,3 +271,21 @@ function enumToString(string $enum): string
{
return sprintf('["%s"]', implode('", "', enumValues($enum)));
}
/**
* Split provided string by comma and return a list of the results.
* An empty array is returned if provided value is empty
*/
function splitByComma(?string $value): array
{
if ($value === null || trim($value) === '') {
return [];
}
return array_map(trim(...), explode(',', $value));
}
function ipAddressFromRequest(ServerRequestInterface $request): ?string
{
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
}

View File

@@ -12,7 +12,7 @@ use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\Writer\SvgWriter;
use Endroid\QrCode\Writer\WriterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Options\QrCodeOptions;
use Shlinkio\Shlink\Core\Config\Options\QrCodeOptions;
use function ctype_xdigit;
use function hexdec;

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\Result\ResultInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
@@ -12,8 +13,8 @@ use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
use Shlinkio\Shlink\Core\Config\Options\QrCodeOptions;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Options\QrCodeOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
@@ -42,22 +43,30 @@ readonly class QrCodeAction implements MiddlewareInterface
}
$params = QrCodeParams::fromRequest($request, $this->options);
$qrCodeBuilder = Builder::create()
->data($this->stringifier->stringify($shortUrl))
->size($params->size)
->margin($params->margin)
->writer($params->writer)
->errorCorrectionLevel($params->errorCorrectionLevel)
->roundBlockSizeMode($params->roundBlockSizeMode)
->foregroundColor($params->color)
->backgroundColor($params->bgColor);
$qrCodeBuilder = new Builder(
writer: $params->writer,
data: $this->stringifier->stringify($shortUrl),
errorCorrectionLevel: $params->errorCorrectionLevel,
size: $params->size,
margin: $params->margin,
roundBlockSizeMode: $params->roundBlockSizeMode,
foregroundColor: $params->color,
backgroundColor: $params->bgColor,
);
return new QrCodeResponse($this->buildQrCode($qrCodeBuilder, $params));
}
private function buildQrCode(Builder $qrCodeBuilder, QrCodeParams $params): ResultInterface
{
$logoUrl = $this->options->logoUrl;
if ($logoUrl !== null) {
$qrCodeBuilder->logoPath($logoUrl)
->logoResizeToHeight((int) ($params->size / 4));
if ($logoUrl === null) {
return $qrCodeBuilder->build();
}
return new QrCodeResponse($qrCodeBuilder->build());
return $qrCodeBuilder->build(
logoPath: $logoUrl,
logoResizeToHeight: (int) ($params->size / 4),
);
}
}

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