mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-01 20:53:14 +08:00
Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8393d44c50 | ||
|
|
3e8ce80f80 | ||
|
|
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 | ||
|
|
a3b7742992 | ||
|
|
4b5fa6ddad | ||
|
|
b6aca82da6 | ||
|
|
8ee3bb4d58 | ||
|
|
46bea241e6 | ||
|
|
5e6d2881bc | ||
|
|
cd19876419 | ||
|
|
f82e103bc5 | ||
|
|
3ff4ac84c4 | ||
|
|
bf0c679a48 | ||
|
|
96c5bc164a | ||
|
|
73aead01b4 | ||
|
|
e19b3cc45d | ||
|
|
a1cab4ca7d | ||
|
|
4b89687e45 | ||
|
|
1c861fecfc | ||
|
|
a12c9f54c4 | ||
|
|
69d72e754f | ||
|
|
db3c5a3031 | ||
|
|
6327ed814a | ||
|
|
9fa32b5b6b | ||
|
|
663ae9f6bb | ||
|
|
70c73bc5d6 | ||
|
|
05d73552cf | ||
|
|
70384237c1 | ||
|
|
36e4a0dd32 | ||
|
|
3ef02d46c0 | ||
|
|
e6ce84aa14 | ||
|
|
348e34d52e | ||
|
|
2e6b3c0561 | ||
|
|
7280b48cdc | ||
|
|
2803f65479 | ||
|
|
3535688c3b | ||
|
|
9cff570c45 | ||
|
|
15c028e151 | ||
|
|
f0dc32b6e5 | ||
|
|
d423d18249 | ||
|
|
8a8e3c3fc8 | ||
|
|
111fc3c37d | ||
|
|
e9a5284dde | ||
|
|
b277f431c2 | ||
|
|
c8b8947b1f | ||
|
|
9a78d1585d | ||
|
|
09414a8834 | ||
|
|
1efa973507 | ||
|
|
e23cd6a856 | ||
|
|
743bb7a6ee | ||
|
|
086efe3c63 | ||
|
|
d751df70fd | ||
|
|
334d95c843 | ||
|
|
5ddac7866b | ||
|
|
a896fbbb90 | ||
|
|
a478699fe8 | ||
|
|
6387e50276 | ||
|
|
28c06de685 | ||
|
|
823573cea7 | ||
|
|
5d0f306bcc | ||
|
|
f30e922074 | ||
|
|
96ff0bffda | ||
|
|
d9b675fc8b | ||
|
|
104b7390da | ||
|
|
7b4456e73f | ||
|
|
86230d9bf3 | ||
|
|
1f8994ca8b | ||
|
|
f7b6f4ba19 | ||
|
|
74ea5969be | ||
|
|
c4718e7523 | ||
|
|
5de706e0fe | ||
|
|
77d06b4b03 | ||
|
|
b4d137375a | ||
|
|
0621ae7735 | ||
|
|
3a6a1f25a7 | ||
|
|
731dc64f44 | ||
|
|
d72b9cf646 | ||
|
|
0f0c4dc549 | ||
|
|
ea0820d881 | ||
|
|
312f20d2f1 | ||
|
|
f8289fa4be | ||
|
|
554209d644 | ||
|
|
4ce44034cb | ||
|
|
221b62ea57 | ||
|
|
0a5c265b12 | ||
|
|
9b55389538 | ||
|
|
60a8d6e986 | ||
|
|
d7523bcb57 | ||
|
|
562110fac4 | ||
|
|
d104265f04 | ||
|
|
4439685403 | ||
|
|
9feb72235a | ||
|
|
c372a498cc | ||
|
|
744b368cc1 | ||
|
|
a03c4519c9 | ||
|
|
66327881d5 | ||
|
|
b93b14986e | ||
|
|
1ade4e9917 | ||
|
|
65f2ab6720 | ||
|
|
7d38ba12bd | ||
|
|
8128e85b6b | ||
|
|
3d99819be4 | ||
|
|
a2ca1618ea | ||
|
|
b244c56862 | ||
|
|
c931874bac | ||
|
|
1b168ac3d2 | ||
|
|
0fc123b249 | ||
|
|
c622804950 | ||
|
|
e093480a5b | ||
|
|
1498b72966 |
@@ -5,7 +5,7 @@ data/log/*
|
||||
data/locks/*
|
||||
data/proxies/*
|
||||
data/migrations_template.txt
|
||||
data/GeoLite2-City.*
|
||||
data/GeoLite2-City*
|
||||
data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
CHANGELOG.md
|
||||
|
||||
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer cs
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer stan
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -57,13 +57,13 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- 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 +74,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -83,13 +83,13 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- 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 +100,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:mysql
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:maria
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:postgres
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3, pdo_sqlsrv-5.9.0
|
||||
extensions: swoole-4.6.7, pdo_sqlsrv-5.9.0
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- name: Create test database
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -195,13 +195,13 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- 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,7 +216,8 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
php-version: ['8.0']
|
||||
test-group: ['unit', 'db']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -225,14 +226,14 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
- run: composer infect:ci
|
||||
- run: composer infect:ci:${{ matrix.test-group }}
|
||||
|
||||
upload-coverage:
|
||||
needs:
|
||||
@@ -242,7 +243,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
7
.github/workflows/publish-release.yml
vendored
7
.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
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.6.3
|
||||
extensions: swoole-4.6.7
|
||||
- if: ${{ matrix.swoole == 'yes' }}
|
||||
run: ./build.sh ${GITHUB_REF#refs/tags/v}
|
||||
- if: ${{ matrix.swoole == 'no' }}
|
||||
@@ -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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,8 +6,7 @@ composer.phar
|
||||
vendor/
|
||||
data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
data/GeoLite2-City.mmdb
|
||||
data/GeoLite2-City.mmdb.*
|
||||
data/GeoLite2-City.*
|
||||
docs/swagger-ui*
|
||||
docs/mercure.html
|
||||
docker-compose.override.yml
|
||||
|
||||
140
CHANGELOG.md
140
CHANGELOG.md
@@ -4,6 +4,146 @@ 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.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
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [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.
|
||||
* [#819](https://github.com/shlinkio/shlink/issues/819) Visits are now always located in real time, even when not using swoole.
|
||||
|
||||
The only side effect is that a GeoLite2 db file is now installed when the docker image starts or during shlink installation or update.
|
||||
|
||||
Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one.
|
||||
|
||||
* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line.
|
||||
* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API.
|
||||
* [#898](https://github.com/shlinkio/shlink/issues/898) Improved tracking granularity, allowing to disable visits tracking completely, or just parts of it.
|
||||
|
||||
In order to achieve it, Shlink now supports 4 new tracking-related options, that can be customized via env vars for docker, or via installer:
|
||||
|
||||
* `disable_tracking`: If true, visits will not be tracked at all.
|
||||
* `disable_ip_tracking`: If true, visits will be tracked, but neither the IP address, nor the location will be resolved.
|
||||
* `disable_referrer_tracking`: If true, the referrer will not be tracked.
|
||||
* `disable_ua_tracking`: If true, the user agent will not be tracked.
|
||||
|
||||
* [#955](https://github.com/shlinkio/shlink/issues/955) Added new option to set short URLs as crawlable, making them be listed in the robots.txt as Allowed.
|
||||
* [#900](https://github.com/shlinkio/shlink/issues/900) Shlink now tries to detect if the visit is coming from a potential bot or crawler, and allows to exclude those visits from visits lists if desired.
|
||||
|
||||
### Changed
|
||||
* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0.
|
||||
* [#1039](https://github.com/shlinkio/shlink/issues/1039) Updated to `endroid/qr-code` 4.0.
|
||||
* [#1008](https://github.com/shlinkio/shlink/issues/1008) Ensured all logs are sent to the filesystem while running API tests, which helps debugging the reason for tests to fail.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1041](https://github.com/shlinkio/shlink/issues/1041) Ensured the default value for the version while building the docker image is `latest`.
|
||||
* [#1067](https://github.com/shlinkio/shlink/issues/1067) Fixed exception when persisting multiple short URLs in one batch which include the same new tags/domains. This can potentially happen when importing URLs.
|
||||
|
||||
|
||||
## [2.6.2] - 2021-03-12
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
@@ -96,11 +96,13 @@ In order to ensure stability and no regressions are introduced while developing
|
||||
|
||||
The project provides some tooling to run them against any of the supported database engines.
|
||||
|
||||
* **API tests**: These are E2E tests that spin up an instance of the app and test it from the outside, by interacting with the REST API.
|
||||
* **API tests**: These are E2E tests that spin up an instance of the app with swoole, and test it from the outside by interacting with the REST API.
|
||||
|
||||
These are the best tests to catch regressions, and to verify everything behaves as expected.
|
||||
|
||||
They use MySQL as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
|
||||
They use Postgres as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
|
||||
|
||||
Since the app instance is run on a process different from the one running the tests, when a test fails it might not be obvious why. To help debugging that, the app will dump all its logs inside `data/log/api-tests`, where you will find the `shlink.log` and `access.log` files.
|
||||
|
||||
* **CLI tests**: *TBD. Once included, its purpose will be the same as API tests, but running through the command line*
|
||||
|
||||
@@ -118,8 +120,8 @@ 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 MySQL 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 test:api` to run API E2E tests. For these, the Postgres database engine is used.
|
||||
* 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.
|
||||
|
||||
|
||||
25
Dockerfile
25
Dockerfile
@@ -1,9 +1,10 @@
|
||||
FROM php:8.0.2-alpine3.13 as base
|
||||
FROM php:8.0.9-alpine3.14 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.5.2
|
||||
ARG SHLINK_VERSION=latest
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.6.3
|
||||
ENV SWOOLE_VERSION 4.7.0
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
@@ -30,13 +31,13 @@ RUN \
|
||||
|
||||
# Install sqlsrv driver
|
||||
RUN if [ $(uname -m) == "x86_64" ]; then \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk ; \
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
|
||||
fi
|
||||
|
||||
# Install swoole
|
||||
@@ -69,10 +70,22 @@ 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();
|
||||
|
||||
@@ -3,6 +3,8 @@ export APP_ENV=test
|
||||
export DB_DRIVER=postgres
|
||||
export TEST_ENV=api
|
||||
|
||||
rm -rf data/log/api-tests
|
||||
|
||||
# Try to stop server just in case it hanged in last execution
|
||||
vendor/bin/laminas mezzio:swoole:stop
|
||||
|
||||
|
||||
@@ -12,67 +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.0.2",
|
||||
"doctrine/orm": "2.8.1 || ^2.8.3",
|
||||
"endroid/qr-code": "3.x-dev#0f1613a as 3.10",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^7.0",
|
||||
"happyr/doctrine-specification": "2.x-dev#cb116d3 as 2.0",
|
||||
"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",
|
||||
"lstrojny/functional-php": "^1.15",
|
||||
"mezzio/mezzio": "^3.3",
|
||||
"mezzio/mezzio-fastroute": "^3.1",
|
||||
"mezzio/mezzio-problem-details": "^1.3",
|
||||
"doctrine/cache": "^1.12",
|
||||
"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.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.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.5",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-common": "^3.7",
|
||||
"shlinkio/shlink-config": "^1.2",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.1",
|
||||
"shlinkio/shlink-importer": "^2.2",
|
||||
"shlinkio/shlink-installer": "^5.4",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.5",
|
||||
"symfony/console": "^5.1",
|
||||
"symfony/filesystem": "^5.1",
|
||||
"symfony/lock": "^5.1",
|
||||
"symfony/mercure": "^0.4.1",
|
||||
"symfony/process": "^5.1",
|
||||
"symfony/string": "^5.1"
|
||||
"shlinkio/shlink-importer": "^2.3.1",
|
||||
"shlinkio/shlink-installer": "^6.1",
|
||||
"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.24.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/shlink-test-utils": "^2.2",
|
||||
"symfony/var-dumper": "^5.3",
|
||||
"veewee/composer-run-parallel": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -111,7 +114,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",
|
||||
@@ -124,6 +127,7 @@
|
||||
],
|
||||
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
|
||||
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
|
||||
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
|
||||
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
|
||||
@@ -132,8 +136,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",
|
||||
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
|
||||
"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",
|
||||
|
||||
@@ -7,7 +7,6 @@ return [
|
||||
'app_options' => [
|
||||
'name' => 'Shlink',
|
||||
'version' => '%SHLINK_VERSION%',
|
||||
'disable_track_param' => null,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common;
|
||||
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||
|
||||
return [
|
||||
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
// When running tests, any mysql-specific option can interfere with other drivers
|
||||
$driverOptions = env('APP_ENV') === 'test' ? [] : [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
@@ -18,7 +10,6 @@ return [
|
||||
'password' => 'root',
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => 'shlink_db',
|
||||
'driverOptions' => $driverOptions,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI;
|
||||
|
||||
use Shlinkio\Shlink\Installer\Config\Option;
|
||||
use Shlinkio\Shlink\Installer\Util\InstallationCommand;
|
||||
|
||||
return [
|
||||
|
||||
@@ -24,7 +27,6 @@ return [
|
||||
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
||||
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
|
||||
Option\Redirect\Regular404RedirectConfigOption::class,
|
||||
Option\DisableTrackParamConfigOption::class,
|
||||
Option\Visit\CheckVisitsThresholdConfigOption::class,
|
||||
Option\Visit\VisitsThresholdConfigOption::class,
|
||||
Option\BasePathConfigOption::class,
|
||||
@@ -37,19 +39,28 @@ return [
|
||||
Option\Mercure\MercureInternalUrlConfigOption::class,
|
||||
Option\Mercure\MercureJwtSecretConfigOption::class,
|
||||
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
|
||||
Option\UrlShortener\IpAnonymizationConfigOption::class,
|
||||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
|
||||
Option\UrlShortener\OrphanVisitsTrackingConfigOption::class,
|
||||
Option\UrlShortener\AppendExtraPathConfigOption::class,
|
||||
Option\Tracking\IpAnonymizationConfigOption::class,
|
||||
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
||||
Option\Tracking\DisableTrackParamConfigOption::class,
|
||||
Option\Tracking\DisableTrackingConfigOption::class,
|
||||
Option\Tracking\DisableIpTrackingConfigOption::class,
|
||||
Option\Tracking\DisableReferrerTrackingConfigOption::class,
|
||||
Option\Tracking\DisableUaTrackingConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
'db_create_schema' => [
|
||||
'command' => 'bin/cli db:create',
|
||||
InstallationCommand::DB_CREATE_SCHEMA => [
|
||||
'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME,
|
||||
],
|
||||
'db_migrate' => [
|
||||
'command' => 'bin/cli db:migrate',
|
||||
InstallationCommand::DB_MIGRATE => [
|
||||
'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME,
|
||||
],
|
||||
InstallationCommand::GEOLITE_DOWNLOAD_DB => [
|
||||
'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
|
||||
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
|
||||
use Symfony\Component\Mercure\Publisher;
|
||||
use Symfony\Component\Mercure\PublisherInterface;
|
||||
use Symfony\Component\Mercure\Hub;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
|
||||
return [
|
||||
|
||||
@@ -21,14 +21,14 @@ return [
|
||||
LcobucciJwtProvider::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
Publisher::class => [
|
||||
Hub::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
],
|
||||
'lazy_services' => [
|
||||
'class_map' => [
|
||||
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
|
||||
Publisher::class => PublisherInterface::class,
|
||||
Hub::class => HubInterface::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,
|
||||
|
||||
31
config/autoload/tracking.global.php
Normal file
31
config/autoload/tracking.global.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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,
|
||||
|
||||
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
|
||||
'track_orphan_visits' => true,
|
||||
|
||||
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
|
||||
'disable_track_param' => null,
|
||||
|
||||
// If true, visits will not be tracked at all
|
||||
'disable_tracking' => false,
|
||||
|
||||
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
|
||||
'disable_ip_tracking' => false,
|
||||
|
||||
// If true, the referrer will not be tracked
|
||||
'disable_referrer_tracking' => false,
|
||||
|
||||
// If true, the user agent will not be tracked
|
||||
'disable_ua_tracking' => false,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -14,13 +14,14 @@ return [
|
||||
'hostname' => '',
|
||||
],
|
||||
'validate_url' => false, // Deprecated
|
||||
'anonymize_remote_addr' => true,
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
'auto_resolve_titles' => false,
|
||||
'append_extra_path' => false,
|
||||
|
||||
// TODO Move these two options to their own config namespace. Maybe "redirects".
|
||||
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
||||
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
'auto_resolve_titles' => false,
|
||||
'track_orphan_visits' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
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);
|
||||
})();
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -9,7 +9,8 @@ use Laminas\ConfigAggregator\ConfigAggregator;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Laminas\Stdlib\Glob;
|
||||
use PDO;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use PHPUnit\Runner\Version;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
||||
@@ -34,30 +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',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
],
|
||||
],
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
|
||||
@@ -74,12 +62,30 @@ $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) => [
|
||||
'handlers' => [
|
||||
$handlerName => [
|
||||
'name' => StreamHandler::class,
|
||||
'params' => [
|
||||
'level' => Logger::DEBUG,
|
||||
'stream' => sprintf('data/log/api-tests/%s', $filename),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
'debug' => true,
|
||||
@@ -111,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();
|
||||
@@ -122,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');
|
||||
@@ -163,4 +169,9 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'logger' => [
|
||||
'Shlink' => $buildTestLoggerConfig('shlink_handler', 'shlink.log'),
|
||||
'Access' => $buildTestLoggerConfig('access_handler', 'access.log'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
FROM php:8.0.2-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
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -44,13 +45,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \
|
||||
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
|
||||
|
||||
# Install pcov and sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
|
||||
docker-php-ext-enable pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
FROM php:8.0.2-alpine3.13
|
||||
FROM php:8.0.9-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 INOTIFY_VERSION 3.0.0
|
||||
ENV SWOOLE_VERSION 4.6.3
|
||||
ENV SWOOLE_VERSION 4.7.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -54,13 +55,13 @@ RUN mkdir -p /usr/src/php/ext/inotify \
|
||||
&& rm /tmp/inotify.tar.gz
|
||||
|
||||
# Install swoole, pcov and mssql driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
@@ -41,12 +41,4 @@ class Version20160819142757 extends AbstractMigration
|
||||
{
|
||||
$db = $this->connection->getDatabasePlatform()->getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +73,4 @@ class Version20160820191203 extends AbstractMigration
|
||||
$schema->dropTable('short_urls_in_tags');
|
||||
$schema->dropTable('tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,12 +45,4 @@ class Version20171021093246 extends AbstractMigration
|
||||
$shortUrls->dropColumn('valid_since');
|
||||
$shortUrls->dropColumn('valid_until');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,4 @@ class Version20171022064541 extends AbstractMigration
|
||||
|
||||
$shortUrls->dropColumn('max_visits');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,4 @@ final class Version20180801183328 extends AbstractMigration
|
||||
{
|
||||
$schema->getTable('short_urls')->getColumn('short_code')->setLength($size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ final class Version20180913205455 extends AbstractMigration
|
||||
|
||||
try {
|
||||
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -66,12 +66,4 @@ final class Version20180913205455 extends AbstractMigration
|
||||
{
|
||||
// Nothing to rollback
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,4 @@ final class Version20180915110857 extends AbstractMigration
|
||||
{
|
||||
// Nothing to run
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +65,4 @@ final class Version20181020060559 extends AbstractMigration
|
||||
{
|
||||
// No down
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,4 @@ final class Version20181020065148 extends AbstractMigration
|
||||
{
|
||||
// No down
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,4 @@ final class Version20181110175521 extends AbstractMigration
|
||||
{
|
||||
return $schema->getTable('visits')->getColumn('user_agent');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,4 @@ final class Version20190824075137 extends AbstractMigration
|
||||
{
|
||||
return $schema->getTable('visits')->getColumn('referer');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +52,4 @@ final class Version20190930165521 extends AbstractMigration
|
||||
$schema->getTable('short_urls')->dropColumn('domain_id');
|
||||
$schema->dropTable('domains');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +46,4 @@ final class Version20191001201532 extends AbstractMigration
|
||||
$shortUrls->dropIndex('unique_short_code_plus_domain');
|
||||
$shortUrls->addUniqueIndex(['short_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,4 @@ final class Version20191020074522 extends AbstractMigration
|
||||
{
|
||||
return $schema->getTable('short_urls')->getColumn('original_url');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +93,4 @@ final class Version20200105165647 extends AbstractMigration
|
||||
$visitLocations->dropColumn($colName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,4 @@ final class Version20200106215144 extends AbstractMigration
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,4 @@ final class Version20200110182849 extends AbstractMigration
|
||||
{
|
||||
// No need (and no way) to undo this migration
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,4 @@ final class Version20200323190014 extends AbstractMigration
|
||||
|
||||
$visitLocations->dropColumn('is_empty');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,4 @@ final class Version20200503170404 extends AbstractMigration
|
||||
$this->skipIf(! $visits->hasIndex(self::INDEX_NAME));
|
||||
$visits->dropIndex(self::INDEX_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,12 +41,4 @@ final class Version20201023090929 extends AbstractMigration
|
||||
$shortUrls->dropColumn('import_original_short_code');
|
||||
$shortUrls->dropIndex('unique_imports');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,12 +86,4 @@ final class Version20201102113208 extends AbstractMigration
|
||||
$shortUrls->removeForeignKey('FK_' . self::API_KEY_COLUMN);
|
||||
$shortUrls->dropColumn(self::API_KEY_COLUMN);
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,4 @@ final class Version20210102174433 extends AbstractMigration
|
||||
$schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key');
|
||||
$schema->dropTable(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,4 @@ final class Version20210118153932 extends AbstractMigration
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,4 @@ final class Version20210202181026 extends AbstractMigration
|
||||
$shortUrls->dropColumn(self::TITLE);
|
||||
$shortUrls->dropColumn('title_was_auto_resolved');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +40,4 @@ final class Version20210207100807 extends AbstractMigration
|
||||
$visits->dropColumn('visited_url');
|
||||
$visits->dropColumn('type');
|
||||
}
|
||||
|
||||
/**
|
||||
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
|
||||
*/
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
37
data/migrations/Version20210306165711.php
Normal file
37
data/migrations/Version20210306165711.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210306165711 extends AbstractMigration
|
||||
{
|
||||
private const TABLE = 'api_keys';
|
||||
private const COLUMN = 'name';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$apiKeys = $schema->getTable(self::TABLE);
|
||||
$this->skipIf($apiKeys->hasColumn(self::COLUMN));
|
||||
|
||||
$apiKeys->addColumn(
|
||||
self::COLUMN,
|
||||
Types::STRING,
|
||||
[
|
||||
'notnull' => false,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$apiKeys = $schema->getTable(self::TABLE);
|
||||
$this->skipIf(! $apiKeys->hasColumn(self::COLUMN));
|
||||
|
||||
$apiKeys->dropColumn(self::COLUMN);
|
||||
}
|
||||
}
|
||||
26
data/migrations/Version20210522051601.php
Normal file
26
data/migrations/Version20210522051601.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 Version20210522051601 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf($shortUrls->hasColumn('crawlable'));
|
||||
$shortUrls->addColumn('crawlable', Types::BOOLEAN, ['default' => false]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf(! $shortUrls->hasColumn('crawlable'));
|
||||
$shortUrls->dropColumn('crawlable');
|
||||
}
|
||||
}
|
||||
28
data/migrations/Version20210522124633.php
Normal file
28
data/migrations/Version20210522124633.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210522124633 extends AbstractMigration
|
||||
{
|
||||
private const POTENTIAL_BOT_COLUMN = 'potential_bot';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf($visits->hasColumn(self::POTENTIAL_BOT_COLUMN));
|
||||
$visits->addColumn(self::POTENTIAL_BOT_COLUMN, Types::BOOLEAN, ['default' => false]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf(! $visits->hasColumn(self::POTENTIAL_BOT_COLUMN));
|
||||
$visits->dropColumn(self::POTENTIAL_BOT_COLUMN);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
log_errors_max_len=0
|
||||
zend.assertions=1
|
||||
assert.exception=1
|
||||
memory_limit=256M
|
||||
|
||||
@@ -42,12 +42,6 @@ $helper = new class {
|
||||
];
|
||||
}
|
||||
|
||||
$driverOptions = ! $isMysql ? [] : [
|
||||
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
|
||||
1002 => 'SET NAMES utf8',
|
||||
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
|
||||
1000 => true,
|
||||
];
|
||||
return [
|
||||
'driver' => self::DB_DRIVERS_MAP[$driver],
|
||||
'dbname' => env('DB_NAME', 'shlink'),
|
||||
@@ -55,7 +49,6 @@ $helper = new class {
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
|
||||
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
|
||||
'driverOptions' => $driverOptions,
|
||||
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
|
||||
];
|
||||
}
|
||||
@@ -101,10 +94,6 @@ $helper = new class {
|
||||
|
||||
return [
|
||||
|
||||
'app_options' => [
|
||||
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
'check_visits_threshold' => true,
|
||||
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
|
||||
@@ -120,13 +109,22 @@ return [
|
||||
'hostname' => env('SHORT_DOMAIN_HOST', ''),
|
||||
],
|
||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
||||
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
|
||||
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
||||
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
||||
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
|
||||
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
|
||||
],
|
||||
|
||||
'tracking' => [
|
||||
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
||||
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
|
||||
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
|
||||
'disable_tracking' => (bool) env('DISABLE_TRACKING', false),
|
||||
'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false),
|
||||
'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false),
|
||||
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
|
||||
],
|
||||
|
||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||
@@ -170,7 +168,7 @@ return [
|
||||
],
|
||||
|
||||
'geolite2' => [
|
||||
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
|
||||
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove hardcoded license on v3
|
||||
],
|
||||
|
||||
'mercure' => $helper->getMercureConfig(),
|
||||
|
||||
@@ -15,6 +15,21 @@ php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
|
||||
echo "Clearing entities cache..."
|
||||
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q
|
||||
|
||||
# Try to download GeoLite2 db file only if the license key env var was defined
|
||||
if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
|
||||
echo "Downloading GeoLite2 db file..."
|
||||
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
|
||||
|
||||
@@ -116,6 +116,15 @@
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
@@ -133,7 +142,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": "example.com"
|
||||
"domain": "example.com",
|
||||
"title": "The title",
|
||||
"crawlable": false
|
||||
}
|
||||
},
|
||||
"ShortUrlMeta": {
|
||||
@@ -179,6 +190,10 @@
|
||||
},
|
||||
"visitLocation": {
|
||||
"$ref": "#/components/schemas/VisitLocation"
|
||||
},
|
||||
"potentialBot": {
|
||||
"type": "boolean",
|
||||
"description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
@@ -193,7 +208,8 @@
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
},
|
||||
"potentialBot": false
|
||||
}
|
||||
},
|
||||
"OrphanVisit": {
|
||||
@@ -232,6 +248,7 @@
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in",
|
||||
"type": "base_url"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
},
|
||||
"visitLocation": {
|
||||
"$ref": "./VisitLocation.json"
|
||||
},
|
||||
"potentialBot": {
|
||||
"type": "boolean",
|
||||
"description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,8 @@
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": null,
|
||||
"title": "Welcome to Steam"
|
||||
"title": "Welcome to Steam",
|
||||
"crawlable": false
|
||||
},
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
@@ -157,7 +158,8 @@
|
||||
"maxVisits": null
|
||||
},
|
||||
"domain": null,
|
||||
"title": null
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
},
|
||||
{
|
||||
"shortCode": "123bA",
|
||||
@@ -172,7 +174,8 @@
|
||||
"maxVisits": null
|
||||
},
|
||||
"domain": "example.com",
|
||||
"title": null
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
@@ -273,6 +276,10 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,7 +312,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": 500
|
||||
},
|
||||
"domain": null
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": null,
|
||||
"title": null
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
},
|
||||
"text/plain": "https://doma.in/abc123"
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": null,
|
||||
"title": null
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -147,6 +148,10 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,7 +189,8 @@
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": null,
|
||||
"title": "Shlink - The URL shortener"
|
||||
"title": "Shlink - The URL shortener",
|
||||
"crawlable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,6 +57,16 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeBots",
|
||||
"in": "query",
|
||||
"description": "Tells if visits from potential bots should be excluded from the result set",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -98,7 +108,8 @@
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null
|
||||
"visitLocation": null,
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
@@ -112,13 +123,15 @@
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
},
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null
|
||||
"visitLocation": null,
|
||||
"potentialBot": true
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,16 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeBots",
|
||||
"in": "query",
|
||||
"description": "Tells if visits from potential bots should be excluded from the result set",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -95,7 +105,8 @@
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null
|
||||
"visitLocation": null,
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
@@ -109,13 +120,15 @@
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
},
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null
|
||||
"visitLocation": null,
|
||||
"potentialBot": true
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
|
||||
@@ -45,6 +45,16 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeBots",
|
||||
"in": "query",
|
||||
"description": "Tells if visits from potential bots should be excluded from the result set",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -87,6 +97,7 @@
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null,
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in",
|
||||
"type": "base_url"
|
||||
},
|
||||
@@ -103,6 +114,7 @@
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in/foo",
|
||||
"type": "invalid_short_url"
|
||||
},
|
||||
@@ -111,6 +123,7 @@
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null,
|
||||
"potentialBot": true,
|
||||
"visitedUrl": "https://doma.in/foo/bar/baz",
|
||||
"type": "regular_404"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,6 +15,7 @@ return [
|
||||
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
|
||||
|
||||
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
|
||||
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
|
||||
|
||||
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
|
||||
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
|
||||
@@ -26,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;
|
||||
@@ -44,6 +45,7 @@ return [
|
||||
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -59,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],
|
||||
|
||||
@@ -80,11 +88,11 @@ return [
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class],
|
||||
Command\Visit\LocateVisitsCommand::class => [
|
||||
Visit\VisitLocator::class,
|
||||
IpLocationResolverInterface::class,
|
||||
LockFactory::class,
|
||||
Util\GeolocationDbUpdater::class,
|
||||
],
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
|
||||
@@ -97,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
|
||||
@@ -42,6 +39,10 @@ class GenerateKeyCommand extends BaseCommand
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
You can optionally set its name for tracking purposes with <comment>--name</comment> or <comment>-m</comment>:
|
||||
|
||||
<info>%command.full_name% --name Alice</info>
|
||||
|
||||
You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
|
||||
|
||||
<info>%command.full_name% --expiration-date 2020-01-01</info>
|
||||
@@ -56,6 +57,12 @@ class GenerateKeyCommand extends BaseCommand
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Generates a new valid API key.')
|
||||
->addOption(
|
||||
'name',
|
||||
'm',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The name by which this API key will be known.',
|
||||
)
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'expiration-date',
|
||||
'e',
|
||||
@@ -82,6 +89,7 @@ class GenerateKeyCommand extends BaseCommand
|
||||
$expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date');
|
||||
$apiKey = $this->apiKeyService->create(
|
||||
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
|
||||
$input->getOption('name'),
|
||||
...$this->roleResolver->determineRoles($input),
|
||||
);
|
||||
|
||||
@@ -89,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
|
||||
@@ -57,11 +54,11 @@ class ListKeysCommand extends BaseCommand
|
||||
$messagePattern = $this->determineMessagePattern($apiKey);
|
||||
|
||||
// Set columns for this row
|
||||
$rowData = [sprintf($messagePattern, $apiKey)];
|
||||
$rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name() ?? '-')];
|
||||
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,12 +69,14 @@ class ListKeysCommand extends BaseCommand
|
||||
return $rowData;
|
||||
});
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(array_filter([
|
||||
ShlinkTable::withRowSeparators($output)->render(array_filter([
|
||||
'Key',
|
||||
'Name',
|
||||
! $enabledOnly ? 'Is enabled' : null,
|
||||
'Expiration date',
|
||||
'Roles',
|
||||
]), $rows);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
@@ -27,11 +27,8 @@ 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
@@ -19,6 +20,7 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_keys;
|
||||
use function array_pad;
|
||||
use function explode;
|
||||
use function Functional\map;
|
||||
@@ -30,27 +32,12 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
public const NAME = 'short-url:list';
|
||||
private const COLUMNS_TO_SHOW = [
|
||||
'shortCode',
|
||||
'title',
|
||||
'shortUrl',
|
||||
'longUrl',
|
||||
'dateCreated',
|
||||
'visitsCount',
|
||||
];
|
||||
private const COLUMNS_TO_SHOW_WITH_TAGS = [
|
||||
...self::COLUMNS_TO_SHOW,
|
||||
'tags',
|
||||
];
|
||||
|
||||
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
|
||||
@@ -90,6 +77,18 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the tags or not.',
|
||||
)
|
||||
->addOption(
|
||||
'show-api-key',
|
||||
'k',
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the API key from which the URL was generated or not.',
|
||||
)
|
||||
->addOption(
|
||||
'show-api-key-name',
|
||||
'm',
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the API key name from which the URL was generated or not.',
|
||||
)
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
@@ -117,18 +116,18 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
$searchTerm = $this->getOptionWithDeprecatedFallback($input, 'search-term');
|
||||
$tags = $input->getOption('tags');
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
$showTags = $this->getOptionWithDeprecatedFallback($input, 'show-tags');
|
||||
$all = $input->getOption('all');
|
||||
$startDate = $this->getStartDateOption($input, $output);
|
||||
$endDate = $this->getEndDateOption($input, $output);
|
||||
$orderBy = $this->processOrderBy($input);
|
||||
$columnsMap = $this->resolveColumnsMap($input);
|
||||
|
||||
$data = [
|
||||
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) {
|
||||
@@ -137,7 +136,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
|
||||
do {
|
||||
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
|
||||
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all);
|
||||
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
|
||||
$page++;
|
||||
|
||||
$continue = $result->hasNextPage() && $io->confirm(
|
||||
@@ -152,32 +151,26 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params, bool $all): Paginator
|
||||
{
|
||||
$result = $this->shortUrlService->listShortUrls($params);
|
||||
private function renderPage(
|
||||
OutputInterface $output,
|
||||
array $columnsMap,
|
||||
ShortUrlsParams $params,
|
||||
bool $all,
|
||||
): Paginator {
|
||||
$shortUrls = $this->shortUrlService->listShortUrls($params);
|
||||
|
||||
$headers = ['Short code', 'Title', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
|
||||
if ($showTags) {
|
||||
$headers[] = 'Tags';
|
||||
}
|
||||
$rows = map($shortUrls, function (ShortUrl $shortUrl) use ($columnsMap) {
|
||||
$rawShortUrl = $this->transformer->transform($shortUrl);
|
||||
return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl));
|
||||
});
|
||||
|
||||
$rows = [];
|
||||
foreach ($result as $row) {
|
||||
$columnsToShow = $showTags ? self::COLUMNS_TO_SHOW_WITH_TAGS : self::COLUMNS_TO_SHOW;
|
||||
$shortUrl = $this->transformer->transform($row);
|
||||
if ($showTags) {
|
||||
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
|
||||
}
|
||||
ShlinkTable::default($output)->render(
|
||||
array_keys($columnsMap),
|
||||
$rows,
|
||||
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||
);
|
||||
|
||||
$rows[] = map($columnsToShow, fn (string $prop) => $shortUrl[$prop]);
|
||||
}
|
||||
|
||||
ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage(
|
||||
$result,
|
||||
'Page %s of %s',
|
||||
));
|
||||
|
||||
return $result;
|
||||
return $shortUrls;
|
||||
}
|
||||
|
||||
private function processOrderBy(InputInterface $input): ?string
|
||||
@@ -190,4 +183,30 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
[$field, $dir] = array_pad(explode(',', $orderBy), 2, null);
|
||||
return $dir === null ? $field : sprintf('%s-%s', $field, $dir);
|
||||
}
|
||||
|
||||
private function resolveColumnsMap(InputInterface $input): array
|
||||
{
|
||||
$pickProp = static fn (string $prop): callable => static fn (array $shortUrl) => $shortUrl[$prop];
|
||||
$columnsMap = [
|
||||
'Short Code' => $pickProp('shortCode'),
|
||||
'Title' => $pickProp('title'),
|
||||
'Short URL' => $pickProp('shortUrl'),
|
||||
'Long URL' => $pickProp('longUrl'),
|
||||
'Date created' => $pickProp('dateCreated'),
|
||||
'Visits count' => $pickProp('visitsCount'),
|
||||
];
|
||||
if ($this->getOptionWithDeprecatedFallback($input, 'show-tags')) {
|
||||
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);
|
||||
}
|
||||
if ($input->getOption('show-api-key')) {
|
||||
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||
(string) $shortUrl->authorApiKey();
|
||||
}
|
||||
if ($input->getOption('show-api-key-name')) {
|
||||
$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
|
||||
|
||||
78
module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
Normal file
78
module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DownloadGeoLiteDbCommand extends Command
|
||||
{
|
||||
public const NAME = 'visit:download-db';
|
||||
|
||||
private ?ProgressBar $progressBar = null;
|
||||
|
||||
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription(
|
||||
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date '
|
||||
. 'copy if so.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void {
|
||||
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
$this->progressBar = new ProgressBar($io);
|
||||
}, function (int $total, int $downloaded): void {
|
||||
$this->progressBar?->setMaxSteps($total);
|
||||
$this->progressBar?->setProgress($downloaded);
|
||||
});
|
||||
|
||||
if ($this->progressBar === null) {
|
||||
$io->info('GeoLite2 db file is up to date.');
|
||||
} else {
|
||||
$this->progressBar->finish();
|
||||
$io->success('GeoLite2 db file properly downloaded.');
|
||||
}
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
$olderDbExists = $e->olderDbExists();
|
||||
|
||||
if ($olderDbExists) {
|
||||
$io->warning(
|
||||
'GeoLite2 db file update failed. Visits will continue to be located with the old version.',
|
||||
);
|
||||
} else {
|
||||
$io->error('GeoLite2 db file download failed. It will not be possible to locate visits.');
|
||||
}
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$this->getApplication()?->renderThrowable($e, $io);
|
||||
}
|
||||
|
||||
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
@@ -19,7 +17,6 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -33,30 +30,23 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
{
|
||||
public const NAME = 'visit:locate';
|
||||
|
||||
private VisitLocatorInterface $visitLocator;
|
||||
private IpLocationResolverInterface $ipLocationResolver;
|
||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
||||
|
||||
private SymfonyStyle $io;
|
||||
private ?ProgressBar $progressBar = null;
|
||||
|
||||
public function __construct(
|
||||
VisitLocatorInterface $visitLocator,
|
||||
IpLocationResolverInterface $ipLocationResolver,
|
||||
LockFactory $locker,
|
||||
GeolocationDbUpdaterInterface $dbUpdater
|
||||
private VisitLocatorInterface $visitLocator,
|
||||
private IpLocationResolverInterface $ipLocationResolver,
|
||||
LockFactory $locker
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
$this->visitLocator = $visitLocator;
|
||||
$this->ipLocationResolver = $ipLocationResolver;
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Resolves visits origin locations.')
|
||||
->setDescription(
|
||||
'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.',
|
||||
)
|
||||
->addOption(
|
||||
'retry',
|
||||
'r',
|
||||
@@ -90,12 +80,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
);
|
||||
}
|
||||
|
||||
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
|
||||
if ($all && $retry && ! $this->warnAndVerifyContinue($input)) {
|
||||
throw new RuntimeException('Execution aborted');
|
||||
}
|
||||
}
|
||||
|
||||
private function warnAndVerifyContinue(): bool
|
||||
private function warnAndVerifyContinue(InputInterface $input): bool
|
||||
{
|
||||
$this->io->warning([
|
||||
'You are about to process the location of all existing visits your short URLs received.',
|
||||
@@ -113,7 +103,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
$all = $retry && $input->getOption('all');
|
||||
|
||||
try {
|
||||
$this->checkDbUpdate();
|
||||
$this->checkDbUpdate($input);
|
||||
|
||||
if ($all) {
|
||||
$this->visitLocator->locateAllVisits($this);
|
||||
@@ -128,8 +118,8 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->io->error($e->getMessage());
|
||||
if ($e instanceof Throwable && $this->io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $this->io);
|
||||
if ($this->io->isVerbose()) {
|
||||
$this->getApplication()?->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
@@ -149,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>]');
|
||||
@@ -161,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);
|
||||
@@ -176,38 +166,23 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
$this->io->writeln($message);
|
||||
}
|
||||
|
||||
private function checkDbUpdate(): void
|
||||
private function checkDbUpdate(InputInterface $input): void
|
||||
{
|
||||
try {
|
||||
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void {
|
||||
$this->io->writeln(
|
||||
sprintf('<fg=blue>%s GeoLite2 database...</>', $olderDbExists ? 'Updating' : 'Downloading'),
|
||||
);
|
||||
$this->progressBar = new ProgressBar($this->io);
|
||||
}, function (int $total, int $downloaded): void {
|
||||
$this->progressBar->setMaxSteps($total);
|
||||
$this->progressBar->setProgress($downloaded);
|
||||
});
|
||||
$cliApp = $this->getApplication();
|
||||
if ($cliApp === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->progressBar !== null) {
|
||||
$this->progressBar->finish();
|
||||
$this->io->newLine();
|
||||
}
|
||||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
if (! $e->olderDbExists()) {
|
||||
$this->io->error('GeoLite2 database download failed. It is not possible to locate visits.');
|
||||
throw $e;
|
||||
}
|
||||
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
|
||||
$exitCode = $downloadDbCommand->run($input, $this->io);
|
||||
|
||||
$this->io->newLine();
|
||||
$this->io->writeln(
|
||||
'<fg=yellow;options=bold>[Warning] GeoLite2 database update failed. Proceeding with old version.</>',
|
||||
);
|
||||
if ($exitCode === ExitCodes::EXIT_FAILURE) {
|
||||
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function getLockConfig(): LockedCommandConfig
|
||||
{
|
||||
return LockedCommandConfig::nonBlocking($this->getName());
|
||||
return LockedCommandConfig::nonBlocking(self::NAME);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
|
||||
{
|
||||
private bool $olderDbExists;
|
||||
|
||||
private function __construct(string $message, int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public static function withOlderDb(?Throwable $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
@@ -37,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.',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user