mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 12:13:13 +08:00
Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
750e6cff45 | ||
|
|
f49e94052d | ||
|
|
ceb642b745 | ||
|
|
ed1d886f01 | ||
|
|
db98d811b0 | ||
|
|
14ba11e1ab | ||
|
|
483bdddb18 | ||
|
|
d16fda3f16 | ||
|
|
c718b94937 | ||
|
|
bb21ab073f | ||
|
|
3ffe530461 | ||
|
|
95cf0d86bc | ||
|
|
9899a5fc56 | ||
|
|
952648185c | ||
|
|
69740493b7 | ||
|
|
994a28f31d | ||
|
|
b0a8a03f0a | ||
|
|
36e740f4cc | ||
|
|
a5874a3f80 | ||
|
|
0c95b978b4 | ||
|
|
e21f9dd1fb | ||
|
|
74a08b86ce | ||
|
|
8212d3c540 | ||
|
|
1ed6458b39 | ||
|
|
60c8f23a63 | ||
|
|
5e627641ea | ||
|
|
abc954aa47 | ||
|
|
3bfa27e682 | ||
|
|
4b7e122254 | ||
|
|
cfd3c13751 | ||
|
|
6a1ee2b894 | ||
|
|
cbec4a4e81 | ||
|
|
c7d8c1cab5 | ||
|
|
c39e1e649d | ||
|
|
95ab64ba77 | ||
|
|
1f8fcdb0f3 | ||
|
|
fb26a8ae50 | ||
|
|
42dbeaa1a5 | ||
|
|
3305f4c03a | ||
|
|
f5beec70c8 | ||
|
|
c2cd21c15e | ||
|
|
633e389275 | ||
|
|
f5aaf298e1 | ||
|
|
7db6136436 | ||
|
|
ce7296eebb | ||
|
|
c6226547f7 | ||
|
|
e7ec8f0489 | ||
|
|
dc466f238b | ||
|
|
f164656874 | ||
|
|
ef3c59152f | ||
|
|
14c6ead389 | ||
|
|
b0d33f3a85 | ||
|
|
066cc20ee6 | ||
|
|
0f51b5b1ce | ||
|
|
ebcf3e0119 | ||
|
|
6ee248d656 | ||
|
|
8a46b410f6 | ||
|
|
cd06cea153 | ||
|
|
8393d44c50 | ||
|
|
3e8ce80f80 | ||
|
|
80e033c91d | ||
|
|
a7dd441333 | ||
|
|
48efaa9fd7 | ||
|
|
92e831175f | ||
|
|
9b75e076b5 | ||
|
|
2c5d6d1651 | ||
|
|
c5cf116f33 | ||
|
|
66a4a9bce6 | ||
|
|
7e7ef64c79 | ||
|
|
9a31f53d4d | ||
|
|
60d6314262 | ||
|
|
eff7445804 | ||
|
|
2bfe21aef4 | ||
|
|
6ae0c7dcfc | ||
|
|
883ac1007a | ||
|
|
ff6747dab5 | ||
|
|
555e6f804c | ||
|
|
98c5c7990f | ||
|
|
27dcdb517d | ||
|
|
916d75d161 | ||
|
|
57bd16f4f5 | ||
|
|
444a1756a2 | ||
|
|
0c97c8f04f | ||
|
|
de81e81ecb | ||
|
|
40a7d5a112 | ||
|
|
7c06633a67 | ||
|
|
9abf611d63 | ||
|
|
565fe4c348 | ||
|
|
7b43403b1c | ||
|
|
9f25979b4c | ||
|
|
20f70b8b07 | ||
|
|
8fbf05acd4 | ||
|
|
6860855c71 | ||
|
|
b78660c685 | ||
|
|
6a40bbdcb5 | ||
|
|
5a1a4f5594 | ||
|
|
2ac7be4363 | ||
|
|
4ef5ab7a90 | ||
|
|
192308a6a3 | ||
|
|
c9ce111643 | ||
|
|
32fda231ad | ||
|
|
e4d4686717 | ||
|
|
ca6c6a1b6e | ||
|
|
806c4ce168 | ||
|
|
9d14597be0 | ||
|
|
dc68bb907c | ||
|
|
e4598c058a | ||
|
|
377562cdff | ||
|
|
969fcccc1f | ||
|
|
4c00764146 | ||
|
|
e98ee64695 | ||
|
|
51c7d0ed3e | ||
|
|
db93498ee6 | ||
|
|
b3af493758 | ||
|
|
7b9ebbbb5f | ||
|
|
ea735fc0a0 | ||
|
|
06227e97d0 | ||
|
|
dbc50b6d4f | ||
|
|
8b75ad1e7f | ||
|
|
8f3c740b57 | ||
|
|
24a6a0c23f | ||
|
|
267d72a76c | ||
|
|
021cecc216 | ||
|
|
4642480bbb | ||
|
|
4d48482d1e | ||
|
|
2054784a4a | ||
|
|
57d816b862 | ||
|
|
32bb66c42b | ||
|
|
e4d15e64b6 | ||
|
|
b11daeae7d | ||
|
|
8e78f8527e | ||
|
|
bc385744db | ||
|
|
02fd28edec | ||
|
|
95770ac104 | ||
|
|
2eeb762cd9 | ||
|
|
de5666d262 | ||
|
|
934d266880 | ||
|
|
b8fa234dbb | ||
|
|
bceea090ed | ||
|
|
8efda2ef56 | ||
|
|
f86cda6730 | ||
|
|
43f59a19fb | ||
|
|
eabaa94e06 | ||
|
|
20575a2b0f | ||
|
|
0096a778ac | ||
|
|
050f83e3bb | ||
|
|
32f7b4fbf6 | ||
|
|
265e8cdeaf | ||
|
|
fe5460e0c5 | ||
|
|
d4cad337fc | ||
|
|
0af6ecbd34 | ||
|
|
6466045363 | ||
|
|
67c7e503d9 | ||
|
|
01e06f0503 | ||
|
|
d6e155d874 | ||
|
|
5a2350bac1 | ||
|
|
2b97f9ac9e | ||
|
|
090b215179 | ||
|
|
32f483c333 | ||
|
|
655652f94f | ||
|
|
53b84c147c | ||
|
|
d8b4827601 | ||
|
|
5737acf759 | ||
|
|
58262e8604 | ||
|
|
b9e5eaf689 | ||
|
|
6d78cd59e9 | ||
|
|
aa00e33b6d | ||
|
|
4ef04c641e | ||
|
|
bfcccd8c33 | ||
|
|
f7d3c73c4a | ||
|
|
bfdece1c23 | ||
|
|
a68f450d36 | ||
|
|
d1df225e47 | ||
|
|
9c6ba4bc61 | ||
|
|
c01121d61a | ||
|
|
e0f0bb5523 |
99
.github/workflows/ci.yml
vendored
99
.github/workflows/ci.yml
vendored
@@ -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: ['7.4', '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,13 +58,16 @@ 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 == '7.4' }}
|
||||
if: ${{ matrix.php-version == '8.0' }}
|
||||
with:
|
||||
name: coverage-unit
|
||||
path: |
|
||||
@@ -74,7 +78,8 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '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,13 +88,16 @@ 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 == '7.4' }}
|
||||
if: ${{ matrix.php-version == '8.0' }}
|
||||
with:
|
||||
name: coverage-db
|
||||
path: |
|
||||
@@ -100,7 +108,8 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '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: ['7.4', '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: ['7.4', '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: ['7.4', '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: ['7.4', '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,13 +229,16 @@ 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 == '7.4' }}
|
||||
if: ${{ matrix.php-version == '8.0' }}
|
||||
with:
|
||||
name: coverage-api
|
||||
path: |
|
||||
@@ -216,8 +253,9 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '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
|
||||
|
||||
5
.github/workflows/publish-release.yml
vendored
5
.github/workflows/publish-release.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
swoole: ['yes', 'no']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -43,7 +43,6 @@ jobs:
|
||||
uses: docker://antonyurchenko/git-release:latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ALLOW_TAG_PREFIX: "true"
|
||||
ALLOW_EMPTY_CHANGELOG: "true"
|
||||
with:
|
||||
args: |
|
||||
@@ -54,7 +53,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: [ '7.4', '8.0' ]
|
||||
php-version: [ '8.0' ]
|
||||
swoole: [ 'yes', 'no' ]
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v1
|
||||
|
||||
143
CHANGELOG.md
143
CHANGELOG.md
@@ -4,6 +4,149 @@ 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.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`.
|
||||
* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes.
|
||||
|
||||
Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High.
|
||||
|
||||
* [#1080](https://github.com/shlinkio/shlink/issues/1080) Added support to redirect to URLs as soon as the path starts with a valid short code, appending the rest of the path to the redirected long URL.
|
||||
|
||||
With this, if you have the `https://example.com/abc123` short URL redirecting to `https://www.twitter.com`, a visit to `https://example.com/abc123/shlinkio` will take you to `https://www.twitter.com/shlinkio`.
|
||||
|
||||
This behavior needs to be actively opted in, via installer config options or env vars.
|
||||
|
||||
* [#943](https://github.com/shlinkio/shlink/issues/943) Added support to define different "not-found" redirects for every domain handled by Shlink.
|
||||
|
||||
Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command or the `PATCH /domains/redirects` REST endpoint to define specific values for every single domain.
|
||||
|
||||
### Changed
|
||||
* [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8.
|
||||
* [#1127](https://github.com/shlinkio/shlink/issues/1127) Updated to infection 0.24.
|
||||
* [#1139](https://github.com/shlinkio/shlink/issues/1139) Updated project dependencies, including base docker image to use PHP 8.0.9 and Alpine 3.14.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
|
||||
|
||||
### Fixed
|
||||
* [#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
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1135](https://github.com/shlinkio/shlink/issues/1135) Fixed error when importing short URLs with no visits from another Shlink instance.
|
||||
* [#1136](https://github.com/shlinkio/shlink/issues/1136) Fixed error when fetching tag/short-url/orphan visits for a page lower than 1.
|
||||
|
||||
|
||||
## [2.7.2] - 2021-07-30
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1128](https://github.com/shlinkio/shlink/issues/1128) Increased memory limit reserved for the docker image, preventing it from crashing on GeoLite db download.
|
||||
|
||||
|
||||
## [2.7.1] - 2021-05-30
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1100](https://github.com/shlinkio/shlink/issues/1100) Fixed Shlink trying to download GeoLite2 db files even when tracking has been disabled.
|
||||
|
||||
|
||||
## [2.7.0] - 2021-05-23
|
||||
### Added
|
||||
* [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows.
|
||||
|
||||
@@ -121,7 +121,7 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
|
||||
For example, `test:db:postgres`.
|
||||
|
||||
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
|
||||
* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
|
||||
* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
|
||||
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
|
||||
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.
|
||||
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -1,10 +1,10 @@
|
||||
FROM php:8.0.6-alpine3.13 as base
|
||||
FROM php:8.0.9-alpine3.14 as base
|
||||
|
||||
ARG SHLINK_VERSION=latest
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.6.7
|
||||
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,14 +68,19 @@ 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
|
||||
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
|
||||
|
||||
# Change the ownership of /etc/shlink/data to be writable, then change the user to non-root
|
||||
# FIXME Disabled for now, as it conflicts with ENABLE_PERIODIC_VISIT_LOCATE, which is used to configure a cron as root.
|
||||
# Ref: https://github.com/shlinkio/shlink/issues/1132
|
||||
#RUN chown 1001 /etc/shlink/data
|
||||
#RUN chown 1001 /etc/shlink/data/locks
|
||||
#RUN chown 1001 /etc/shlink/data/proxies
|
||||
#RUN chown 1001 /etc/shlink/data/cache
|
||||
#RUN chown 1001 /etc/shlink/data/log
|
||||
#USER 1001
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]
|
||||
|
||||
@@ -33,7 +33,7 @@ The idea is that you can just generate a container using the image and provide t
|
||||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 7.4 or 8.0
|
||||
* PHP 8.0
|
||||
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
|
||||
* apcu extension is recommended if you don't plan to use swoole.
|
||||
* xml extension is required if you want to generate QR codes in svg format.
|
||||
|
||||
7
bin/cli
7
bin/cli
@@ -3,5 +3,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$run = require __DIR__ . '/../config/run.php';
|
||||
$run(true);
|
||||
use Symfony\Component\Console\Application;
|
||||
|
||||
/** @var Application $app */
|
||||
$app = require __DIR__ . '/../config/cli-app.php';
|
||||
$app->run();
|
||||
|
||||
@@ -12,68 +12,70 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0",
|
||||
"php": "^8.0",
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^2.0",
|
||||
"cakephp/chronos": "^2.0",
|
||||
"cakephp/chronos": "^2.2",
|
||||
"cocur/slugify": "^4.0",
|
||||
"doctrine/cache": "^1.9",
|
||||
"doctrine/migrations": "^3.1.1",
|
||||
"doctrine/orm": "^2.8.4",
|
||||
"endroid/qr-code": "^4.0",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^7.0",
|
||||
"doctrine/migrations": "^3.2",
|
||||
"doctrine/orm": "^2.9",
|
||||
"endroid/qr-code": "^4.2",
|
||||
"geoip2/geoip2": "^2.11",
|
||||
"guzzlehttp/guzzle": "^7.3",
|
||||
"happyr/doctrine-specification": "^2.0",
|
||||
"jaybizzle/crawler-detect": "^1.2",
|
||||
"laminas/laminas-config": "^3.3",
|
||||
"laminas/laminas-config-aggregator": "^1.1",
|
||||
"laminas/laminas-diactoros": "^2.1.3",
|
||||
"laminas/laminas-inputfilter": "^2.10",
|
||||
"laminas/laminas-servicemanager": "^3.6",
|
||||
"laminas/laminas-stdlib": "^3.2",
|
||||
"lcobucci/jwt": "^4.0",
|
||||
"league/uri": "^6.2",
|
||||
"laminas/laminas-config": "^3.5",
|
||||
"laminas/laminas-config-aggregator": "^1.5",
|
||||
"laminas/laminas-diactoros": "^2.6",
|
||||
"laminas/laminas-inputfilter": "^2.12",
|
||||
"laminas/laminas-servicemanager": "^3.7",
|
||||
"laminas/laminas-stdlib": "^3.5",
|
||||
"lcobucci/jwt": "^4.1",
|
||||
"league/uri": "^6.4",
|
||||
"lstrojny/functional-php": "^1.17",
|
||||
"mezzio/mezzio": "^3.3",
|
||||
"mezzio/mezzio-fastroute": "^3.1",
|
||||
"mezzio/mezzio-problem-details": "^1.3",
|
||||
"mezzio/mezzio": "^3.5",
|
||||
"mezzio/mezzio-fastroute": "^3.2",
|
||||
"mezzio/mezzio-problem-details": "^1.4",
|
||||
"mezzio/mezzio-swoole": "^3.3",
|
||||
"monolog/monolog": "^2.0",
|
||||
"monolog/monolog": "^2.3",
|
||||
"nikolaposa/monolog-factory": "^3.1",
|
||||
"ocramius/proxy-manager": "^2.11",
|
||||
"pagerfanta/core": "^2.5",
|
||||
"pagerfanta/core": "^2.7",
|
||||
"php-middleware/request-id": "^4.1",
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.7",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "^3.7",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"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",
|
||||
"shlinkio/shlink-installer": "^6.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.5",
|
||||
"symfony/console": "^5.1",
|
||||
"symfony/filesystem": "^5.1",
|
||||
"symfony/lock": "^5.1",
|
||||
"symfony/mercure": "^0.5.1",
|
||||
"symfony/process": "^5.1",
|
||||
"symfony/string": "^5.1"
|
||||
"shlinkio/shlink-importer": "^2.3.1",
|
||||
"shlinkio/shlink-installer": "^6.2",
|
||||
"shlinkio/shlink-ip-geolocation": "^2.0",
|
||||
"symfony/console": "^5.3",
|
||||
"symfony/filesystem": "^5.3",
|
||||
"symfony/lock": "^5.3",
|
||||
"symfony/mercure": "^0.5.3",
|
||||
"symfony/process": "^5.3",
|
||||
"symfony/string": "^5.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"devster/ubench": "^2.1",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.1",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.3.0",
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"infection/infection": "^0.21.0",
|
||||
"infection/infection": "^0.25.0",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpstan/phpstan": "^0.12.64",
|
||||
"phpstan/phpstan": "^0.12.94",
|
||||
"phpstan/phpstan-doctrine": "^0.12.42",
|
||||
"phpstan/phpstan-symfony": "^0.12.41",
|
||||
"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.1",
|
||||
"symfony/var-dumper": "^5.2",
|
||||
"veewee/composer-run-parallel": "^0.1.0"
|
||||
"shlinkio/php-coding-standard": "~2.2.0",
|
||||
"shlinkio/shlink-test-utils": "^2.2",
|
||||
"symfony/var-dumper": "^5.3",
|
||||
"veewee/composer-run-parallel": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -82,6 +84,7 @@
|
||||
"Shlinkio\\Shlink\\Core\\": "module/Core/src"
|
||||
},
|
||||
"files": [
|
||||
"config/constants.php",
|
||||
"module/Core/functions/functions.php"
|
||||
]
|
||||
},
|
||||
@@ -112,7 +115,7 @@
|
||||
],
|
||||
"cs": "phpcs",
|
||||
"cs:fix": "phpcbf",
|
||||
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
|
||||
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8",
|
||||
"test": [
|
||||
"@test:unit",
|
||||
"@test:db",
|
||||
@@ -134,7 +137,7 @@
|
||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
|
||||
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests",
|
||||
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
|
||||
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
|
||||
"infect:ci": "@parallel infect:ci:unit infect:ci:db",
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
];
|
||||
})();
|
||||
|
||||
@@ -10,6 +10,8 @@ return [
|
||||
'password' => 'root',
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => 'shlink_db',
|
||||
'dbname' => 'shlink',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
@@ -42,13 +43,19 @@ return [
|
||||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
|
||||
Option\UrlShortener\AppendExtraPathConfigOption::class,
|
||||
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' => [
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
];
|
||||
})();
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
@@ -68,6 +68,7 @@ return [
|
||||
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
|
||||
IpAddress::class,
|
||||
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
|
||||
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
|
||||
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
|
||||
Core\ErrorHandler\NotFoundRedirectHandler::class,
|
||||
Core\ErrorHandler\NotFoundTemplateHandler::class,
|
||||
|
||||
21
config/autoload/qr-codes.global.php
Normal file
21
config/autoload/qr-codes.global.php
Normal 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),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
21
config/autoload/redis.global.php
Normal file
21
config/autoload/redis.global.php
Normal 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'),
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
})();
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
return [
|
||||
|
||||
'mezzio-swoole' => [
|
||||
@@ -10,11 +12,12 @@ return [
|
||||
|
||||
'swoole-http-server' => [
|
||||
'host' => '0.0.0.0',
|
||||
'port' => (int) env('PORT', 8080),
|
||||
'process-name' => 'shlink',
|
||||
|
||||
'options' => [
|
||||
'worker_num' => 16,
|
||||
'task_worker_num' => 16,
|
||||
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
|
||||
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -2,23 +2,29 @@
|
||||
|
||||
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;
|
||||
$useHttps = env('USE_HTTPS'); // Deprecated. For v3, set this to true by default, instead of null
|
||||
|
||||
return [
|
||||
|
||||
'url_shortener' => [
|
||||
'domain' => [
|
||||
// Deprecated SHORT_DOMAIN_* env vars
|
||||
'schema' => $useHttps !== null ? (bool) $useHttps : env('SHORT_DOMAIN_SCHEMA', 'http'),
|
||||
'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,
|
||||
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
||||
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
'auto_resolve_titles' => false,
|
||||
],
|
||||
|
||||
];
|
||||
];
|
||||
})();
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$isSwoole = extension_loaded('swoole');
|
||||
|
||||
return [
|
||||
|
||||
'url_shortener' => [
|
||||
'domain' => [
|
||||
'schema' => 'http',
|
||||
'hostname' => 'localhost:8080',
|
||||
'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'),
|
||||
],
|
||||
'auto_resolve_titles' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
19
config/autoload/webhooks.global.php
Normal file
19
config/autoload/webhooks.global.php
Normal 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),
|
||||
],
|
||||
|
||||
];
|
||||
})();
|
||||
12
config/cli-app.php
Normal file
12
config/cli-app.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as CliApp;
|
||||
|
||||
return (static function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
return $container->get(CliApp::class);
|
||||
})();
|
||||
@@ -4,12 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Tools\Console\ConsoleRunner;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return (function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
$em = $container->get(EntityManager::class);
|
||||
|
||||
return (static function () {
|
||||
/** @var EntityManager $em */
|
||||
$em = include __DIR__ . '/entity-manager.php';
|
||||
return ConsoleRunner::createHelperSet($em);
|
||||
})();
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
config/constants.php
Normal file
20
config/constants.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?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';
|
||||
@@ -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__));
|
||||
|
||||
|
||||
12
config/entity-manager.php
Normal file
12
config/entity-manager.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return (static function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
return $container->get(EntityManager::class);
|
||||
})();
|
||||
@@ -4,12 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
use Mezzio\Application;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as CliApp;
|
||||
|
||||
return function (bool $isCli = false): void {
|
||||
return static function (): void {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
$app = $container->get($isCli ? CliApp::class : Application::class);
|
||||
$app = $container->get(Application::class);
|
||||
|
||||
$app->run();
|
||||
};
|
||||
|
||||
@@ -35,26 +35,17 @@ if ($isApiTest) {
|
||||
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
|
||||
}
|
||||
|
||||
$buildDbConnection = function (): array {
|
||||
$buildDbConnection = static function (): array {
|
||||
$driver = env('DB_DRIVER', 'sqlite');
|
||||
$isCi = env('CI', false);
|
||||
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
||||
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
||||
$getMysqlHost = static fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
||||
$getCiMysqlPort = static fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
||||
|
||||
$driverConfigMap = [
|
||||
return match ($driver) {
|
||||
'sqlite' => [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => sys_get_temp_dir() . '/shlink-tests.db',
|
||||
],
|
||||
'mysql' => [
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
|
||||
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
|
||||
'user' => 'root',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
|
||||
@@ -71,10 +62,16 @@ $buildDbConnection = function (): array {
|
||||
'password' => 'Passw0rd!',
|
||||
'dbname' => 'shlink_test',
|
||||
],
|
||||
];
|
||||
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
|
||||
|
||||
return $driverConfigMap[$driver] ?? [];
|
||||
default => [ // mysql and maria
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
|
||||
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
|
||||
'user' => 'root',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [
|
||||
@@ -120,7 +117,7 @@ return [
|
||||
'name' => 'start_collecting_coverage',
|
||||
'path' => '/api-tests/start-coverage',
|
||||
'middleware' => middleware(static function () use (&$coverage) {
|
||||
if ($coverage) {
|
||||
if ($coverage) { // @phpstan-ignore-line
|
||||
$coverage->start('API tests');
|
||||
}
|
||||
return new EmptyResponse();
|
||||
@@ -131,7 +128,7 @@ return [
|
||||
'name' => 'dump_coverage',
|
||||
'path' => '/api-tests/stop-coverage',
|
||||
'middleware' => middleware(static function () use (&$coverage) {
|
||||
if ($coverage) {
|
||||
if ($coverage) { // @phpstan-ignore-line
|
||||
$basePath = __DIR__ . '/../../build/coverage-api';
|
||||
$coverage->stop();
|
||||
(new PHP())->process($coverage, $basePath . '.cov');
|
||||
|
||||
@@ -11,7 +11,7 @@ server {
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
|
||||
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi.conf;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM php:8.0.6-fpm-alpine3.13
|
||||
FROM php:8.0.9-fpm-alpine3.14
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.19
|
||||
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
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
FROM php:8.0.6-alpine3.13
|
||||
FROM php:8.0.9-alpine3.14
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.19
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV APCU_VERSION 5.1.20
|
||||
ENV INOTIFY_VERSION 3.0.0
|
||||
ENV SWOOLE_VERSION 4.6.7
|
||||
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
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ final class Version20180913205455 extends AbstractMigration
|
||||
|
||||
try {
|
||||
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
41
data/migrations/Version20210720143824.php
Normal file
41
data/migrations/Version20210720143824.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Schema\Table;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210720143824 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$domainsTable = $schema->getTable('domains');
|
||||
$this->skipIf($domainsTable->hasColumn('base_url_redirect'));
|
||||
|
||||
$this->createRedirectColumn($domainsTable, 'base_url_redirect');
|
||||
$this->createRedirectColumn($domainsTable, 'regular_not_found_redirect');
|
||||
$this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect');
|
||||
}
|
||||
|
||||
private function createRedirectColumn(Table $table, string $columnName): void
|
||||
{
|
||||
$table->addColumn($columnName, Types::STRING, [
|
||||
'notnull' => false,
|
||||
'default' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$domainsTable = $schema->getTable('domains');
|
||||
$this->skipIf(! $domainsTable->hasColumn('base_url_redirect'));
|
||||
|
||||
$domainsTable->dropColumn('base_url_redirect');
|
||||
$domainsTable->dropColumn('regular_not_found_redirect');
|
||||
$domainsTable->dropColumn('invalid_short_url_redirect');
|
||||
}
|
||||
}
|
||||
26
data/migrations/Version20211002072605.php
Normal file
26
data/migrations/Version20211002072605.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
log_errors_max_len=0
|
||||
zend.assertions=1
|
||||
assert.exception=1
|
||||
memory_limit=256M
|
||||
|
||||
@@ -7,127 +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(),
|
||||
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
||||
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
||||
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', 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' => [
|
||||
@@ -142,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 the default value
|
||||
],
|
||||
|
||||
'mercure' => $helper->getMercureConfig(),
|
||||
|
||||
];
|
||||
|
||||
@@ -21,6 +21,15 @@ if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
|
||||
php bin/cli visit:download-db -n -q
|
||||
fi
|
||||
|
||||
# Periodicaly run visit:locate every hour
|
||||
# https://shlink.io/documentation/long-running-tasks/#locate-visits
|
||||
# set env var "ENABLE_PERIODIC_VISIT_LOCATE=true" to enable
|
||||
if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
|
||||
echo "Configuring periodic visit locate..."
|
||||
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
|
||||
/usr/sbin/crond &
|
||||
fi
|
||||
|
||||
# When restarting the container, swoole might think it is already in execution
|
||||
# This forces the app to be started every second until the exit code is 0
|
||||
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
|
||||
|
||||
59
docs/adr/2021-08-05-migrate-to-a-new-caching-library.md
Normal file
59
docs/adr/2021-08-05-migrate-to-a-new-caching-library.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
20
docs/swagger/definitions/NotFoundRedirects.json
Normal file
20
docs/swagger/definitions/NotFoundRedirects.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrlRedirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits the domain's base URL"
|
||||
},
|
||||
"regular404Redirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits a not found URL other than an invalid short URL"
|
||||
},
|
||||
"invalidShortUrlRedirect": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "URL to redirect to when a user hits an invalid short URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
48
docs/swagger/definitions/ShortUrlEdition.json
Normal file
48
docs/swagger/definitions/ShortUrlEdition.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The list of tags",
|
||||
"description": "The list of domains",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -33,13 +33,16 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["domain", "isDefault"],
|
||||
"required": ["domain", "isDefault", "redirects"],
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"redirects": {
|
||||
"$ref": "../definitions/NotFoundRedirects.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,15 +59,30 @@
|
||||
"data": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"isDefault": true
|
||||
"isDefault": true,
|
||||
"redirects": {
|
||||
"baseUrlRedirect": "https://example.com/my-landing-page",
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||
}
|
||||
},
|
||||
{
|
||||
"domain": "aaa.com",
|
||||
"isDefault": false
|
||||
"isDefault": false,
|
||||
"redirects": {
|
||||
"baseUrlRedirect": null,
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"domain": "bbb.com",
|
||||
"isDefault": false
|
||||
"isDefault": false,
|
||||
"redirects": {
|
||||
"baseUrlRedirect": null,
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
124
docs/swagger/paths/v2_domains_redirects.json
Normal file
124
docs/swagger/paths/v2_domains_redirects.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"patch": {
|
||||
"operationId": "setDomainRedirects",
|
||||
"tags": [
|
||||
"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",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Request body.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"required": ["domain"],
|
||||
"properties": {
|
||||
"domain": {
|
||||
"description": "The domain's authority for which you want to set redirects",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../definitions/NotFoundRedirects.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The domain's redirects after the update, when existing redirects have been merged with provided ones.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"required": ["baseUrlRedirect", "regular404Redirect", "invalidShortUrlRedirect"]
|
||||
},
|
||||
{
|
||||
"$ref": "../definitions/NotFoundRedirects.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"baseUrlRedirect": "https://example.com/my-landing-page",
|
||||
"regular404Redirect": null,
|
||||
"invalidShortUrlRedirect": "https://example.com/invalid-url"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Provided data is invalid.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["invalidElements"],
|
||||
"properties": {
|
||||
"invalidElements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"domain",
|
||||
"baseUrlRedirect",
|
||||
"regular404Redirect",
|
||||
"invalidShortUrlRedirect"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Default domain was provided, and it cannot be edited this way.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"URL Shortener"
|
||||
],
|
||||
"summary": "Short URL QR code",
|
||||
"description": "Generates a QR code image pointing to a short URL",
|
||||
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
@@ -35,10 +35,8 @@
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"png",
|
||||
"svg"
|
||||
]
|
||||
"enum": ["png", "svg"],
|
||||
"default": "png"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -51,6 +49,17 @@
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "errorCorrection",
|
||||
"in": "query",
|
||||
"description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["L", "M", "Q", "H"],
|
||||
"default": "L"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Shlink",
|
||||
"description": "Shlink, the self-hosted URL shortener",
|
||||
@@ -102,6 +102,9 @@
|
||||
"/rest/v{version}/domains": {
|
||||
"$ref": "paths/v2_domains.json"
|
||||
},
|
||||
"/rest/v{version}/domains/redirects": {
|
||||
"$ref": "paths/v2_domains_redirects.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/mercure-info": {
|
||||
"$ref": "paths/v2_mercure-info.json"
|
||||
|
||||
@@ -27,6 +27,7 @@ return [
|
||||
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
|
||||
|
||||
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class,
|
||||
Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class,
|
||||
|
||||
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
|
||||
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
|
||||
|
||||
@@ -10,6 +10,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainService;
|
||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
|
||||
@@ -23,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 [
|
||||
|
||||
@@ -60,11 +61,17 @@ return [
|
||||
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
|
||||
Util\GeolocationDbUpdater::class => [
|
||||
DbUpdater::class,
|
||||
Reader::class,
|
||||
LOCAL_LOCK_FACTORY,
|
||||
TrackingOptions::class,
|
||||
],
|
||||
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
|
||||
ApiKey\RoleResolver::class => [DomainService::class],
|
||||
|
||||
@@ -98,6 +105,7 @@ return [
|
||||
Command\Tag\DeleteTagsCommand::class => [TagService::class],
|
||||
|
||||
Command\Domain\ListDomainsCommand::class => [DomainService::class],
|
||||
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
|
||||
@@ -8,13 +8,12 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function is_string;
|
||||
|
||||
class RoleResolver implements RoleResolverInterface
|
||||
{
|
||||
private DomainServiceInterface $domainService;
|
||||
|
||||
public function __construct(DomainServiceInterface $domainService)
|
||||
public function __construct(private DomainServiceInterface $domainService)
|
||||
{
|
||||
$this->domainService = $domainService;
|
||||
}
|
||||
|
||||
public function determineRoles(InputInterface $input): array
|
||||
@@ -26,7 +25,7 @@ class RoleResolver implements RoleResolverInterface
|
||||
if ($author) {
|
||||
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
|
||||
}
|
||||
if ($domainAuthority !== null) {
|
||||
if (is_string($domainAuthority)) {
|
||||
$domain = $this->domainService->getOrCreate($domainAuthority);
|
||||
$roleDefinitions[] = RoleDefinition::forDomain($domain);
|
||||
}
|
||||
|
||||
@@ -19,12 +19,9 @@ class DisableKeyCommand extends Command
|
||||
{
|
||||
public const NAME = 'api-key:disable';
|
||||
|
||||
private ApiKeyServiceInterface $apiKeyService;
|
||||
|
||||
public function __construct(ApiKeyServiceInterface $apiKeyService)
|
||||
public function __construct(private ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
@@ -23,14 +23,11 @@ class GenerateKeyCommand extends BaseCommand
|
||||
{
|
||||
public const NAME = 'api-key:generate';
|
||||
|
||||
private ApiKeyServiceInterface $apiKeyService;
|
||||
private RoleResolverInterface $roleResolver;
|
||||
|
||||
public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
|
||||
{
|
||||
public function __construct(
|
||||
private ApiKeyServiceInterface $apiKeyService,
|
||||
private RoleResolverInterface $roleResolver,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
$this->roleResolver = $roleResolver;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -100,7 +97,7 @@ class GenerateKeyCommand extends BaseCommand
|
||||
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
|
||||
|
||||
if (! $apiKey->isAdmin()) {
|
||||
ShlinkTable::fromOutput($io)->render(
|
||||
ShlinkTable::default($io)->render(
|
||||
['Role name', 'Role metadata'],
|
||||
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
|
||||
null,
|
||||
|
||||
@@ -27,12 +27,9 @@ class ListKeysCommand extends BaseCommand
|
||||
|
||||
public const NAME = 'api-key:list';
|
||||
|
||||
private ApiKeyServiceInterface $apiKeyService;
|
||||
|
||||
public function __construct(ApiKeyServiceInterface $apiKeyService)
|
||||
public function __construct(private ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -61,7 +58,7 @@ class ListKeysCommand extends BaseCommand
|
||||
if (! $enabledOnly) {
|
||||
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
||||
}
|
||||
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
|
||||
$rowData[] = $expiration?->toAtomString() ?? '-';
|
||||
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
|
||||
fn (string $roleName, array $meta) =>
|
||||
empty($meta)
|
||||
@@ -72,7 +69,7 @@ class ListKeysCommand extends BaseCommand
|
||||
return $rowData;
|
||||
});
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(array_filter([
|
||||
ShlinkTable::withRowSeparators($output)->render(array_filter([
|
||||
'Key',
|
||||
'Name',
|
||||
! $enabledOnly ? 'Is enabled' : null,
|
||||
|
||||
@@ -12,40 +12,36 @@ use function Shlinkio\Shlink\Core\kebabCaseToCamelCase;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
|
||||
/** @deprecated */
|
||||
abstract class BaseCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @param mixed|null $default
|
||||
* @param string|string[]|bool|null $default
|
||||
*/
|
||||
protected function addOptionWithDeprecatedFallback(
|
||||
string $name,
|
||||
?string $shortcut = null,
|
||||
?int $mode = null,
|
||||
string $description = '',
|
||||
$default = null
|
||||
bool|string|array|null $default = null,
|
||||
): self {
|
||||
$this->addOption($name, $shortcut, $mode, $description, $default);
|
||||
|
||||
if (str_contains($name, '-')) {
|
||||
$camelCaseName = kebabCaseToCamelCase($name);
|
||||
$this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Same as "%s".', $name), $default);
|
||||
$this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Alias for "%s".', $name), $default);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|string|string[]|null
|
||||
*/
|
||||
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name)
|
||||
// @phpstan-ignore-next-line
|
||||
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name) // phpcs:ignore
|
||||
{
|
||||
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
|
||||
$camelCaseName = kebabCaseToCamelCase($name);
|
||||
$resolvedOptionName = str_contains($rawInput, $camelCaseName) ? $camelCaseName : $name;
|
||||
|
||||
if (str_contains($rawInput, $camelCaseName)) {
|
||||
return $input->getOption($camelCaseName);
|
||||
}
|
||||
|
||||
return $input->getOption($name);
|
||||
return $input->getOption($resolvedOptionName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,16 +13,14 @@ use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||
{
|
||||
private ProcessRunnerInterface $processRunner;
|
||||
private string $phpBinary;
|
||||
|
||||
public function __construct(
|
||||
LockFactory $locker,
|
||||
ProcessRunnerInterface $processRunner,
|
||||
PhpExecutableFinder $phpFinder
|
||||
private ProcessRunnerInterface $processRunner,
|
||||
PhpExecutableFinder $phpFinder,
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
$this->processRunner = $processRunner;
|
||||
$this->phpBinary = $phpFinder->find(false) ?: 'php';
|
||||
}
|
||||
|
||||
@@ -34,6 +32,6 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||
|
||||
protected function getLockConfig(): LockedCommandConfig
|
||||
{
|
||||
return LockedCommandConfig::blocking($this->getName());
|
||||
return LockedCommandConfig::blocking($this->getName() ?? static::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,19 +21,14 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
|
||||
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
|
||||
|
||||
private Connection $regularConn;
|
||||
private Connection $noDbNameConn;
|
||||
|
||||
public function __construct(
|
||||
LockFactory $locker,
|
||||
ProcessRunnerInterface $processRunner,
|
||||
PhpExecutableFinder $phpFinder,
|
||||
Connection $conn,
|
||||
Connection $noDbNameConn
|
||||
private Connection $regularConn,
|
||||
private Connection $noDbNameConn,
|
||||
) {
|
||||
parent::__construct($locker, $processRunner, $phpFinder);
|
||||
$this->regularConn = $conn;
|
||||
$this->noDbNameConn = $noDbNameConn;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
114
module/CLI/src/Command/Domain/DomainRedirectsCommand.php
Normal file
114
module/CLI/src/Command/Domain/DomainRedirectsCommand.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Functional\filter;
|
||||
use function Functional\invoke;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
|
||||
class DomainRedirectsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:redirects';
|
||||
|
||||
public function __construct(private DomainServiceInterface $domainService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Set specific "not found" redirects for individual domains.')
|
||||
->addArgument(
|
||||
'domain',
|
||||
InputArgument::REQUIRED,
|
||||
'The domain authority to which you want to set the specific redirects',
|
||||
);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
/** @var string|null $domain */
|
||||
$domain = $input->getArgument('domain');
|
||||
if ($domain !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
|
||||
|
||||
/** @var string[] $availableDomains */
|
||||
$availableDomains = invoke(
|
||||
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()),
|
||||
'toString',
|
||||
);
|
||||
if (empty($availableDomains)) {
|
||||
$input->setArgument('domain', $askNewDomain());
|
||||
return;
|
||||
}
|
||||
|
||||
$selectedOption = $io->choice(
|
||||
'Select the domain to configure',
|
||||
[...$availableDomains, '<options=bold>New domain</>'],
|
||||
);
|
||||
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$domainAuthority = $input->getArgument('domain');
|
||||
$domain = $this->domainService->findByAuthority($domainAuthority);
|
||||
|
||||
$ask = static function (string $message, ?string $current) use ($io): ?string {
|
||||
if ($current === null) {
|
||||
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
|
||||
}
|
||||
|
||||
$choice = $io->choice($message, [
|
||||
sprintf('Keep current one: [%s]', $current),
|
||||
'Set new redirect URL',
|
||||
'Remove redirect',
|
||||
]);
|
||||
|
||||
return match ($choice) {
|
||||
'Set new redirect URL' => $io->ask('New redirect URL'),
|
||||
'Remove redirect' => null,
|
||||
default => $current,
|
||||
};
|
||||
};
|
||||
|
||||
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
|
||||
$ask(
|
||||
'URL to redirect to when a user hits this domain\'s base URL',
|
||||
$domain?->baseUrlRedirect(),
|
||||
),
|
||||
$ask(
|
||||
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
|
||||
$domain?->regular404Redirect(),
|
||||
),
|
||||
$ask(
|
||||
'URL to redirect to when a user hits an invalid short URL',
|
||||
$domain?->invalidShortUrlRedirect(),
|
||||
),
|
||||
));
|
||||
|
||||
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function Functional\map;
|
||||
@@ -18,30 +20,58 @@ class ListDomainsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:list';
|
||||
|
||||
private DomainServiceInterface $domainService;
|
||||
|
||||
public function __construct(DomainServiceInterface $domainService)
|
||||
public function __construct(private DomainServiceInterface $domainService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->domainService = $domainService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('List all domains that have been ever used for some short URL');
|
||||
->setDescription('List all domains that have been ever used for some short URL')
|
||||
->addOption(
|
||||
'show-redirects',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Will display an extra column with the information of the "not found" redirects for every domain.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$domains = $this->domainService->listDomains();
|
||||
$showRedirects = $input->getOption('show-redirects');
|
||||
$commonFields = ['Domain', 'Is default'];
|
||||
$table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(
|
||||
['Domain', 'Is default'],
|
||||
map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
|
||||
$table->render(
|
||||
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
|
||||
map($domains, function (DomainItem $domain) use ($showRedirects) {
|
||||
$commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No'];
|
||||
|
||||
return $showRedirects
|
||||
? [
|
||||
...$commonValues,
|
||||
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig()),
|
||||
]
|
||||
: $commonValues;
|
||||
}),
|
||||
);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
|
||||
{
|
||||
$baseUrl = $config->baseUrlRedirect() ?? 'N/A';
|
||||
$regular404 = $config->regular404Redirect() ?? 'N/A';
|
||||
$invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
|
||||
|
||||
return <<<EOL
|
||||
* Base URL: {$baseUrl}
|
||||
* Regular 404: {$regular404}
|
||||
* Invalid short URL: {$invalidShortUrl}
|
||||
EOL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,9 @@ class DeleteShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:delete';
|
||||
|
||||
private DeleteShortUrlServiceInterface $deleteShortUrlService;
|
||||
|
||||
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->deleteShortUrlService = $deleteShortUrlService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
@@ -30,19 +30,12 @@ class GenerateShortUrlCommand extends BaseCommand
|
||||
{
|
||||
public const NAME = 'short-url:generate';
|
||||
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private ShortUrlStringifierInterface $stringifier;
|
||||
private int $defaultShortCodeLength;
|
||||
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
ShortUrlStringifierInterface $stringifier,
|
||||
int $defaultShortCodeLength
|
||||
private UrlShortenerInterface $urlShortener,
|
||||
private ShortUrlStringifierInterface $stringifier,
|
||||
private int $defaultShortCodeLength,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->stringifier = $stringifier;
|
||||
$this->defaultShortCodeLength = $defaultShortCodeLength;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -111,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.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,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([
|
||||
|
||||
@@ -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,17 +20,15 @@ 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
|
||||
{
|
||||
public const NAME = 'short-url:visits';
|
||||
|
||||
private VisitsStatsHelperInterface $visitsHelper;
|
||||
|
||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
||||
public function __construct(private VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
$this->visitsHelper = $visitsHelper;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -76,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) {
|
||||
@@ -84,7 +81,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
|
||||
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
||||
});
|
||||
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
|
||||
ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
@@ -33,14 +33,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
|
||||
public const NAME = 'short-url:list';
|
||||
|
||||
private ShortUrlServiceInterface $shortUrlService;
|
||||
private DataTransformerInterface $transformer;
|
||||
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer)
|
||||
{
|
||||
public function __construct(
|
||||
private ShortUrlServiceInterface $shortUrlService,
|
||||
private DataTransformerInterface $transformer,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
$this->transformer = $transformer;
|
||||
}
|
||||
|
||||
protected function doConfigure(): void
|
||||
@@ -129,8 +126,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
|
||||
];
|
||||
|
||||
if ($all) {
|
||||
@@ -158,7 +155,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
OutputInterface $output,
|
||||
array $columnsMap,
|
||||
ShortUrlsParams $params,
|
||||
bool $all
|
||||
bool $all,
|
||||
): Paginator {
|
||||
$shortUrls = $this->shortUrlService->listShortUrls($params);
|
||||
|
||||
@@ -167,7 +164,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl));
|
||||
});
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(
|
||||
ShlinkTable::default($output)->render(
|
||||
array_keys($columnsMap),
|
||||
$rows,
|
||||
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||
@@ -203,14 +200,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
}
|
||||
if ($input->getOption('show-api-key')) {
|
||||
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||
(string) $shortUrl->authorApiKey();
|
||||
(string) $shortUrl->authorApiKey();
|
||||
}
|
||||
if ($input->getOption('show-api-key-name')) {
|
||||
$columnsMap['API Key Name'] = static function (array $_, ShortUrl $shortUrl): ?string {
|
||||
$apiKey = $shortUrl->authorApiKey();
|
||||
|
||||
return $apiKey !== null ? $apiKey->name() : null;
|
||||
};
|
||||
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>
|
||||
$shortUrl->authorApiKey()?->name();
|
||||
}
|
||||
|
||||
return $columnsMap;
|
||||
|
||||
@@ -21,12 +21,9 @@ class ResolveUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:parse';
|
||||
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
|
||||
public function __construct(ShortUrlResolverInterface $urlResolver)
|
||||
public function __construct(private ShortUrlResolverInterface $urlResolver)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->urlResolver = $urlResolver;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
@@ -17,12 +17,9 @@ class CreateTagCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:create';
|
||||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
public function __construct(private TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
@@ -16,12 +16,9 @@ class DeleteTagsCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:delete';
|
||||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
public function __construct(private TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
@@ -18,12 +18,9 @@ class ListTagsCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:list';
|
||||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
public function __construct(private TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -35,7 +32,7 @@ class ListTagsCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,9 @@ class RenameTagCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:rename';
|
||||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
public function __construct(private TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
@@ -14,12 +14,9 @@ use function sprintf;
|
||||
|
||||
abstract class AbstractLockedCommand extends Command
|
||||
{
|
||||
private LockFactory $locker;
|
||||
|
||||
public function __construct(LockFactory $locker)
|
||||
public function __construct(private LockFactory $locker)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->locker = $locker;
|
||||
}
|
||||
|
||||
final protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
|
||||
@@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
abstract class AbstractWithDateRangeCommand extends BaseCommand
|
||||
@@ -49,7 +50,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
|
||||
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
|
||||
{
|
||||
$value = $this->getOptionWithDeprecatedFallback($input, $key);
|
||||
if (empty($value)) {
|
||||
if (empty($value) || ! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -63,7 +64,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
|
||||
));
|
||||
|
||||
if ($output->isVeryVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $output);
|
||||
$this->getApplication()?->renderThrowable($e, $output);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -8,15 +8,11 @@ final class LockedCommandConfig
|
||||
{
|
||||
public const DEFAULT_TTL = 600.0; // 10 minutes
|
||||
|
||||
private string $lockName;
|
||||
private bool $isBlocking;
|
||||
private float $ttl;
|
||||
|
||||
private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL)
|
||||
{
|
||||
$this->lockName = $lockName;
|
||||
$this->isBlocking = $isBlocking;
|
||||
$this->ttl = $ttl;
|
||||
private function __construct(
|
||||
private string $lockName,
|
||||
private bool $isBlocking,
|
||||
private float $ttl = self::DEFAULT_TTL,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function blocking(string $lockName): self
|
||||
|
||||
@@ -19,13 +19,11 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
{
|
||||
public const NAME = 'visit:download-db';
|
||||
|
||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
||||
private ?ProgressBar $progressBar = null;
|
||||
|
||||
public function __construct(GeolocationDbUpdaterInterface $dbUpdater)
|
||||
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -47,8 +45,8 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
$this->progressBar = new ProgressBar($io);
|
||||
}, function (int $total, int $downloaded): void {
|
||||
$this->progressBar->setMaxSteps($total);
|
||||
$this->progressBar->setProgress($downloaded);
|
||||
$this->progressBar?->setMaxSteps($total);
|
||||
$this->progressBar?->setProgress($downloaded);
|
||||
});
|
||||
|
||||
if ($this->progressBar === null) {
|
||||
@@ -71,7 +69,7 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
}
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $io);
|
||||
$this->getApplication()?->renderThrowable($e, $io);
|
||||
}
|
||||
|
||||
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
|
||||
|
||||
@@ -30,19 +30,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
{
|
||||
public const NAME = 'visit:locate';
|
||||
|
||||
private VisitLocatorInterface $visitLocator;
|
||||
private IpLocationResolverInterface $ipLocationResolver;
|
||||
|
||||
private SymfonyStyle $io;
|
||||
|
||||
public function __construct(
|
||||
VisitLocatorInterface $visitLocator,
|
||||
IpLocationResolverInterface $ipLocationResolver,
|
||||
LockFactory $locker
|
||||
private VisitLocatorInterface $visitLocator,
|
||||
private IpLocationResolverInterface $ipLocationResolver,
|
||||
LockFactory $locker,
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
$this->visitLocator = $visitLocator;
|
||||
$this->ipLocationResolver = $ipLocationResolver;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -124,7 +119,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
} catch (Throwable $e) {
|
||||
$this->io->error($e->getMessage());
|
||||
if ($this->io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $this->io);
|
||||
$this->getApplication()?->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
@@ -144,7 +139,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
throw IpCannotBeLocatedException::forEmptyAddress();
|
||||
}
|
||||
|
||||
$ipAddr = $visit->getRemoteAddr();
|
||||
$ipAddr = $visit->getRemoteAddr() ?? '';
|
||||
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
|
||||
if ($ipAddr === IpAddress::LOCALHOST) {
|
||||
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
|
||||
@@ -156,7 +151,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
} catch (WrongIpException $e) {
|
||||
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
|
||||
if ($this->io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $this->io);
|
||||
$this->getApplication()?->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
throw IpCannotBeLocatedException::forError($e);
|
||||
@@ -173,7 +168,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
|
||||
private function checkDbUpdate(InputInterface $input): void
|
||||
{
|
||||
$downloadDbCommand = $this->getApplication()->find(DownloadGeoLiteDbCommand::NAME);
|
||||
$cliApp = $this->getApplication();
|
||||
if ($cliApp === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
|
||||
$exitCode = $downloadDbCommand->run($input, $this->io);
|
||||
|
||||
if ($exitCode === ExitCodes::EXIT_FAILURE) {
|
||||
@@ -183,6 +183,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
|
||||
protected function getLockConfig(): LockedCommandConfig
|
||||
{
|
||||
return LockedCommandConfig::nonBlocking($this->getName());
|
||||
return LockedCommandConfig::nonBlocking(self::NAME);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
|
||||
return $e;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $buildEpoch
|
||||
*/
|
||||
public static function withInvalidEpochInOldDb($buildEpoch): self
|
||||
public static function withInvalidEpochInOldDb(mixed $buildEpoch): self
|
||||
{
|
||||
$e = new self(sprintf(
|
||||
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
|
||||
|
||||
@@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
|
||||
use GeoIp2\Database\Reader;
|
||||
use MaxMind\Db\Reader\Metadata;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
@@ -18,15 +19,12 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
{
|
||||
private const LOCK_NAME = 'geolocation-db-update';
|
||||
|
||||
private DbUpdaterInterface $dbUpdater;
|
||||
private Reader $geoLiteDbReader;
|
||||
private LockFactory $locker;
|
||||
|
||||
public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, LockFactory $locker)
|
||||
{
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
$this->geoLiteDbReader = $geoLiteDbReader;
|
||||
$this->locker = $locker;
|
||||
public function __construct(
|
||||
private DbUpdaterInterface $dbUpdater,
|
||||
private Reader $geoLiteDbReader,
|
||||
private LockFactory $locker,
|
||||
private TrackingOptions $trackingOptions,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +32,10 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
*/
|
||||
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void
|
||||
{
|
||||
if ($this->trackingOptions->disableTracking() || $this->trackingOptions->disableIpTracking()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = $this->locker->createLock(self::LOCK_NAME);
|
||||
$lock->acquire(true); // Block until lock is released
|
||||
|
||||
|
||||
@@ -18,12 +18,10 @@ use function str_replace;
|
||||
|
||||
class ProcessRunner implements ProcessRunnerInterface
|
||||
{
|
||||
private ProcessHelper $helper;
|
||||
private Closure $createProcess;
|
||||
|
||||
public function __construct(ProcessHelper $helper, ?callable $createProcess = null)
|
||||
public function __construct(private ProcessHelper $helper, ?callable $createProcess = null)
|
||||
{
|
||||
$this->helper = $helper;
|
||||
$this->createProcess = $createProcess !== null
|
||||
? Closure::fromCallable($createProcess)
|
||||
: static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);
|
||||
|
||||
@@ -5,23 +5,33 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Util;
|
||||
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Helper\TableSeparator;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function Functional\intersperse;
|
||||
|
||||
final class ShlinkTable
|
||||
{
|
||||
private const DEFAULT_STYLE_NAME = 'default';
|
||||
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
|
||||
|
||||
private ?Table $baseTable;
|
||||
|
||||
public function __construct(Table $baseTable)
|
||||
private function __construct(private Table $baseTable, private bool $withRowSeparators)
|
||||
{
|
||||
$this->baseTable = $baseTable;
|
||||
}
|
||||
|
||||
public static function fromOutput(OutputInterface $output): self
|
||||
public static function default(OutputInterface $output): self
|
||||
{
|
||||
return new self(new Table($output));
|
||||
return new self(new Table($output), false);
|
||||
}
|
||||
|
||||
public static function withRowSeparators(OutputInterface $output): self
|
||||
{
|
||||
return new self(new Table($output), true);
|
||||
}
|
||||
|
||||
public static function fromBaseTable(Table $baseTable): self
|
||||
{
|
||||
return new self($baseTable, false);
|
||||
}
|
||||
|
||||
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
|
||||
@@ -29,11 +39,12 @@ final class ShlinkTable
|
||||
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
|
||||
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
|
||||
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
|
||||
$tableRows = $this->withRowSeparators ? intersperse($rows, new TableSeparator()) : $rows;
|
||||
|
||||
$table = clone $this->baseTable;
|
||||
$table->setStyle($style)
|
||||
->setHeaders($headers)
|
||||
->setRows($rows)
|
||||
->setRows($tableRows)
|
||||
->setFooterTitle($footerTitle)
|
||||
->setHeaderTitle($headerTitle)
|
||||
->render();
|
||||
|
||||
@@ -33,10 +33,10 @@ class RoleResolverTest extends TestCase
|
||||
public function properRolesAreResolvedBasedOnInput(
|
||||
InputInterface $input,
|
||||
array $expectedRoles,
|
||||
int $expectedDomainCalls
|
||||
int $expectedDomainCalls,
|
||||
): void {
|
||||
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
|
||||
(new Domain('example.com'))->setId('1'),
|
||||
Domain::withAuthority('example.com')->setId('1'),
|
||||
);
|
||||
|
||||
$result = $this->resolver->determineRoles($input);
|
||||
@@ -47,7 +47,7 @@ class RoleResolverTest extends TestCase
|
||||
|
||||
public function provideRoles(): iterable
|
||||
{
|
||||
$domain = (new Domain('example.com'))->setId('1');
|
||||
$domain = Domain::withAuthority('example.com')->setId('1');
|
||||
$buildInput = function (array $definition): InputInterface {
|
||||
$input = $this->prophesize(InputInterface::class);
|
||||
|
||||
@@ -68,6 +68,21 @@ class RoleResolverTest extends TestCase
|
||||
[RoleDefinition::forDomain($domain)],
|
||||
1,
|
||||
];
|
||||
yield 'false domain role' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => false]),
|
||||
[],
|
||||
0,
|
||||
];
|
||||
yield 'true domain role' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => true]),
|
||||
[],
|
||||
0,
|
||||
];
|
||||
yield 'string array domain role' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => ['foo', 'bar']]),
|
||||
[],
|
||||
0,
|
||||
];
|
||||
yield 'author role only' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
|
||||
[RoleDefinition::forAuthoredShortUrls()],
|
||||
|
||||
@@ -53,7 +53,9 @@ class ListKeysCommandTest extends TestCase
|
||||
| Key | Name | Is enabled | Expiration date | Roles |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
| {$apiKey1} | - | +++ | - | Admin |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
| {$apiKey2} | - | +++ | - | Admin |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
| {$apiKey3} | - | +++ | - | Admin |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
|
||||
@@ -67,6 +69,7 @@ class ListKeysCommandTest extends TestCase
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| {$apiKey1} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| {$apiKey2} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
|
||||
@@ -76,11 +79,13 @@ class ListKeysCommandTest extends TestCase
|
||||
[
|
||||
$apiKey1 = ApiKey::create(),
|
||||
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
|
||||
$apiKey3 = $this->apiKeyWithRoles([RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]),
|
||||
$apiKey3 = $this->apiKeyWithRoles(
|
||||
[RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1'))],
|
||||
),
|
||||
$apiKey4 = ApiKey::create(),
|
||||
$apiKey5 = $this->apiKeyWithRoles([
|
||||
RoleDefinition::forAuthoredShortUrls(),
|
||||
RoleDefinition::forDomain((new Domain('example.com'))->setId('1')),
|
||||
RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1')),
|
||||
]),
|
||||
$apiKey6 = ApiKey::create(),
|
||||
],
|
||||
@@ -90,11 +95,16 @@ class ListKeysCommandTest extends TestCase
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey1} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey2} | - | - | Author only |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey3} | - | - | Domain only: example.com |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey4} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey5} | - | - | Author only |
|
||||
| | | | Domain only: example.com |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey6} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
|
||||
@@ -113,8 +123,11 @@ class ListKeysCommandTest extends TestCase
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey1} | Alice | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey2} | Alice and Bob | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey3} | | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey4} | - | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
|
||||
|
||||
180
module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php
Normal file
180
module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function substr_count;
|
||||
|
||||
class DomainRedirectsCommandTest extends TestCase
|
||||
{
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $domainService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDomains
|
||||
*/
|
||||
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
|
||||
{
|
||||
$domainAuthority = 'my-domain.com';
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withRedirects('foo.com', null, 'baz.com'),
|
||||
)->willReturn(Domain::withAuthority(''));
|
||||
|
||||
$this->commandTester->setInputs(['foo.com', '', 'baz.com']);
|
||||
$this->commandTester->execute(['domain' => $domainAuthority]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('[OK] "Not found" redirects properly set for "my-domain.com"', $output);
|
||||
self::assertStringContainsString('URL to redirect to when a user hits this domain\'s base URL', $output);
|
||||
self::assertStringContainsString(
|
||||
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
|
||||
$output,
|
||||
);
|
||||
self::assertStringContainsString('URL to redirect to when a user hits an invalid short URL', $output);
|
||||
self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)'));
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideDomains(): iterable
|
||||
{
|
||||
yield 'no domain' => [null];
|
||||
yield 'domain without redirects' => [Domain::withAuthority('')];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function offersNewOptionsForDomainsWithExistingRedirects(): void
|
||||
{
|
||||
$domainAuthority = 'example.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
$domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com', 'baz.com'));
|
||||
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withRedirects(null, 'edited.com', 'baz.com'),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['2', '1', 'edited.com', '0']);
|
||||
$this->commandTester->execute(['domain' => $domainAuthority]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('[OK] "Not found" redirects properly set for "example.com"', $output);
|
||||
self::assertStringContainsString('Keep current one: [bar.com]', $output);
|
||||
self::assertStringContainsString('Keep current one: [baz.com]', $output);
|
||||
self::assertStringContainsString('Keep current one: [baz.com]', $output);
|
||||
self::assertStringNotContainsStringIgnoringCase('(Leave empty for no redirect)', $output);
|
||||
self::assertEquals(3, substr_count($output, 'Set new redirect URL'));
|
||||
self::assertEquals(3, substr_count($output, 'Remove redirect'));
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function authorityIsRequestedWhenNotProvidedAndNoOtherDomainsExist(): void
|
||||
{
|
||||
$domainAuthority = 'example.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs([$domainAuthority, '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function oneOfTheExistingDomainsCanBeSelected(): void
|
||||
{
|
||||
$domainAuthority = 'existing-two.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority($domainAuthority)),
|
||||
]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['1', '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringNotContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
self::assertStringNotContainsString('default-domain.com', $output);
|
||||
self::assertStringContainsString('existing-one.com', $output);
|
||||
self::assertStringContainsString($domainAuthority, $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function aNewDomainCanBeCreatedEvenIfOthersAlreadyExist(): void
|
||||
{
|
||||
$domainAuthority = 'new-domain.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('existing-two.com')),
|
||||
]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
|
||||
$this->commandTester->setInputs(['2', $domainAuthority, '', '', '']);
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
self::assertStringNotContainsString('default-domain.com', $output);
|
||||
self::assertStringContainsString('existing-one.com', $output);
|
||||
self::assertStringContainsString('existing-two.com', $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
@@ -26,10 +29,38 @@ class ListDomainsCommandTest extends TestCase
|
||||
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function allDomainsAreProperlyPrinted(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInputsAndOutputs
|
||||
*/
|
||||
public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void
|
||||
{
|
||||
$expectedOutput = <<<OUTPUT
|
||||
$bazDomain = Domain::withAuthority('baz.com');
|
||||
$bazDomain->configureNotFoundRedirects(NotFoundRedirects::withRedirects(
|
||||
null,
|
||||
'https://foo.com/baz-domain/regular',
|
||||
'https://foo.com/baz-domain/invalid',
|
||||
));
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions([
|
||||
'base_url' => 'https://foo.com/default/base',
|
||||
'invalid_short_url' => 'https://foo.com/default/invalid',
|
||||
])),
|
||||
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
|
||||
DomainItem::forExistingDomain($bazDomain),
|
||||
]);
|
||||
|
||||
$this->commandTester->execute($input);
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideInputsAndOutputs(): iterable
|
||||
{
|
||||
$withoutRedirectsOutput = <<<OUTPUT
|
||||
+---------+------------+
|
||||
| Domain | Is default |
|
||||
+---------+------------+
|
||||
@@ -39,16 +70,27 @@ class ListDomainsCommandTest extends TestCase
|
||||
+---------+------------+
|
||||
|
||||
OUTPUT;
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
new DomainItem('foo.com', true),
|
||||
new DomainItem('bar.com', false),
|
||||
new DomainItem('baz.com', false),
|
||||
]);
|
||||
$withRedirectsOutput = <<<OUTPUT
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| Domain | Is default | "Not found" redirects |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| foo.com | Yes | * Base URL: https://foo.com/default/base |
|
||||
| | | * Regular 404: N/A |
|
||||
| | | * Invalid short URL: https://foo.com/default/invalid |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| bar.com | No | * Base URL: N/A |
|
||||
| | | * Regular 404: N/A |
|
||||
| | | * Invalid short URL: N/A |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
| baz.com | No | * Base URL: N/A |
|
||||
| | | * Regular 404: https://foo.com/baz-domain/regular |
|
||||
| | | * Invalid short URL: https://foo.com/baz-domain/invalid |
|
||||
+---------+------------+---------------------------------------------------------+
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
OUTPUT;
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
yield 'no args' => [[], $withoutRedirectsOutput];
|
||||
yield 'no show redirects' => [['--show-redirects' => false], $withoutRedirectsOutput];
|
||||
yield 'show redirects' => [['--show-redirects' => true], $withRedirectsOutput];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
|
||||
array $retryAnswer,
|
||||
int $expectedDeleteCalls,
|
||||
string $expectedMessage
|
||||
string $expectedMessage,
|
||||
): void {
|
||||
$shortCode = 'abc123';
|
||||
$identifier = new ShortUrlIdentifier($shortCode);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -110,7 +110,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
array $input,
|
||||
array $expectedContents,
|
||||
array $notExpectedContents,
|
||||
ApiKey $apiKey
|
||||
ApiKey $apiKey,
|
||||
): void {
|
||||
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
||||
->willReturn(new Paginator(new ArrayAdapter([
|
||||
@@ -185,7 +185,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
?string $searchTerm,
|
||||
array $tags,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null
|
||||
?string $endDate = null,
|
||||
): void {
|
||||
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||
'page' => $page,
|
||||
@@ -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,
|
||||
|
||||
@@ -36,7 +36,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
|
||||
public function showsProperMessageWhenGeoLiteUpdateFails(
|
||||
bool $olderDbExists,
|
||||
string $expectedMessage,
|
||||
int $expectedExitCode
|
||||
int $expectedExitCode,
|
||||
): void {
|
||||
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
|
||||
function (array $args) use ($olderDbExists): void {
|
||||
|
||||
@@ -73,7 +73,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
int $expectedEmptyCalls,
|
||||
int $expectedAllCalls,
|
||||
bool $expectWarningPrint,
|
||||
array $args
|
||||
array $args,
|
||||
): void {
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
@@ -13,6 +13,7 @@ use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock;
|
||||
@@ -28,11 +29,13 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
private GeolocationDbUpdater $geolocationDbUpdater;
|
||||
private ObjectProphecy $dbUpdater;
|
||||
private ObjectProphecy $geoLiteDbReader;
|
||||
private TrackingOptions $trackingOptions;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
|
||||
$this->geoLiteDbReader = $this->prophesize(Reader::class);
|
||||
$this->trackingOptions = new TrackingOptions();
|
||||
|
||||
$locker = $this->prophesize(Lock\LockFactory::class);
|
||||
$lock = $this->prophesize(Lock\LockInterface::class);
|
||||
@@ -45,6 +48,7 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
$this->dbUpdater->reveal(),
|
||||
$this->geoLiteDbReader->reveal(),
|
||||
$locker->reveal(),
|
||||
$this->trackingOptions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,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));
|
||||
@@ -157,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' => '',
|
||||
@@ -174,4 +174,27 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
'record_size' => 4,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideTrackingOptions
|
||||
*/
|
||||
public function downloadDbIsSkippedIfTrackingIsDisabled(array $props): void
|
||||
{
|
||||
foreach ($props as $prop) {
|
||||
$this->trackingOptions->{$prop} = true;
|
||||
}
|
||||
|
||||
$this->geolocationDbUpdater->checkDbUpdate();
|
||||
|
||||
$this->dbUpdater->databaseFileExists(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->geoLiteDbReader->metadata(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideTrackingOptions(): iterable
|
||||
{
|
||||
yield 'disableTracking' => [['disableTracking']];
|
||||
yield 'disableIpTracking' => [['disableIpTracking']];
|
||||
yield 'both' => [['disableTracking', 'disableIpTracking']];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class ShlinkTableTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->baseTable = $this->prophesize(Table::class);
|
||||
$this->shlinkTable = new ShlinkTable($this->baseTable->reveal());
|
||||
$this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -57,7 +57,7 @@ class ShlinkTableTest extends TestCase
|
||||
/** @test */
|
||||
public function newTableIsCreatedForFactoryMethod(): void
|
||||
{
|
||||
$instance = ShlinkTable::fromOutput($this->prophesize(OutputInterface::class)->reveal());
|
||||
$instance = ShlinkTable::default($this->prophesize(OutputInterface::class)->reveal());
|
||||
|
||||
$ref = new ReflectionObject($instance);
|
||||
$baseTable = $ref->getProperty('baseTable');
|
||||
|
||||
@@ -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,
|
||||
@@ -37,6 +39,7 @@ return [
|
||||
Domain\DomainService::class => ConfigAbstractFactory::class,
|
||||
|
||||
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Visit\RequestTracker::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
||||
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
|
||||
@@ -45,6 +48,8 @@ return [
|
||||
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
|
||||
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
|
||||
|
||||
Config\NotFoundRedirectResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Action\RedirectAction::class => ConfigAbstractFactory::class,
|
||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
@@ -53,7 +58,9 @@ return [
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
|
||||
@@ -69,10 +76,11 @@ return [
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
|
||||
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
|
||||
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class],
|
||||
ErrorHandler\NotFoundRedirectHandler::class => [
|
||||
NotFoundRedirectOptions::class,
|
||||
Util\RedirectResponseHelper::class,
|
||||
Config\NotFoundRedirectResolver::class,
|
||||
Domain\DomainService::class,
|
||||
],
|
||||
|
||||
Options\AppOptions::class => ['config.app_options'],
|
||||
@@ -80,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,
|
||||
@@ -92,6 +102,7 @@ return [
|
||||
EventDispatcherInterface::class,
|
||||
Options\TrackingOptions::class,
|
||||
],
|
||||
Visit\RequestTracker::class => [Visit\VisitsTracker::class, Options\TrackingOptions::class],
|
||||
Service\ShortUrlService::class => [
|
||||
'em',
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
@@ -108,36 +119,45 @@ return [
|
||||
],
|
||||
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
||||
Service\ShortUrl\ShortCodeHelper::class => ['em'],
|
||||
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
|
||||
Domain\DomainService::class => [
|
||||
'em',
|
||||
'config.url_shortener.domain.hostname',
|
||||
Options\NotFoundRedirectOptions::class,
|
||||
],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
Util\DoctrineBatchHelper::class => ['em'],
|
||||
Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
|
||||
|
||||
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
|
||||
|
||||
Action\RedirectAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Visit\VisitsTracker::class,
|
||||
Options\TrackingOptions::class,
|
||||
Visit\RequestTracker::class,
|
||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
|
||||
Util\RedirectResponseHelper::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\PixelAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Visit\VisitsTracker::class,
|
||||
Options\TrackingOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\PixelAction::class => [Service\ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class],
|
||||
Action\QrCodeAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
ShortUrl\Helper\ShortUrlStringifier::class,
|
||||
'Logger_Shlink',
|
||||
Options\QrCodeOptions::class,
|
||||
],
|
||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
||||
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
|
||||
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
|
||||
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Visit\RequestTracker::class,
|
||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class,
|
||||
Util\RedirectResponseHelper::class,
|
||||
Options\UrlShortenerOptions::class,
|
||||
],
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => [
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
||||
|
||||
@@ -24,4 +24,19 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder->createField('authority', Types::STRING)
|
||||
->unique()
|
||||
->build();
|
||||
|
||||
$builder->createField('baseUrlRedirect', Types::STRING)
|
||||
->columnName('base_url_redirect')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('regular404Redirect', Types::STRING)
|
||||
->columnName('regular_not_found_redirect')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('invalidShortUrlRedirect', Types::STRING)
|
||||
->columnName('invalid_short_url_redirect')
|
||||
->nullable()
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ return [
|
||||
'httpClient',
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
'config.url_shortener.visits_webhooks',
|
||||
Options\WebhookOptions::class,
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
||||
Options\AppOptions::class,
|
||||
],
|
||||
|
||||
@@ -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,26 +42,10 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
|
||||
$startDate = parseDateFromQuery($query, $startDateName);
|
||||
$endDate = parseDateFromQuery($query, $endDateName);
|
||||
|
||||
// TODO Use match expression when migrating to PHP8
|
||||
if ($startDate === null && $endDate === null) {
|
||||
return DateRange::emptyInstance();
|
||||
}
|
||||
|
||||
if ($startDate !== null && $endDate !== null) {
|
||||
return DateRange::withStartAndEndDate($startDate, $endDate);
|
||||
}
|
||||
|
||||
if ($startDate !== null) {
|
||||
return DateRange::withStartDate($startDate);
|
||||
}
|
||||
|
||||
return 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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user