Compare commits

..

92 Commits

Author SHA1 Message Date
Alejandro Celaya
4a3fa85b5f Merge pull request #1234 from acelaya-forks/feature/sql-injection
Enforced doctrine/dbal 3.1.4
2021-11-15 19:53:01 +01:00
Alejandro Celaya
ade23a9650 Enforced doctrine/dbal 3.1.4 2021-11-15 19:41:38 +01:00
Alejandro Celaya
dc8f5d002d Merge pull request #1215 from shlinkio/develop
Release 2.9.2
2021-10-23 16:52:47 +02:00
Alejandro Celaya
9030e5e6eb Merge pull request #1214 from acelaya-forks/feature/min-task-workers
Feature/min task workers
2021-10-23 16:47:18 +02:00
Alejandro Celaya
2b827baeed Updated changelog 2021-10-23 16:35:38 +02:00
Alejandro Celaya
cc6fa312f0 Ensured minimum amount of task workers provided via config option or env var is 4 2021-10-23 16:32:06 +02:00
Alejandro Celaya
b8eba5b643 Merge pull request #1213 from acelaya-forks/feature/migrations-3.3
Feature/migrations 3.3
2021-10-23 16:18:39 +02:00
Alejandro Celaya
0c3f98cc37 Replaced implicit false in migration by a check on the platform 2021-10-23 16:04:54 +02:00
Alejandro Celaya
cd35770d26 Ensured migrations are not transactional when run in mysql 2021-10-23 16:02:29 +02:00
Alejandro Celaya
bd3a59e9ca Updated to doctrine-migrations 3.3 2021-10-23 15:44:56 +02:00
Alejandro Celaya
ff50d601b3 Merge pull request #1212 from acelaya-forks/feature/wrong-transactionality
Removed transactionality when dispatching async events
2021-10-23 13:49:09 +02:00
Alejandro Celaya
a4fde0f9e6 Changed mechanism to determine if connection to database worked for health endpoint 2021-10-23 13:36:27 +02:00
Alejandro Celaya
c7a621cb31 Removed transactionality when dispatching async events, as they run in different processes with different db connections 2021-10-23 13:22:42 +02:00
Alejandro Celaya
6f62d62909 Merge pull request #1203 from shlinkio/develop
Release 2.9.1
2021-10-11 09:03:16 +02:00
Alejandro Celaya
c3aa2df4e9 Merge pull request #1202 from acelaya-forks/feature/fix-use-https
Fixed crash when trying to resolve schema based on USE_HTTPS env var
2021-10-11 09:01:38 +02:00
Alejandro Celaya
f4fbf2da75 Tagged version in changelog 2021-10-11 08:47:41 +02:00
Alejandro Celaya
288de8acaa Fixed crash when trying to resolve schema based on USE_HTTPS env var 2021-10-11 08:46:40 +02:00
Alejandro Celaya
750e6cff45 Merge pull request #1200 from shlinkio/develop
Release 2.9.0
2021-10-10 22:45:17 +02:00
Alejandro Celaya
f49e94052d Merge pull request #1199 from acelaya-forks/feature/address-based-tracking
Feature/address based tracking
2021-10-10 22:42:27 +02:00
Alejandro Celaya
ceb642b745 Updated to latest installer and changelog 2021-10-10 22:31:26 +02:00
Alejandro Celaya
ed1d886f01 Added option to disable tracking based on IP address patterns 2021-10-10 22:00:22 +02:00
Alejandro Celaya
db98d811b0 Merge pull request #1198 from acelaya-forks/feature/orphan-visits-webhook
Feature/orphan visits webhook
2021-10-09 13:08:05 +02:00
Alejandro Celaya
14ba11e1ab Enhanced changelog 2021-10-09 12:36:37 +02:00
Alejandro Celaya
483bdddb18 Updated to installer version with support for orphan visits webhooks 2021-10-09 12:35:45 +02:00
Alejandro Celaya
d16fda3f16 Added option to send orphan visits to webhooks 2021-10-09 10:53:21 +02:00
Alejandro Celaya
c718b94937 Fixed crash when notifying orphan visits to a webhook 2021-10-09 10:35:37 +02:00
Alejandro Celaya
bb21ab073f Merge pull request #1196 from acelaya-forks/feature/redis-sentinels
Feature/redis sentinels
2021-10-08 19:05:17 +02:00
Alejandro Celaya
3ffe530461 Updated changelog 2021-10-08 18:52:53 +02:00
Alejandro Celaya
95cf0d86bc Added support to provide redis sentinel when using redis cache 2021-10-08 18:52:17 +02:00
Alejandro Celaya
9899a5fc56 Merge pull request #1195 from acelaya-forks/feature/not-found-redirect-placeholders
Feature/not found redirect placeholders
2021-10-03 17:04:17 +02:00
Alejandro Celaya
952648185c Removed duplicated space 2021-10-03 16:48:39 +02:00
Alejandro Celaya
69740493b7 Updated changelog 2021-10-03 16:47:43 +02:00
Alejandro Celaya
994a28f31d Ensured NotFoundRedirectResolver replaces placeholders from the URL 2021-10-03 16:45:13 +02:00
Alejandro Celaya
b0a8a03f0a Refactored NotFoundRedirectResolver to remove duplicated lines and non-strict code 2021-10-03 10:35:35 +02:00
Alejandro Celaya
36e740f4cc Added logic to forward path and domain to not-found redirects when they contain placeholders 2021-10-02 17:30:25 +02:00
Alejandro Celaya
a5874a3f80 Merge pull request #1194 from acelaya-forks/feature/optinally-forward-query
Feature/optinally forward query
2021-10-02 10:56:48 +02:00
Alejandro Celaya
0c95b978b4 Added option in CLI to disable query forwarding when creating Short URLs 2021-10-02 10:45:00 +02:00
Alejandro Celaya
e21f9dd1fb Added forwardQuery prop to the SHortUrl serialization 2021-10-02 10:31:23 +02:00
Alejandro Celaya
74a08b86ce Estended ShortUrlRedirectionBuilderTest covering short URLS withput query forwarding 2021-10-02 10:16:56 +02:00
Alejandro Celaya
8212d3c540 Allowed to set and update the forwardQuery param on short URLs 2021-10-02 10:02:47 +02:00
Alejandro Celaya
1ed6458b39 Added forwardQuery property in short URLs, that determines if the query should be forwarded to the long URL 2021-10-02 09:32:04 +02:00
Alejandro Celaya
60c8f23a63 Merge pull request #1193 from acelaya-forks/feature/api-key-visits
Added extra DB tests ensuring proper short URL visits are resolved fr…
2021-10-01 19:59:30 +02:00
Alejandro Celaya
5e627641ea Added more tests ensuring any short URL can be fetched by using an admin API key 2021-10-01 19:32:34 +02:00
Alejandro Celaya
abc954aa47 Added extra DB tests ensuring proper short URL visits are resolved from an API key 2021-09-30 22:57:24 +02:00
Alejandro Celaya
3bfa27e682 Merge pull request #1191 from acelaya-forks/feature/default-qr-codes-config
Feature/default qr codes config
2021-09-26 20:39:09 +02:00
Alejandro Celaya
4b7e122254 Updated changelog 2021-09-26 20:15:00 +02:00
Alejandro Celaya
cfd3c13751 Updated to latest installer 2021-09-26 20:13:50 +02:00
Alejandro Celaya
6a1ee2b894 Added new config to set custom defaults for QR codes 2021-09-26 13:25:02 +02:00
Alejandro Celaya
cbec4a4e81 Moved constants to its own file inside config folder 2021-09-26 11:26:26 +02:00
Alejandro Celaya
c7d8c1cab5 Merge pull request #1189 from acelaya-forks/feature/roll-back-domain-redirects-logic
Reolled-back logic that would have made domains with no specific redi…
2021-09-26 11:22:58 +02:00
Alejandro Celaya
c39e1e649d Reolled-back logic that would have made domains with no specific redirects to not fall back to the default redirects 2021-09-26 11:10:00 +02:00
Alejandro Celaya
95ab64ba77 Merge pull request #1187 from acelaya-forks/feature/build-8.1
Feature/build 8.1
2021-09-26 10:43:55 +02:00
Alejandro Celaya
1f8fcdb0f3 Fixed typo in ci workflow 2021-09-26 10:20:09 +02:00
Alejandro Celaya
fb26a8ae50 Downgraded pdo_sqlsrv version for PHP 8.0 2021-09-26 10:19:26 +02:00
Alejandro Celaya
42dbeaa1a5 Updated MS native deps in swoole dev container 2021-09-26 10:06:35 +02:00
Alejandro Celaya
3305f4c03a Updated pdo_sqlsrv version used in CI workflow 2021-09-26 10:04:50 +02:00
Alejandro Celaya
f5beec70c8 Updated MS native deps 2021-09-26 10:03:07 +02:00
Alejandro Celaya
c2cd21c15e Updated swoole version used in CI workflow 2021-09-26 09:53:58 +02:00
Alejandro Celaya
633e389275 Updated changelog 2021-09-26 09:50:35 +02:00
Alejandro Celaya
f5aaf298e1 Added experimental builds under PHP 8.1 2021-09-26 09:49:51 +02:00
Alejandro Celaya
7db6136436 Simplified how the not-found redirects are resolved 2021-09-26 09:40:24 +02:00
Alejandro Celaya
ce7296eebb Merge pull request #1186 from acelaya-forks/feature/deprecate-domain-env-vars
Feature/deprecate domain env vars
2021-09-26 09:23:01 +02:00
Alejandro Celaya
c6226547f7 Updated changelog 2021-09-26 09:12:26 +02:00
Alejandro Celaya
e7ec8f0489 Deprecated SHORT_DOMAIN_* env vars with replacements 2021-09-26 09:10:54 +02:00
Alejandro Celaya
dc466f238b Updated changelog 2021-09-12 08:32:24 +02:00
Alejandro Celaya
f164656874 Merge pull request #1172 from NReilingh/patch-1
Slight misuse of VOLUME in Dockerfile
2021-09-12 08:30:28 +02:00
Nick Reilingh
ef3c59152f Dockerfile -- remove unneeded VOLUME instructions 2021-09-11 16:40:09 -04:00
Nick Reilingh
14c6ead389 Dockerfile - comment misused VOLUME instructions
Issuing a VOLUME instruction in a production Dockerfile requires the Docker engine to create a volume whether or not it is mapped to the host or a named volume. Neither of these paths have data that needs to be persisted for production use, so their inclusion under a typical `docker run` example forces the engine to create extraneous volumes which quickly become orphaned whenever the container is recreated.
2021-09-11 13:46:52 -04:00
Alejandro Celaya
b0d33f3a85 Merge pull request #1166 from acelaya-forks/feature/fix-undefined-var
Feature/fix undefined var
2021-08-26 10:06:33 +02:00
Alejandro Celaya
066cc20ee6 Updated changelog 2021-08-26 09:53:10 +02:00
Alejandro Celaya
0f51b5b1ce Fixed warning displayed when trying to late visits and there are no pending 2021-08-26 09:52:11 +02:00
Alejandro Celaya
ebcf3e0119 Merge pull request #1158 from acelaya-forks/feature/global-cors
Feature/global cors
2021-08-16 13:02:18 +02:00
Alejandro Celaya
6ee248d656 Updated changelog 2021-08-16 12:50:18 +02:00
Alejandro Celaya
8a46b410f6 Ensured Cors middleware applies for all routes, not only rest ones 2021-08-16 12:49:15 +02:00
Alejandro Celaya
cd06cea153 Fixed merge conflicts 2021-08-15 19:32:27 +02:00
Alejandro Celaya
8393d44c50 Merge pull request #1156 from acelaya-forks/feature/query-num-keys
Fixed numeric query params being replaced by 0 in long URLs
2021-08-15 19:25:13 +02:00
Alejandro Celaya
3e8ce80f80 Fixed numeric query params being replaced by 0 in long URLs 2021-08-15 19:13:26 +02:00
Alejandro Celaya
80e033c91d Fixed local dev config for db 2021-08-14 19:23:08 +02:00
Alejandro Celaya
a7dd441333 Added missing double quote. Closes #1151 2021-08-09 22:16:12 +02:00
Alejandro Celaya
48efaa9fd7 Merge pull request #1150 from acelaya-forks/feature/env-config
Feature/env config
2021-08-07 14:13:26 +02:00
Alejandro Celaya
92e831175f Ensure no DB driver config falls back to SQLite 2021-08-07 13:32:59 +02:00
Alejandro Celaya
9b75e076b5 Updated changelog 2021-08-07 11:08:52 +02:00
Alejandro Celaya
2c5d6d1651 Moved env vars to common global config files, so that theycan be used in non-docker contexts too 2021-08-07 11:05:20 +02:00
Alejandro Celaya
c5cf116f33 Fixed changelog message 2021-08-06 19:59:35 +02:00
Alejandro Celaya
66a4a9bce6 Moved bugfix from Unreleased to v2.8.0, as it's already fixed there 2021-08-06 13:57:39 +02:00
Alejandro Celaya
7e7ef64c79 Merge pull request #1146 from acelaya-forks/feature/coding-standard
Updated to coding standard v2.2.0
2021-08-05 19:58:34 +02:00
Alejandro Celaya
9a31f53d4d Updated to coding standard v2.2.0 2021-08-05 19:47:17 +02:00
Alejandro Celaya
60d6314262 Merge pull request #1145 from acelaya-forks/feature/update-cache
Feature/update cache
2021-08-05 17:07:41 +02:00
Alejandro Celaya
eff7445804 Updated changelog 2021-08-05 16:50:50 +02:00
Alejandro Celaya
2bfe21aef4 Documented architectural decission on what component to pick for caching 2021-08-05 16:47:30 +02:00
Alejandro Celaya
6ae0c7dcfc Updated to latest common with symfony/cache support 2021-08-05 14:05:44 +02:00
Alejandro Celaya
883ac1007a Updated to provisional hero-common v4.0 2021-08-04 18:46:19 +02:00
145 changed files with 1623 additions and 676 deletions

View File

@@ -21,7 +21,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer cs
@@ -39,7 +39,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer stan
@@ -48,7 +48,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -57,10 +58,13 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' }}
@@ -74,7 +78,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -83,10 +88,13 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' }}
@@ -100,7 +108,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -111,16 +120,20 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:mysql
db-tests-maria:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -131,16 +144,20 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:maria
db-tests-postgres:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -151,16 +168,20 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:postgres
db-tests-ms:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -169,13 +190,25 @@ jobs:
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
- name: Use PHP
if: ${{ matrix.php-version == '8.1' }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7, pdo_sqlsrv-5.9.0
extensions: swoole-4.7.1, pdo_sqlsrv-5.10.0beta1
coverage: none
- run: composer install --no-interaction --prefer-dist
- name: Use PHP
if: ${{ matrix.php-version != '8.1' }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1, pdo_sqlsrv-5.9.0
coverage: none
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- name: Create test database
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: composer test:db:ms
@@ -184,7 +217,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -195,10 +229,13 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: bin/test/run-api-tests.sh
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' }}
@@ -216,8 +253,9 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
php-version: ['8.0', '8.1']
test-group: ['unit', 'db']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -226,10 +264,13 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- uses: actions/download-artifact@v2
with:
path: build

View File

@@ -4,6 +4,118 @@ 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).
## [2.9.3] - 2021-11-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1232](https://github.com/shlinkio/shlink/issues/1232) Solved potential SQL injection by enforcing `doctrine/dbal` 3.1.4.
## [2.9.2] - 2021-10-23
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1210](https://github.com/shlinkio/shlink/issues/1210) Fixed real time updates not being notified due to an incorrect handling of db transactions on multi-process tasks.
* [#1211](https://github.com/shlinkio/shlink/issues/1211) Fixed `There is no active transaction` error when running migrations in MySQL/Mariadb after updating to doctrine-migrations 3.3.
* [#1197](https://github.com/shlinkio/shlink/issues/1197) Fixed amount of task workers provided via config option or env var not being validated to ensure enough workers to process all parallel tasks.
## [2.9.1] - 2021-10-11
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1201](https://github.com/shlinkio/shlink/issues/1201) Fixed crash when using the new `USE_HTTPS`, as it's boolean raw value was being used instead of resolving "https" or "http".
## [2.9.0] - 2021-10-10
### Added
* [#1015](https://github.com/shlinkio/shlink/issues/1015) Shlink now accepts configuration via env vars even when not using docker.
The config generated with the installing tool still has precedence over the env vars, so it cannot be combined. Either you use the tool, or use env vars.
* [#1149](https://github.com/shlinkio/shlink/issues/1149) Allowed to set custom defaults for the QR codes.
* [#1112](https://github.com/shlinkio/shlink/issues/1112) Added new option to define if the query string should be forwarded on a per-short URL basis.
The new `forwardQuery=true|false` param can be provided during short URL creation or edition, via REST API or CLI command, allowing to override the default behavior which makes the query string to always be forwarded.
* [#1105](https://github.com/shlinkio/shlink/issues/1105) Added support to define placeholders on not-found redirects, so that the redirected URL receives the originally visited path and/or domain.
Currently, `{DOMAIN}` and `{ORIGINAL_PATH}` placeholders are supported, and they can be used both in the redirected URL's path or query.
When they are used in the query, the values are URL encoded.
* [#1119](https://github.com/shlinkio/shlink/issues/1119) Added support to provide redis sentinel when using redis cache.
* [#1016](https://github.com/shlinkio/shlink/issues/1016) Added new option to send orphan visits to webhooks, via `NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS` env var or installer tool.
The option is disabled by default, as the payload is backwards incompatible. You will need to adapt your webhooks to treat the `shortUrl` property as optional before enabling this option.
* [#1104](https://github.com/shlinkio/shlink/issues/1104) Added ability to disable tracking based on IP addresses.
IP addresses can be provided in the form of fixed addresses, CIDR blocks, or wildcard patterns (192.168.*.*).
### Changed
* [#1142](https://github.com/shlinkio/shlink/issues/1142) Replaced `doctrine/cache` package with `symfony/cache`.
* [#1157](https://github.com/shlinkio/shlink/issues/1157) All routes now support CORS, not only rest ones.
* [#1144](https://github.com/shlinkio/shlink/issues/1144) Added experimental builds under PHP 8.1.
### Deprecated
* [#1164](https://github.com/shlinkio/shlink/issues/1164) Deprecated `SHORT_DOMAIN_HOST` and `SHORT_DOMAIN_SCHEMA` env vars. Use `DEFAULT_DOMAIN` and `USE_HTTPS=true|false` instead.
### Removed
* *Nothing*
### Fixed
* [#1165](https://github.com/shlinkio/shlink/issues/1165) Fixed warning displayed when trying to locate visits and there are none pending.
* [#1172](https://github.com/shlinkio/shlink/pull/1172) Removed unneeded explicitly defined volumes in docker image.
## [2.8.1] - 2021-08-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1155](https://github.com/shlinkio/shlink/issues/1155) Fixed numeric query params in long URLs being replaced by `0`.
## [2.8.0] - 2021-08-04
### Added
* [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`.
@@ -33,7 +145,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
### Fixed
* *Nothing*
* [#1098](https://github.com/shlinkio/shlink/issues/1098) Fixed errors when using Redis for caching, caused by some third party lib bug that was fixed on dependencies update.
## [2.7.3] - 2021-08-02

View File

@@ -2,9 +2,9 @@ FROM php:8.0.9-alpine3.14 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.7.0
ENV SWOOLE_VERSION 4.7.1
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV LC_ALL "C"
WORKDIR /etc/shlink
@@ -68,11 +68,6 @@ RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
# Expose default swoole port
EXPOSE 8080
# Expose params config dir, since the user is expected to provide custom config from there
VOLUME /etc/shlink/config/params
# Expose data dir to allow persistent runtime data and SQLite db
VOLUME /etc/shlink/data
# 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

View File

@@ -18,8 +18,8 @@
"akrabat/ip-address-middleware": "^2.0",
"cakephp/chronos": "^2.2",
"cocur/slugify": "^4.0",
"doctrine/cache": "^1.12",
"doctrine/migrations": "^3.2",
"doctrine/dbal": "^3.1.4",
"doctrine/migrations": "^3.3 <3.3.2",
"doctrine/orm": "^2.9",
"endroid/qr-code": "^4.2",
"geoip2/geoip2": "^2.11",
@@ -47,11 +47,12 @@
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.7",
"rlanvin/php-ip": "3.0.0-rc2",
"shlinkio/shlink-common": "^4.0",
"shlinkio/shlink-config": "^1.2",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3.1",
"shlinkio/shlink-installer": "^6.1",
"shlinkio/shlink-installer": "^6.2.1",
"shlinkio/shlink-ip-geolocation": "^2.0",
"symfony/console": "^5.3",
"symfony/filesystem": "^5.3",
@@ -64,7 +65,7 @@
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.3.0",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.24.0",
"infection/infection": "^0.25.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^0.12.94",
"phpstan/phpstan-doctrine": "^0.12.42",
@@ -72,8 +73,8 @@
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.1",
"shlinkio/shlink-test-utils": "^2.2",
"shlinkio/php-coding-standard": "~2.2.0",
"shlinkio/shlink-test-utils": "^2.3",
"symfony/var-dumper": "^5.3",
"veewee/composer-run-parallel": "^1.0"
},
@@ -84,6 +85,7 @@
"Shlinkio\\Shlink\\Core\\": "module/Core/src"
},
"files": [
"config/constants.php",
"module/Core/functions/functions.php"
]
},

View File

@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
return [
'delete_short_urls' => [
'visits_threshold' => 15,
'check_visits_threshold' => true,
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
],
];

View File

@@ -2,24 +2,52 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
return [
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
'default_repository_classname' => EntitySpecificationRepository::class,
return (static function (): array {
$driver = env('DB_DRIVER');
$isMysqlCompatible = contains(['maria', 'mysql'], $driver);
$resolveDriver = static fn () => match ($driver) {
'postgres' => 'pdo_pgsql',
'mssql' => 'pdo_sqlsrv',
default => 'pdo_mysql',
};
$resolveDefaultPort = static fn () => match ($driver) {
'postgres' => '5432',
'mssql' => '1433',
default => '3306',
};
$resolveConnection = static fn () => match (true) {
$driver === null || $driver === 'sqlite' => [
'driver' => 'pdo_sqlite',
'path' => 'data/database.sqlite',
],
'connection' => [
'user' => '',
'password' => '',
'dbname' => 'shlink',
default => [
'driver' => $resolveDriver(),
'dbname' => env('DB_NAME', 'shlink'),
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
'port' => env('DB_PORT', $resolveDefaultPort()),
'unix_socket' => $isMysqlCompatible ? env('DB_UNIX_SOCKET') : null,
'charset' => 'utf8',
],
],
};
];
return [
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
'default_repository_classname' => EntitySpecificationRepository::class,
],
'connection' => $resolveConnection(),
],
];
})();

View File

@@ -10,6 +10,8 @@ return [
'password' => 'root',
'driver' => 'pdo_mysql',
'host' => 'shlink_db',
'dbname' => 'shlink',
'charset' => 'utf8',
],
],

View File

@@ -2,12 +2,14 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data',
'license_key' => 'G4Lm0C60yJsnkdPi', // Deprecated. Remove hardcoded license on v3
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove hardcoded license on v3
],
];

View File

@@ -24,6 +24,7 @@ return [
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
Option\UrlShortener\ValidateUrlConfigOption::class,
Option\Visit\VisitsWebhooksConfigOption::class,
Option\Visit\OrphanVisitsWebhooksConfigOption::class,
Option\Redirect\BaseUrlRedirectConfigOption::class,
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
Option\Redirect\Regular404RedirectConfigOption::class,
@@ -46,10 +47,15 @@ return [
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,
Option\Tracking\DisableTrackingFromConfigOption::class,
Option\Tracking\DisableTrackingConfigOption::class,
Option\Tracking\DisableIpTrackingConfigOption::class,
Option\Tracking\DisableReferrerTrackingConfigOption::class,
Option\Tracking\DisableUaTrackingConfigOption::class,
Option\QrCode\DefaultSizeConfigOption::class,
Option\QrCode\DefaultMarginConfigOption::class,
Option\QrCode\DefaultFormatConfigOption::class,
Option\QrCode\DefaultErrorCorrectionConfigOption::class,
],
'installation_commands' => [

View File

@@ -3,12 +3,13 @@
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Shlinkio\Shlink\Common\Cache\RedisFactory;
use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
use Predis\ClientInterface as PredisClient;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Symfony\Component\Lock;
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
return [
@@ -24,16 +25,12 @@ return [
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
],
'aliases' => [
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
'lock_store' => 'local_lock_store',
'lock_store' => env('REDIS_SERVERS') === null ? 'local_lock_store' : 'redis_lock_store',
'redis_lock_store' => Lock\Store\RedisStore::class,
'local_lock_store' => Lock\Store\FlockStore::class,
],
'delegators' => [
Lock\Store\RedisStore::class => [
RetryLockStoreDelegatorFactory::class,
],
Lock\LockFactory::class => [
LoggerAwareDelegatorFactory::class,
],
@@ -42,7 +39,7 @@ return [
ConfigAbstractFactory::class => [
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
Lock\Store\RedisStore::class => [PredisClient::class],
Lock\LockFactory::class => ['lock_store'],
LOCAL_LOCK_FACTORY => ['local_lock_store'],
],

View File

@@ -7,30 +7,36 @@ use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface;
return [
use function Shlinkio\Shlink\Common\env;
'mercure' => [
'public_hub_url' => null,
'internal_hub_url' => null,
'jwt_secret' => null,
'jwt_issuer' => 'Shlink',
],
return (static function (): array {
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
return [
'mercure' => [
'public_hub_url' => $publicUrl,
'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
'jwt_secret' => env('MERCURE_JWT_SECRET'),
'jwt_issuer' => 'Shlink',
],
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
],
Hub::class => [
LazyServiceFactory::class,
],
],
Hub::class => [
LazyServiceFactory::class,
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Hub::class => HubInterface::class,
],
],
],
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Hub::class => HubInterface::class,
],
],
],
];
];
})();

View File

@@ -18,12 +18,12 @@ return [
'middleware' => [
ContentLengthMiddleware::class,
ErrorHandler::class,
Rest\Middleware\CrossDomainMiddleware::class,
],
],
'error-handler-rest' => [
'path' => '/rest',
'middleware' => [
Rest\Middleware\CrossDomainMiddleware::class,
RequestIdMiddleware::class,
ProblemDetails\ProblemDetailsMiddleware::class,
],

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
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_SIZE;
return [
'qr_codes' => [
'size' => (int) env('DEFAULT_QR_CODE_SIZE', DEFAULT_QR_CODE_SIZE),
'margin' => (int) env('DEFAULT_QR_CODE_MARGIN', DEFAULT_QR_CODE_MARGIN),
'format' => env('DEFAULT_QR_CODE_FORMAT', DEFAULT_QR_CODE_FORMAT),
'error_correction' => env('DEFAULT_QR_CODE_ERROR_CORRECTION', DEFAULT_QR_CODE_ERROR_CORRECTION),
],
];

View File

@@ -2,12 +2,23 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
return [
'not_found_redirects' => [
'invalid_short_url' => null,
'regular_404' => null,
'base_url' => null,
'invalid_short_url' => env('INVALID_SHORT_URL_REDIRECT_TO'),
'regular_404' => env('REGULAR_404_REDIRECT_TO'),
'base_url' => env('BASE_URL_REDIRECT_TO'),
],
'url_shortener' => [
// TODO Move these options to their own config namespace. Maybe "redirects".
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
],
];

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
return (static function (): array {
$redisServers = env('REDIS_SERVERS');
return match (true) {
$redisServers === null => [],
default => [
'cache' => [
'redis' => [
'servers' => $redisServers,
'sentinel_service' => env('REDIS_SENTINEL_SERVICE'),
],
],
],
};
})();

View File

@@ -4,10 +4,12 @@ declare(strict_types=1);
use Mezzio\Router\FastRouteRouter;
use function Shlinkio\Shlink\Common\env;
return [
'router' => [
'base_path' => '',
'base_path' => env('BASE_PATH', ''),
'fastroute' => [
FastRouteRouter::CONFIG_CACHE_ENABLED => true,

View File

@@ -2,21 +2,30 @@
declare(strict_types=1);
return [
use function Shlinkio\Shlink\Common\env;
'mezzio-swoole' => [
// Setting this to true can have unexpected behaviors when running several concurrent slow DB queries
'enable_coroutine' => false,
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
'swoole-http-server' => [
'host' => '0.0.0.0',
'process-name' => 'shlink',
return (static function () {
$taskWorkers = (int) env('TASK_WORKER_NUM', 16);
'options' => [
'worker_num' => 16,
'task_worker_num' => 16,
return [
'mezzio-swoole' => [
// Setting this to true can have unexpected behaviors when running several concurrent slow DB queries
'enable_coroutine' => false,
'swoole-http-server' => [
'host' => '0.0.0.0',
'port' => (int) env('PORT', 8080),
'process-name' => 'shlink',
'options' => [
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
'task_worker_num' => $taskWorkers < MIN_TASK_WORKERS ? MIN_TASK_WORKERS : $taskWorkers,
],
],
],
],
];
];
})();

View File

@@ -2,30 +2,35 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
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' => true,
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
'track_orphan_visits' => true,
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => null,
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
// If true, visits will not be tracked at all
'disable_tracking' => false,
'disable_tracking' => (bool) env('DISABLE_TRACKING', false),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => false,
'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false),
// If true, the referrer will not be tracked
'disable_referrer_tracking' => false,
'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false),
// If true, the user agent will not be tracked
'disable_ua_tracking' => false,
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => env('DISABLE_TRACKING_FROM'),
],
];

View File

@@ -2,26 +2,37 @@
declare(strict_types=1);
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
use function Shlinkio\Shlink\Common\env;
return [
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
'url_shortener' => [
'domain' => [
'schema' => 'https',
'hostname' => '',
return (static function (): array {
$shortCodesLength = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
$shortCodesLength = $shortCodesLength < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $shortCodesLength;
$resolveSchema = static function (): string {
$useHttps = env('USE_HTTPS'); // Deprecated. For v3, set this to true by default, instead of null
if ($useHttps !== null) {
$boolUseHttps = (bool) $useHttps;
return $boolUseHttps ? 'https' : 'http';
}
return env('SHORT_DOMAIN_SCHEMA', 'http');
};
return [
'url_shortener' => [
'domain' => [
// Deprecated SHORT_DOMAIN_* env vars
'schema' => $resolveSchema(),
'hostname' => env('DEFAULT_DOMAIN', env('SHORT_DOMAIN_HOST', '')),
],
'validate_url' => (bool) env('VALIDATE_URLS', false), // Deprecated
'default_short_codes_length' => $shortCodesLength,
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
],
'validate_url' => false, // Deprecated
'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
'auto_resolve_titles' => false,
'append_extra_path' => false,
// TODO Move these two options to their own config namespace. Maybe "redirects".
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
],
];
];
})();

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
return (static function (): array {
$webhooks = env('VISITS_WEBHOOKS');
return [
'url_shortener' => [
// TODO Move these options to their own config namespace
'visits_webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
'notify_orphan_visits_to_webhooks' => (bool) env('NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS', false),
],
];
})();

View File

@@ -8,7 +8,7 @@ use Laminas\ConfigAggregator;
use Laminas\Diactoros;
use Mezzio;
use Mezzio\ProblemDetails;
use Mezzio\Swoole\ConfigProvider as SwooleConfigProvider;
use Mezzio\Swoole;
use function class_exists;
use function Shlinkio\Shlink\Common\env;
@@ -17,7 +17,7 @@ return (new ConfigAggregator\ConfigAggregator([
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
class_exists(SwooleConfigProvider::class) ? SwooleConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
class_exists(Swoole\ConfigProvider::class) ? Swoole\ConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,
@@ -31,6 +31,7 @@ return (new ConfigAggregator\ConfigAggregator([
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
env('APP_ENV') === 'test'
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
// Deprecated. When the SimplifiedConfigParser is removed, load only generated_config.php here
: new ConfigAggregator\LaminasConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
], 'data/cache/app_config.php', [
Core\Config\SimplifiedConfigParser::class,

21
config/constants.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Fig\Http\Message\StatusCodeInterface;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
const DEFAULT_QR_CODE_SIZE = 300;
const DEFAULT_QR_CODE_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const MIN_TASK_WORKERS = 4;

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
use Laminas\ServiceManager\ServiceManager;
use Symfony\Component\Lock;
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
chdir(dirname(__DIR__));

View File

@@ -29,6 +29,6 @@ register_shutdown_function(function () use ($httpClient): void {
);
});
$testHelper->createTestDb();
$testHelper->createTestDb(['bin/cli', 'db:create'], ['bin/cli', 'db:migrate']);
ApiTest\ApiTestCase::setApiClient($httpClient);
ApiTest\ApiTestCase::setSeedFixturesCallback(fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []));

View File

@@ -3,7 +3,7 @@ MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.20
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update

View File

@@ -2,10 +2,10 @@ FROM php:8.0.9-alpine3.14
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.20
ENV PDO_SQLSRV_VERSION 5.9.0
ENV INOTIFY_VERSION 3.0.0
ENV SWOOLE_VERSION 4.7.0
ENV MS_ODBC_SQL_VERSION 17.5.2.1
ENV SWOOLE_VERSION 4.7.1
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update

View File

@@ -39,6 +39,11 @@ class Version20160819142757 extends AbstractMigration
*/
public function down(Schema $schema): void
{
$db = $this->connection->getDatabasePlatform()->getName();
$this->connection->getDatabasePlatform()->getName();
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -73,4 +73,9 @@ class Version20160820191203 extends AbstractMigration
$schema->dropTable('short_urls_in_tags');
$schema->dropTable('tags');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -45,4 +45,9 @@ class Version20171021093246 extends AbstractMigration
$shortUrls->dropColumn('valid_since');
$shortUrls->dropColumn('valid_until');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -42,4 +42,9 @@ class Version20171022064541 extends AbstractMigration
$shortUrls->dropColumn('max_visits');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -39,4 +39,9 @@ final class Version20180801183328 extends AbstractMigration
{
$schema->getTable('short_urls')->getColumn('short_code')->setLength($size);
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -66,4 +66,9 @@ final class Version20180913205455 extends AbstractMigration
{
// Nothing to rollback
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -47,4 +47,9 @@ final class Version20180915110857 extends AbstractMigration
{
// Nothing to run
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -65,4 +65,9 @@ final class Version20181020060559 extends AbstractMigration
{
// No down
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -38,4 +38,9 @@ final class Version20181020065148 extends AbstractMigration
{
// No down
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -34,4 +34,9 @@ final class Version20181110175521 extends AbstractMigration
{
return $schema->getTable('visits')->getColumn('user_agent');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -34,4 +34,9 @@ final class Version20190824075137 extends AbstractMigration
{
return $schema->getTable('visits')->getColumn('referer');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -52,4 +52,9 @@ final class Version20190930165521 extends AbstractMigration
$schema->getTable('short_urls')->dropColumn('domain_id');
$schema->dropTable('domains');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -46,4 +46,9 @@ final class Version20191001201532 extends AbstractMigration
$shortUrls->dropIndex('unique_short_code_plus_domain');
$shortUrls->addUniqueIndex(['short_code']);
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -34,4 +34,9 @@ final class Version20191020074522 extends AbstractMigration
{
return $schema->getTable('short_urls')->getColumn('original_url');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -93,4 +93,9 @@ final class Version20200105165647 extends AbstractMigration
$visitLocations->dropColumn($colName);
}
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -44,4 +44,9 @@ final class Version20200106215144 extends AbstractMigration
]);
}
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -50,4 +50,9 @@ final class Version20200110182849 extends AbstractMigration
{
// No need (and no way) to undo this migration
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -42,4 +42,9 @@ final class Version20200323190014 extends AbstractMigration
$visitLocations->dropColumn('is_empty');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -24,4 +24,9 @@ final class Version20200503170404 extends AbstractMigration
$this->skipIf(! $visits->hasIndex(self::INDEX_NAME));
$visits->dropIndex(self::INDEX_NAME);
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -41,4 +41,9 @@ final class Version20201023090929 extends AbstractMigration
$shortUrls->dropColumn('import_original_short_code');
$shortUrls->dropIndex('unique_imports');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -60,10 +60,7 @@ final class Version20201102113208 extends AbstractMigration
->execute();
}
/**
* @return string|int|null
*/
private function resolveOneApiKeyId(Result $result)
private function resolveOneApiKeyId(Result $result): string|int|null
{
$results = [];
while ($row = $result->fetchAssociative()) {
@@ -86,4 +83,9 @@ final class Version20201102113208 extends AbstractMigration
$shortUrls->removeForeignKey('FK_' . self::API_KEY_COLUMN);
$shortUrls->dropColumn(self::API_KEY_COLUMN);
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -49,4 +49,9 @@ final class Version20210102174433 extends AbstractMigration
$schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key');
$schema->dropTable(self::TABLE_NAME);
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -23,4 +23,9 @@ final class Version20210118153932 extends AbstractMigration
public function down(Schema $schema): void
{
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -33,4 +33,9 @@ final class Version20210202181026 extends AbstractMigration
$shortUrls->dropColumn(self::TITLE);
$shortUrls->dropColumn('title_was_auto_resolved');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -40,4 +40,9 @@ final class Version20210207100807 extends AbstractMigration
$visits->dropColumn('visited_url');
$visits->dropColumn('type');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -34,4 +34,9 @@ final class Version20210306165711 extends AbstractMigration
$apiKeys->dropColumn(self::COLUMN);
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -23,4 +23,9 @@ final class Version20210522051601 extends AbstractMigration
$this->skipIf(! $shortUrls->hasColumn('crawlable'));
$shortUrls->dropColumn('crawlable');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -25,4 +25,9 @@ final class Version20210522124633 extends AbstractMigration
$this->skipIf(! $visits->hasColumn(self::POTENTIAL_BOT_COLUMN));
$visits->dropColumn(self::POTENTIAL_BOT_COLUMN);
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -38,4 +38,9 @@ final class Version20210720143824 extends AbstractMigration
$domainsTable->dropColumn('regular_not_found_redirect');
$domainsTable->dropColumn('invalid_short_url_redirect');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20211002072605 extends AbstractMigration
{
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf($shortUrls->hasColumn('forward_query'));
$shortUrls->addColumn('forward_query', Types::BOOLEAN, ['default' => true]);
}
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf(! $shortUrls->hasColumn('forward_query'));
$shortUrls->dropColumn('forward_query');
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -18,4 +18,9 @@ final class <className> extends AbstractMigration
{
<down>
}
public function isTransactional(): bool
{
return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
}
}

View File

@@ -11,8 +11,8 @@ It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), wh
The most basic way to run Shlink's docker image is by providing these mandatory env vars.
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**.
* `USE_HTTPS`: Either **true** or **false**.
* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this.
To run shlink on top of a local docker service, and using an internal SQLite database, do the following:
@@ -21,8 +21,8 @@ To run shlink on top of a local docker service, and using an internal SQLite dat
docker run \
--name shlink \
-p 8080:8080 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e DEFAULT_DOMAIN=doma.in \
-e USE_HTTPS=true \
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
shlinkio/shlink:stable
```

View File

@@ -7,128 +7,8 @@ namespace Shlinkio\Shlink;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use function explode;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\Core\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
$helper = new class {
private const DB_DRIVERS_MAP = [
'mysql' => 'pdo_mysql',
'maria' => 'pdo_mysql',
'postgres' => 'pdo_pgsql',
'mssql' => 'pdo_sqlsrv',
];
private const DB_PORTS_MAP = [
'mysql' => '3306',
'maria' => '3306',
'postgres' => '5432',
'mssql' => '1433',
];
public function getDbConfig(): array
{
$driver = env('DB_DRIVER');
$isMysql = contains(['maria', 'mysql'], $driver);
if ($driver === null || $driver === 'sqlite') {
return [
'driver' => 'pdo_sqlite',
'path' => 'data/database.sqlite',
];
}
return [
'driver' => self::DB_DRIVERS_MAP[$driver],
'dbname' => env('DB_NAME', 'shlink'),
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
];
}
public function getNotFoundRedirectsConfig(): array
{
return [
'invalid_short_url' => env('INVALID_SHORT_URL_REDIRECT_TO'),
'regular_404' => env('REGULAR_404_REDIRECT_TO'),
'base_url' => env('BASE_URL_REDIRECT_TO'),
];
}
public function getVisitsWebhooks(): array
{
$webhooks = env('VISITS_WEBHOOKS');
return $webhooks === null ? [] : explode(',', $webhooks);
}
public function getRedisConfig(): ?array
{
$redisServers = env('REDIS_SERVERS');
return $redisServers === null ? null : ['servers' => $redisServers];
}
public function getDefaultShortCodesLength(): int
{
$value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value;
}
public function getMercureConfig(): array
{
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
return [
'public_hub_url' => $publicUrl,
'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
'jwt_secret' => env('MERCURE_JWT_SECRET'),
];
}
};
return [
'delete_short_urls' => [
'check_visits_threshold' => true,
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
],
'entity_manager' => [
'connection' => $helper->getDbConfig(),
],
'url_shortener' => [
'domain' => [
'schema' => env('SHORT_DOMAIN_SCHEMA', 'http'),
'hostname' => env('SHORT_DOMAIN_HOST', ''),
],
'validate_url' => (bool) env('VALIDATE_URLS', false),
'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
],
'tracking' => [
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
'disable_tracking' => (bool) env('DISABLE_TRACKING', false),
'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false),
'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false),
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
],
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
'logger' => [
'Shlink' => [
'handlers' => [
@@ -143,34 +23,4 @@ return [
],
],
'dependencies' => [
'aliases' => env('REDIS_SERVERS') === null ? [] : [
'lock_store' => 'redis_lock_store',
],
],
'cache' => [
'redis' => $helper->getRedisConfig(),
],
'router' => [
'base_path' => env('BASE_PATH', ''),
],
'mezzio-swoole' => [
'swoole-http-server' => [
'port' => (int) env('PORT', 8080),
'options' => [
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),
],
],
],
'geolite2' => [
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove hardcoded license on v3
],
'mercure' => $helper->getMercureConfig(),
];

View File

@@ -0,0 +1,59 @@
# Migrate to a new caching library
* Status: Accepted
* Date: 2021-08-05
## Context and problem statement
Shlink has always used the `doctrine/cache` library to handle anything related with cache.
It was convenient, as it provided several adapters, and it was the library used by other doctrine packages.
However, after the creation of the caching PSRs ([PSR-6 - Cache](https://www.php-fig.org/psr/psr-6) and [PSR-16 - Simple cache](https://www.php-fig.org/psr/psr-16)), most library authors have moved to those interfaces, and the doctrine team has decided to recommend using any other existing package and decommission their own solution.
Also, Shlink needs support for Redis clusters and Redis sentinels, which is not supported by `doctrine/cache` Redis adapters.
## Considered option
After some research, the only packages that seem to support the capabilities required by Shlink and also seem healthy, are these:
* [Symfony cache](https://symfony.com/doc/current/components/cache.html)
* 🟢 PSR-6 compliant: **yes**
* 🟢 PSR-16 compliant: **yes**
* 🟢 APCu support: **yes**
* 🟢 Redis support: **yes**
* 🟢 Redis cluster support: **yes**
* 🟢 Redis sentinel support: **yes**
* 🟢 Can use redis through Predis: **yes**
* 🔴 Individual packages per adapter: **no**
* [Laminas cache](https://docs.laminas.dev/laminas-cache/)
* 🟢 PSR-6 compliant: **yes**
* 🟢 PSR-16 compliant: **yes**
* 🟢 APCu support: **yes**
* 🟢 Redis support: **yes**
* 🟢 Redis cluster support: **yes**
* 🔴 Redis sentinel support: **no**
* 🔴 Can use redis through Predis: **no**
* 🟢 Individual packages per adapter: **yes**
## Decision outcome
Even though Symfony packs all their adapters in a single component, which means we will install some code that will never be used, Laminas relies on the native redis extension for anything related with redis.
That would make Shlink more complex to install, so it seems Symfony's package is the option where it's easier to migrate to.
Also, it's important that the cache component can share the Redis integration (through `Predis`, in this case), as it's also used by other components (the lock component, to name one).
## Pros and Cons of the Options
### Symfony cache
* Good because it supports Redis Sentinel.
* Good because it allows using a external `Predis` instance.
* Bad because it packs all the adapters in a single component.
### Laminas cache
* Good because allows installing only the adapters you are going to use, through separated packages.
* Bad because it requires the php-redis native extension in order to interact with Redis.
* Bad because it does ot seem to support Redis Sentinels.

View File

@@ -2,5 +2,6 @@
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
* [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md)
* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md)
* [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md)

View File

@@ -1,5 +1,18 @@
{
"type": "object",
"required": [
"shortCode",
"shortUrl",
"longUrl",
"dateCreated",
"visitsCount",
"tags",
"meta",
"domain",
"title",
"crawlable",
"forwardQuery"
],
"properties": {
"shortCode": {
"type": "string",
@@ -45,6 +58,10 @@
"crawlable": {
"type": "boolean",
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
},
"forwardQuery": {
"type": "boolean",
"description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
}
}
}

View File

@@ -0,0 +1,48 @@
{
"type": "object",
"properties": {
"longUrl": {
"description": "The long URL this short URL will redirect to",
"type": "string"
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string",
"nullable": true
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string",
"nullable": true
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number",
"nullable": true
},
"validateUrl": {
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "The list of tags to set to the short URL."
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL.",
"nullable": true
},
"crawlable": {
"type": "boolean",
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
},
"forwardQuery": {
"type": "boolean",
"description": "Tells if the query params should be forwarded from the short URL to the long one, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
}
}
}

View File

@@ -225,63 +225,37 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"longUrl"
],
"properties": {
"longUrl": {
"description": "The URL to parse",
"type": "string"
"allOf": [
{
"$ref": "../definitions/ShortUrlEdition.json"
},
"tags": {
"description": "The URL to parse",
"type": "array",
"items": {
"type": "string"
{
"type": "object",
"required": ["longUrl"],
"properties": {
"customSlug": {
"description": "A unique custom slug to be used instead of the generated short code",
"type": "string"
},
"findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean"
},
"domain": {
"description": "The domain to which the short URL will be attached",
"type": "string"
},
"shortCodeLength": {
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
"type": "number"
},
"validateUrl": {
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
}
}
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string"
},
"customSlug": {
"description": "A unique custom slug to be used instead of the generated short code",
"type": "string"
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
},
"findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean"
},
"domain": {
"description": "The domain to which the short URL will be attached",
"type": "string"
},
"shortCodeLength": {
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
"type": "number"
},
"validateUrl": {
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL."
},
"crawlable": {
"type": "boolean",
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
}
}
]
}
}
}

View File

@@ -112,48 +112,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"description": "The long URL this short URL will redirect to",
"type": "string"
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string",
"nullable": true
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string",
"nullable": true
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number",
"nullable": true
},
"validateUrl": {
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "The list of tags to set to the short URL."
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL.",
"nullable": true
},
"crawlable": {
"type": "boolean",
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
}
}
"$ref": "../definitions/ShortUrlEdition.json"
}
}
}

View File

@@ -5,7 +5,7 @@
"Domains"
],
"summary": "Sets domain \"not found\" redirects",
"description": "Sets the URLs that you want a visitor to get redirected to for \not found\" URLs for a specific domain",
"description": "Sets the URLs that you want a visitor to get redirected to for \"not found\" URLs for a specific domain",
"security": [
{
"ApiKey": []

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Console as SymfonyCli;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
return [

View File

@@ -25,7 +25,7 @@ class GenerateKeyCommand extends BaseCommand
public function __construct(
private ApiKeyServiceInterface $apiKeyService,
private RoleResolverInterface $roleResolver
private RoleResolverInterface $roleResolver,
) {
parent::__construct();
}

View File

@@ -18,7 +18,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
public function __construct(
LockFactory $locker,
private ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder
PhpExecutableFinder $phpFinder,
) {
parent::__construct($locker);
$this->phpBinary = $phpFinder->find(false) ?: 'php';

View File

@@ -26,7 +26,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
private Connection $regularConn,
private Connection $noDbNameConn
private Connection $noDbNameConn,
) {
parent::__construct($locker, $processRunner, $phpFinder);
}
@@ -67,11 +67,11 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
// In order to create the new database, we have to use a connection where the dbname was not set.
// Otherwise, it will fail to connect and will not be able to create the new database
$schemaManager = $this->noDbNameConn->getSchemaManager();
$schemaManager = $this->noDbNameConn->createSchemaManager();
$databases = $schemaManager->listDatabases();
$shlinkDatabase = $this->regularConn->getDatabase();
if (! contains($databases, $shlinkDatabase)) {
if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) {
$schemaManager->createDatabase($shlinkDatabase);
}
}
@@ -80,7 +80,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
{
// If at least one of the shlink tables exist, we will consider the database exists somehow.
// Any inconsistency should be taken care by the migrations
$schemaManager = $this->regularConn->getSchemaManager();
$schemaManager = $this->regularConn->createSchemaManager();
return ! empty($schemaManager->listTableNames());
}
}

View File

@@ -33,7 +33,7 @@ class GenerateShortUrlCommand extends BaseCommand
public function __construct(
private UrlShortenerInterface $urlShortener,
private ShortUrlStringifierInterface $stringifier,
private int $defaultShortCodeLength
private int $defaultShortCodeLength,
) {
parent::__construct();
}
@@ -104,7 +104,19 @@ class GenerateShortUrlCommand extends BaseCommand
'no-validate-url',
null,
InputOption::VALUE_NONE,
'Forces the long URL to not be validated, regardless what is globally configured.',
'[DEPRECATED] Forces the long URL to not be validated, regardless what is globally configured.',
)
->addOption(
'crawlable',
'r',
InputOption::VALUE_NONE,
'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.',
)
->addOption(
'no-forward-query',
'w',
InputOption::VALUE_NONE,
'Disables the forwarding of the query string to the long URL, when the new short URL is visited.',
);
}
@@ -156,6 +168,8 @@ class GenerateShortUrlCommand extends BaseCommand
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
]));
$io->writeln([

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
@@ -21,6 +20,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetVisitsCommand extends AbstractWithDateRangeCommand
@@ -73,7 +73,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$paginator = $this->visitsHelper->visitsForShortUrl(
$identifier,
new VisitsParams(new DateRange($startDate, $endDate)),
new VisitsParams(buildDateRange($startDate, $endDate)),
);
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {

View File

@@ -35,7 +35,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
public function __construct(
private ShortUrlServiceInterface $shortUrlService,
private DataTransformerInterface $transformer
private DataTransformerInterface $transformer,
) {
parent::__construct();
}

View File

@@ -11,7 +11,7 @@ final class LockedCommandConfig
private function __construct(
private string $lockName,
private bool $isBlocking,
private float $ttl = self::DEFAULT_TTL
private float $ttl = self::DEFAULT_TTL,
) {
}

View File

@@ -35,7 +35,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
public function __construct(
private VisitLocatorInterface $visitLocator,
private IpLocationResolverInterface $ipLocationResolver,
LockFactory $locker
LockFactory $locker,
) {
parent::__construct($locker);
}

View File

@@ -23,7 +23,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
private DbUpdaterInterface $dbUpdater,
private Reader $geoLiteDbReader,
private LockFactory $locker,
private TrackingOptions $trackingOptions
private TrackingOptions $trackingOptions,
) {
}

View File

@@ -46,10 +46,10 @@ class CreateDatabaseCommandTest extends TestCase
$this->databasePlatform = $this->prophesize(AbstractPlatform::class);
$this->regularConn = $this->prophesize(Connection::class);
$this->regularConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
$this->regularConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
$this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal());
$noDbNameConn = $this->prophesize(Connection::class);
$noDbNameConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
$noDbNameConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
$command = new CreateDatabaseCommand(
$locker->reveal(),

View File

@@ -45,7 +45,7 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(null, null)),
new VisitsParams(DateRange::emptyInstance()),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
@@ -61,7 +61,7 @@ class GetVisitsCommandTest extends TestCase
$endDate = '2016-02-01';
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
new VisitsParams(DateRange::withStartAndEndDate(Chronos::parse($startDate), Chronos::parse($endDate))),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
@@ -80,7 +80,7 @@ class GetVisitsCommandTest extends TestCase
$startDate = 'foo';
$info = $this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange()),
new VisitsParams(DateRange::emptyInstance()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([

View File

@@ -238,11 +238,10 @@ class ListShortUrlsCommandTest extends TestCase
}
/**
* @param string|array|null $expectedOrderBy
* @test
* @dataProvider provideOrderBy
*/
public function orderByIsProperlyComputed(array $commandArgs, $expectedOrderBy): void
public function orderByIsProperlyComputed(array $commandArgs, string|array|null $expectedOrderBy): void
{
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
'orderBy' => $expectedOrderBy,

View File

@@ -116,9 +116,8 @@ class GeolocationDbUpdaterTest extends TestCase
/**
* @test
* @dataProvider provideSmallDays
* @param string|int $buildEpoch
*/
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek($buildEpoch): void
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(string|int $buildEpoch): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch));
@@ -161,10 +160,7 @@ class GeolocationDbUpdaterTest extends TestCase
$this->geolocationDbUpdater->checkDbUpdate();
}
/**
* @param string|int $buildEpoch
*/
private function buildMetaWithBuildEpoch($buildEpoch): Metadata
private function buildMetaWithBuildEpoch(string|int $buildEpoch): Metadata
{
return new Metadata([
'binary_format_major_version' => '',

View File

@@ -25,6 +25,8 @@ return [
Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class,
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
Options\TrackingOptions::class => ConfigAbstractFactory::class,
Options\QrCodeOptions::class => ConfigAbstractFactory::class,
Options\WebhookOptions::class => ConfigAbstractFactory::class,
Service\UrlShortener::class => ConfigAbstractFactory::class,
Service\ShortUrlService::class => ConfigAbstractFactory::class,
@@ -86,6 +88,8 @@ return [
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Options\TrackingOptions::class => ['config.tracking'],
Options\QrCodeOptions::class => ['config.qr_codes'],
Options\WebhookOptions::class => ['config.url_shortener'], // TODO This config is currently under url_shortener
Service\UrlShortener::class => [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
@@ -125,7 +129,7 @@ return [
Util\DoctrineBatchHelper::class => ['em'],
Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class],
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
@@ -138,6 +142,7 @@ return [
Service\ShortUrl\ShortUrlResolver::class,
ShortUrl\Helper\ShortUrlStringifier::class,
'Logger_Shlink',
Options\QrCodeOptions::class,
],
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],

View File

@@ -100,4 +100,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->columnName('crawlable')
->option('default', false)
->build();
$builder->createField('forwardQuery', Types::BOOLEAN)
->columnName('forward_query')
->option('default', true)
->build();
};

View File

@@ -58,7 +58,7 @@ return [
'httpClient',
'em',
'Logger_Shlink',
'config.url_shortener.visits_webhooks',
Options\WebhookOptions::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Options\AppOptions::class,
],

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core;
use Cake\Chronos\Chronos;
use DateTimeInterface;
use Fig\Http\Message\StatusCodeInterface;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
@@ -16,20 +15,12 @@ use function Functional\reduce_left;
use function is_array;
use function lcfirst;
use function print_r;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
use function str_repeat;
use function str_replace;
use function ucwords;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
function generateRandomShortCode(int $length): string
{
static $shortIdFactory;
@@ -51,18 +42,10 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
$startDate = parseDateFromQuery($query, $startDateName);
$endDate = parseDateFromQuery($query, $endDateName);
return match (true) {
$startDate === null && $endDate === null => DateRange::emptyInstance(),
$startDate !== null && $endDate !== null => DateRange::withStartAndEndDate($startDate, $endDate),
$startDate !== null => DateRange::withStartDate($startDate),
default => DateRange::withEndDate($endDate),
};
return buildDateRange($startDate, $endDate);
}
/**
* @param string|DateTimeInterface|Chronos|null $date
*/
function parseDateField($date): ?Chronos
function parseDateField(string|DateTimeInterface|Chronos|null $date): ?Chronos
{
if ($date === null || $date instanceof Chronos) {
return $date;

View File

@@ -14,40 +14,42 @@ use Endroid\QrCode\Writer\SvgWriter;
use Endroid\QrCode\Writer\WriterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Options\QrCodeOptions;
use function Functional\contains;
use function strtolower;
use function trim;
final class QrCodeParams
{
private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
private const SUPPORTED_FORMATS = ['png', 'svg'];
private function __construct(
private int $size,
private int $margin,
private WriterInterface $writer,
private ErrorCorrectionLevelInterface $errorCorrectionLevel
private ErrorCorrectionLevelInterface $errorCorrectionLevel,
) {
}
public static function fromRequest(ServerRequestInterface $request): self
public static function fromRequest(ServerRequestInterface $request, QrCodeOptions $defaults): self
{
$query = $request->getQueryParams();
return new self(
self::resolveSize($request, $query),
self::resolveMargin($query),
self::resolveWriter($query),
self::resolveErrorCorrection($query),
self::resolveSize($request, $query, $defaults),
self::resolveMargin($query, $defaults),
self::resolveWriter($query, $defaults),
self::resolveErrorCorrection($query, $defaults),
);
}
private static function resolveSize(Request $request, array $query): int
private static function resolveSize(Request $request, array $query, QrCodeOptions $defaults): int
{
// FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
$size = (int) $request->getAttribute('size', $query['size'] ?? $defaults->size());
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
@@ -55,13 +57,9 @@ final class QrCodeParams
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
}
private static function resolveMargin(array $query): int
private static function resolveMargin(array $query, QrCodeOptions $defaults): int
{
$margin = $query['margin'] ?? null;
if ($margin === null) {
return 0;
}
$margin = $query['margin'] ?? (string) $defaults->margin();
$intMargin = (int) $margin;
if ($margin !== (string) $intMargin) {
return 0;
@@ -70,18 +68,20 @@ final class QrCodeParams
return $intMargin < 0 ? 0 : $intMargin;
}
private static function resolveWriter(array $query): WriterInterface
private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface
{
$format = strtolower(trim($query['format'] ?? 'png'));
$qFormat = self::normalizeParam($query['format'] ?? '');
$format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format());
return match ($format) {
'svg' => new SvgWriter(),
default => new PngWriter(),
};
}
private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface
private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevelInterface
{
$errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l'));
$errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection());
return match ($errorCorrectionLevel) {
'h' => new ErrorCorrectionLevelHigh(),
'q' => new ErrorCorrectionLevelQuartile(),
@@ -90,6 +90,11 @@ final class QrCodeParams
};
}
private static function normalizeParam(string $param): string
{
return strtolower(trim($param));
}
public function size(): int
{
return $this->size;

View File

@@ -14,6 +14,7 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options\QrCodeOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
@@ -22,7 +23,8 @@ class QrCodeAction implements MiddlewareInterface
public function __construct(
private ShortUrlResolverInterface $urlResolver,
private ShortUrlStringifierInterface $stringifier,
private LoggerInterface $logger
private LoggerInterface $logger,
private QrCodeOptions $defaultOptions,
) {
}
@@ -37,7 +39,7 @@ class QrCodeAction implements MiddlewareInterface
return $handler->handle($request);
}
$params = QrCodeParams::fromRequest($request);
$params = QrCodeParams::fromRequest($request, $this->defaultOptions);
$qrCodeBuilder = Builder::create()
->data($this->stringifier->stringify($shortUrl))
->size($params->size())

View File

@@ -4,31 +4,78 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use League\Uri\Exceptions\SyntaxError;
use League\Uri\Uri;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use function Functional\compose;
use function str_replace;
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
{
public function __construct(private RedirectResponseHelperInterface $redirectResponseHelper)
{
private const DOMAIN_PLACEHOLDER = '{DOMAIN}';
private const ORIGINAL_PATH_PLACEHOLDER = '{ORIGINAL_PATH}';
public function __construct(
private RedirectResponseHelperInterface $redirectResponseHelper,
private LoggerInterface $logger,
) {
}
public function resolveRedirectResponse(
NotFoundType $notFoundType,
NotFoundRedirectConfigInterface $config
NotFoundRedirectConfigInterface $config,
UriInterface $currentUri,
): ?ResponseInterface {
return match (true) {
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() =>
// @phpstan-ignore-next-line Create custom PHPStan rule
$this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()),
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() =>
// @phpstan-ignore-next-line Create custom PHPStan rule
$this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()),
$urlToRedirectTo = match (true) {
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(),
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(),
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
// @phpstan-ignore-next-line Create custom PHPStan rule
$this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()),
$config->invalidShortUrlRedirect(),
default => null,
};
if ($urlToRedirectTo === null) {
return null;
}
return $this->redirectResponseHelper->buildRedirectResponse(
$this->resolvePlaceholders($currentUri, $urlToRedirectTo),
);
}
private function resolvePlaceholders(UriInterface $currentUri, string $redirectUrl): string
{
$domain = $currentUri->getAuthority();
$path = $currentUri->getPath();
try {
$redirectUri = Uri::createFromString($redirectUrl);
} catch (SyntaxError $e) {
$this->logger->warning('It was not possible to parse "{url}" as a valid URL: {e}', [
'e' => $e,
'url' => $redirectUrl,
]);
return $redirectUrl;
}
$replacePlaceholderForPattern = static fn (string $pattern, string $replace, callable $modifier) =>
static fn (?string $value) =>
$value === null ? null : str_replace($modifier($pattern), $modifier($replace), $value);
$replacePlaceholders = static fn (callable $modifier) => compose(
$replacePlaceholderForPattern(self::DOMAIN_PLACEHOLDER, $domain, $modifier),
$replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier),
);
$replacePlaceholdersInPath = $replacePlaceholders('\Functional\id');
$replacePlaceholdersInQuery = $replacePlaceholders('\urlencode');
return $redirectUri
->withPath($replacePlaceholdersInPath($redirectUri->getPath()))
->withQuery($replacePlaceholdersInQuery($redirectUri->getQuery()))
->__toString();
}
}

View File

@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
interface NotFoundRedirectResolverInterface
{
public function resolveRedirectResponse(
NotFoundType $notFoundType,
NotFoundRedirectConfigInterface $config
NotFoundRedirectConfigInterface $config,
UriInterface $currentUri,
): ?ResponseInterface;
}

View File

@@ -84,7 +84,7 @@ class DomainService implements DomainServiceInterface
public function configureNotFoundRedirects(
string $authority,
NotFoundRedirects $notFoundRedirects,
?ApiKey $apiKey = null
?ApiKey $apiKey = null,
): Domain {
if ($authority === $this->defaultDomain) {
throw InvalidDomainException::forDefaultDomainRedirects();

View File

@@ -14,7 +14,7 @@ final class DomainItem implements JsonSerializable
private function __construct(
private string $authority,
private NotFoundRedirectConfigInterface $notFoundRedirectConfig,
private bool $isDefault
private bool $isDefault,
) {
}

View File

@@ -43,6 +43,7 @@ class ShortUrl extends AbstractEntity
private ?string $title = null;
private bool $titleWasAutoResolved = false;
private bool $crawlable = false;
private bool $forwardQuery = true;
private function __construct()
{
@@ -80,6 +81,7 @@ class ShortUrl extends AbstractEntity
$instance->title = $meta->getTitle();
$instance->titleWasAutoResolved = $meta->titleWasAutoResolved();
$instance->crawlable = $meta->isCrawlable();
$instance->forwardQuery = $meta->forwardQuery();
return $instance;
}
@@ -207,6 +209,11 @@ class ShortUrl extends AbstractEntity
return $this->crawlable;
}
public function forwardQuery(): bool
{
return $this->forwardQuery;
}
public function update(
ShortUrlEdit $shortUrlEdit,
?ShortUrlRelationResolverInterface $relationResolver = null,
@@ -238,6 +245,9 @@ class ShortUrl extends AbstractEntity
$this->title = $shortUrlEdit->title();
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
}
if ($shortUrlEdit->forwardQueryWasProvided()) {
$this->forwardQuery = $shortUrlEdit->forwardQuery();
}
}
/**

View File

@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ErrorHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface;
@@ -26,17 +27,25 @@ class NotFoundRedirectHandler implements MiddlewareInterface
{
/** @var NotFoundType $notFoundType */
$notFoundType = $request->getAttribute(NotFoundType::class);
$authority = $request->getUri()->getAuthority();
$domainSpecificRedirect = $this->resolveDomainSpecificRedirect($authority, $notFoundType);
$currentUri = $request->getUri();
$domainSpecificRedirect = $this->resolveDomainSpecificRedirect($currentUri, $notFoundType);
return $domainSpecificRedirect
?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions)
// If we did not find domain-specific redirects for current domain, we try to fall back to default redirects
?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions, $currentUri)
// Ultimately, we just call next handler if no domain-specific redirects or default redirects were found
?? $handler->handle($request);
}
private function resolveDomainSpecificRedirect(string $authority, NotFoundType $notFoundType): ?ResponseInterface
{
$domain = $this->domainService->findByAuthority($authority);
return $domain === null ? null : $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain);
private function resolveDomainSpecificRedirect(
UriInterface $currentUri,
NotFoundType $notFoundType,
): ?ResponseInterface {
$domain = $this->domainService->findByAuthority($currentUri->getAuthority());
if ($domain === null) {
return null;
}
return $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain, $currentUri);
}
}

View File

@@ -24,7 +24,7 @@ class LocateVisit
private EntityManagerInterface $em,
private LoggerInterface $logger,
private DbUpdaterInterface $dbUpdater,
private EventDispatcherInterface $eventDispatcher
private EventDispatcherInterface $eventDispatcher,
) {
}

View File

@@ -21,7 +21,7 @@ class NotifyVisitToMercure
private HubInterface $hub,
private MercureUpdatesGeneratorInterface $updatesGenerator,
private EntityManagerInterface $em,
private LoggerInterface $logger
private LoggerInterface $logger,
) {
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Closure;
use Doctrine\ORM\EntityManagerInterface;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
@@ -17,10 +16,10 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Throwable;
use function Functional\map;
use function Functional\partial_left;
class NotifyVisitToWebHooks
{
@@ -28,16 +27,15 @@ class NotifyVisitToWebHooks
private ClientInterface $httpClient,
private EntityManagerInterface $em,
private LoggerInterface $logger,
/** @var string[] */
private array $webhooks,
private WebhookOptions $webhookOptions,
private DataTransformerInterface $transformer,
private AppOptions $appOptions
private AppOptions $appOptions,
) {
}
public function __invoke(VisitLocated $shortUrlLocated): void
{
if (empty($this->webhooks)) {
if (! $this->webhookOptions->hasWebhooks()) {
return;
}
@@ -52,6 +50,10 @@ class NotifyVisitToWebHooks
return;
}
if ($visit->isOrphan() && ! $this->webhookOptions->notifyOrphanVisits()) {
return;
}
$requestOptions = $this->buildRequestOptions($visit);
$requestPromises = $this->performRequests($requestOptions, $visitId);
@@ -61,15 +63,16 @@ class NotifyVisitToWebHooks
private function buildRequestOptions(Visit $visit): array
{
$payload = ['visit' => $visit->jsonSerialize()];
$shortUrl = $visit->getShortUrl();
if ($shortUrl !== null) {
$payload['shortUrl'] = $this->transformer->transform($shortUrl);
}
return [
RequestOptions::TIMEOUT => 10,
RequestOptions::HEADERS => [
'User-Agent' => (string) $this->appOptions,
],
RequestOptions::JSON => [
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
'visit' => $visit->jsonSerialize(),
],
RequestOptions::JSON => $payload,
RequestOptions::HEADERS => ['User-Agent' => $this->appOptions->__toString()],
];
}
@@ -78,13 +81,11 @@ class NotifyVisitToWebHooks
*/
private function performRequests(array $requestOptions, string $visitId): array
{
$logWebhookFailure = Closure::fromCallable([$this, 'logWebhookFailure']);
return map(
$this->webhooks,
$this->webhookOptions->webhooks(),
fn (string $webhook): PromiseInterface => $this->httpClient
->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions)
->otherwise(partial_left($logWebhookFailure, $webhook, $visitId)),
->otherwise(fn (Throwable $e) => $this->logWebhookFailure($webhook, $visitId, $e)),
);
}

View File

@@ -26,7 +26,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private EntityManagerInterface $em,
private ShortUrlRelationResolverInterface $relationResolver,
private ShortCodeHelperInterface $shortCodeHelper,
private DoctrineBatchHelperInterface $batchHelper
private DoctrineBatchHelperInterface $batchHelper,
) {
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class);
}

View File

@@ -20,7 +20,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
public function __construct(
private DataTransformerInterface $shortUrlTransformer,
private DataTransformerInterface $orphanVisitTransformer
private DataTransformerInterface $orphanVisitTransformer,
) {
}

View File

@@ -32,6 +32,8 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
private ?bool $validateUrl = null;
private bool $crawlablePropWasProvided = false;
private bool $crawlable = false;
private bool $forwardQueryPropWasProvided = false;
private bool $forwardQuery = true;
private function __construct()
{
@@ -64,6 +66,7 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
$this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data);
$this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data);
$this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data);
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data);
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
@@ -73,6 +76,7 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE);
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true;
}
public function longUrl(): ?string
@@ -176,4 +180,14 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
{
return $this->crawlablePropWasProvided;
}
public function forwardQuery(): bool
{
return $this->forwardQuery;
}
public function forwardQueryWasProvided(): bool
{
return $this->forwardQueryPropWasProvided;
}
}

View File

@@ -14,7 +14,7 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
final class ShortUrlMeta implements TitleResolutionModelInterface
{
@@ -32,6 +32,7 @@ final class ShortUrlMeta implements TitleResolutionModelInterface
private ?string $title = null;
private bool $titleWasAutoResolved = false;
private bool $crawlable = false;
private bool $forwardQuery = true;
private function __construct()
{
@@ -82,6 +83,7 @@ final class ShortUrlMeta implements TitleResolutionModelInterface
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE);
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true;
}
public function getLongUrl(): string
@@ -195,4 +197,9 @@ final class ShortUrlMeta implements TitleResolutionModelInterface
{
return $this->crawlable;
}
public function forwardQuery(): bool
{
return $this->forwardQuery;
}
}

View File

@@ -8,6 +8,7 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\parseDateField;
final class ShortUrlsParams
@@ -54,7 +55,7 @@ final class ShortUrlsParams
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
$this->dateRange = new DateRange(
$this->dateRange = buildDateRange(
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
);

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