mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-06 23:33:13 +08:00
Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a30f796100 | ||
|
|
93a2c83652 | ||
|
|
4d4423413d | ||
|
|
a1c74c4038 | ||
|
|
f71bb5e307 | ||
|
|
9190996e54 | ||
|
|
af8b6b7f96 | ||
|
|
e775b0f12f | ||
|
|
3ee5853b32 | ||
|
|
832a24e4c7 | ||
|
|
551368c30d | ||
|
|
9f24b8eb76 | ||
|
|
4c83ae2b22 | ||
|
|
28e0fb049b | ||
|
|
f79a369884 | ||
|
|
34c7b870a7 | ||
|
|
ec9f874bb9 | ||
|
|
1980d35691 | ||
|
|
ec8cbf82e5 | ||
|
|
2b1011de52 | ||
|
|
fa9ace83ad | ||
|
|
a9a53a9652 | ||
|
|
afca8b2a62 | ||
|
|
daeb293fb9 | ||
|
|
1ca50a4a8a | ||
|
|
c6602a81ab | ||
|
|
46da0e7824 | ||
|
|
e790a38cea | ||
|
|
11879ea377 | ||
|
|
7105add009 | ||
|
|
af61fdb52d | ||
|
|
2b4fc354db | ||
|
|
5b72001a8c | ||
|
|
7c79906ac4 | ||
|
|
e30a724529 | ||
|
|
73f97ea874 | ||
|
|
09c155b7d3 | ||
|
|
1e2d115768 | ||
|
|
53ba58d7e9 | ||
|
|
2a30afbe7d | ||
|
|
4d39c7041b | ||
|
|
c012b4740d | ||
|
|
55778eb810 | ||
|
|
fb8ab0b5fe | ||
|
|
fcce18b059 | ||
|
|
43a3d469e7 | ||
|
|
f730c24ecb | ||
|
|
b8522b8c17 | ||
|
|
b0d96040be | ||
|
|
5554675d03 | ||
|
|
c88401ef29 | ||
|
|
75f77ed929 | ||
|
|
4539ab2dcf | ||
|
|
9ad0561cac | ||
|
|
774052a983 | ||
|
|
3beb27acc2 | ||
|
|
5432eb7b77 | ||
|
|
181ff16409 | ||
|
|
682a0768b7 | ||
|
|
d29ebb706e | ||
|
|
4e6836c605 | ||
|
|
59c0d36c0b | ||
|
|
e10b2884c0 | ||
|
|
8fb54e815e | ||
|
|
3a14483568 | ||
|
|
fdd8efc12d | ||
|
|
3fef4b4a28 | ||
|
|
cea50a860e | ||
|
|
f9318bb1b3 | ||
|
|
d22f020eb5 | ||
|
|
c556d8123b | ||
|
|
a592833bd0 | ||
|
|
881da3db3b | ||
|
|
32eb9924e5 | ||
|
|
d2c06dd0ab | ||
|
|
75b8ed813f | ||
|
|
f811002c2b | ||
|
|
ca1b17863c | ||
|
|
644f5be6fe | ||
|
|
65fbb1dfb3 | ||
|
|
8597966187 | ||
|
|
6ddd70d21d | ||
|
|
d32112fe7e | ||
|
|
da858f0353 | ||
|
|
ba8b041698 | ||
|
|
d9fee5582a | ||
|
|
c9f17d54ee | ||
|
|
f5c1e12db4 | ||
|
|
18ceafeb60 | ||
|
|
67e93a6874 | ||
|
|
f6a83a3062 | ||
|
|
8a0e902bdd | ||
|
|
590fc3fc92 | ||
|
|
0d54b7696f | ||
|
|
6b1dadc35c | ||
|
|
86009543ed | ||
|
|
b728a78673 | ||
|
|
fb89cb80ac | ||
|
|
d0a986dd5a | ||
|
|
bb231e668b | ||
|
|
f53fa5c90f | ||
|
|
1f3e0d1f73 | ||
|
|
33a404f051 | ||
|
|
51e130c7a0 | ||
|
|
343ee04acb | ||
|
|
9372d1739a | ||
|
|
13555366e3 | ||
|
|
8162dafe16 | ||
|
|
0b6602b275 | ||
|
|
2cf9f64e8e | ||
|
|
37c0a813db | ||
|
|
a9269811dc | ||
|
|
0b353737ea | ||
|
|
a3fc1513e1 | ||
|
|
5886d73093 | ||
|
|
12adce9ac2 | ||
|
|
d8cbf0512b | ||
|
|
2bb2c2cde3 | ||
|
|
27fd9c5988 | ||
|
|
542673fcb0 | ||
|
|
e60d80bb16 | ||
|
|
bb9e57fa8b | ||
|
|
1d4bea68af | ||
|
|
d2f9f5fd5e | ||
|
|
f13c3364eb | ||
|
|
ac04bedead | ||
|
|
67a66cefa6 | ||
|
|
43db066cb4 | ||
|
|
faec758fba | ||
|
|
ccec6e03aa | ||
|
|
3f08b38558 | ||
|
|
1ee5f64738 | ||
|
|
d22169803f | ||
|
|
57807c4360 | ||
|
|
6e1d07b0cc | ||
|
|
0c0349fa39 | ||
|
|
8d8a0f2484 | ||
|
|
8ff913aaf2 | ||
|
|
f7d54abb2b | ||
|
|
ce990c67e3 | ||
|
|
907b8453c6 | ||
|
|
8a0ba11f79 | ||
|
|
0c1ecd3caa | ||
|
|
c07c37f7bd | ||
|
|
fe652c67f4 | ||
|
|
297985cf01 | ||
|
|
10f79ec01d | ||
|
|
e87d4d61bc | ||
|
|
e58f2a384e | ||
|
|
881002634a | ||
|
|
aa80c2bb82 | ||
|
|
75cd9774b7 | ||
|
|
1a8e4cdfd7 | ||
|
|
6858dc4785 | ||
|
|
5d1d9dcac3 | ||
|
|
732bb06c62 | ||
|
|
5f00d8b732 | ||
|
|
a3ff545d43 | ||
|
|
279bd12a2d | ||
|
|
1b2a0d674f | ||
|
|
fd82de31c0 | ||
|
|
327d35fe57 | ||
|
|
e18187f04e | ||
|
|
bd2f488e2c | ||
|
|
96350c8b8f | ||
|
|
a737eed5c5 | ||
|
|
9b2ccaeb7b | ||
|
|
304979273f | ||
|
|
7add41d560 | ||
|
|
51ebe57ac8 | ||
|
|
6ff5a532ea | ||
|
|
fccd92497a | ||
|
|
452bfea088 | ||
|
|
240d2588f9 | ||
|
|
eca7800487 | ||
|
|
b9e58b9300 | ||
|
|
54918db9ef | ||
|
|
b07a603456 | ||
|
|
4fb2c64fa8 | ||
|
|
258c4102be | ||
|
|
b9c7f8e8d4 | ||
|
|
f32e7cc7c4 | ||
|
|
4ebd48b2b0 | ||
|
|
f71bd84a20 | ||
|
|
33b45eb620 | ||
|
|
1f9a912c04 | ||
|
|
45151cdde6 | ||
|
|
8ca45eb388 | ||
|
|
b7a34a6640 | ||
|
|
8ec686f4e2 | ||
|
|
43fc655218 | ||
|
|
f5a30c4c2d | ||
|
|
af1dd78b2c | ||
|
|
fc95986f0e | ||
|
|
c52794aed6 | ||
|
|
15a72e2a88 | ||
|
|
94af588a3c | ||
|
|
0a4f3bc0f5 | ||
|
|
09e3464426 | ||
|
|
7fcc4ebd57 | ||
|
|
b246815529 | ||
|
|
ad1334f289 | ||
|
|
49bccf9a06 | ||
|
|
1a8bf54e8b | ||
|
|
96bb0321eb | ||
|
|
37f0abf86f | ||
|
|
f9119a38b3 | ||
|
|
8465a9da31 | ||
|
|
b6b0d09647 |
@@ -8,17 +8,16 @@ data/migrations_template.txt
|
|||||||
data/GeoLite2-City.*
|
data/GeoLite2-City.*
|
||||||
data/database.sqlite
|
data/database.sqlite
|
||||||
data/shlink-tests.db
|
data/shlink-tests.db
|
||||||
**/.gitignore
|
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
|
UPGRADE.md
|
||||||
composer.lock
|
composer.lock
|
||||||
vendor
|
vendor
|
||||||
docs
|
docs
|
||||||
indocker
|
indocker
|
||||||
docker-*
|
docker-*
|
||||||
php*
|
|
||||||
infection.json
|
|
||||||
phpstan.neon
|
phpstan.neon
|
||||||
|
php*xml*
|
||||||
|
infection.json
|
||||||
**/test*
|
**/test*
|
||||||
build*
|
build*
|
||||||
.github
|
**/.*
|
||||||
hooks
|
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/Bug.md
vendored
2
.github/ISSUE_TEMPLATE/Bug.md
vendored
@@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
|
|||||||
* Shlink Version: x.y.z
|
* Shlink Version: x.y.z
|
||||||
* PHP Version: x.y.z
|
* PHP Version: x.y.z
|
||||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
||||||
* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z)
|
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
|
||||||
|
|
||||||
#### Summary
|
#### Summary
|
||||||
|
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
2
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
|
|||||||
* Shlink Version: x.y.z
|
* Shlink Version: x.y.z
|
||||||
* PHP Version: x.y.z
|
* PHP Version: x.y.z
|
||||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
||||||
* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z)
|
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
|
||||||
|
|
||||||
#### Summary
|
#### Summary
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,6 @@
|
|||||||
.idea
|
.idea
|
||||||
build
|
build
|
||||||
!hooks/build
|
!docker/build
|
||||||
composer.lock
|
composer.lock
|
||||||
composer.phar
|
composer.phar
|
||||||
vendor/
|
vendor/
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ checks:
|
|||||||
code_rating: true
|
code_rating: true
|
||||||
duplication: true
|
duplication: true
|
||||||
build:
|
build:
|
||||||
|
dependencies:
|
||||||
|
override:
|
||||||
|
- composer install --no-interaction --no-scripts --ignore-platform-reqs
|
||||||
nodes:
|
nodes:
|
||||||
analysis:
|
analysis:
|
||||||
tests:
|
tests:
|
||||||
|
|||||||
14
.travis.yml
14
.travis.yml
@@ -18,7 +18,7 @@ cache:
|
|||||||
|
|
||||||
before_install:
|
before_install:
|
||||||
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||||
- yes | pecl install swoole
|
- yes | pecl install swoole-4.4.15
|
||||||
- phpenv config-rm xdebug.ini || return 0
|
- phpenv config-rm xdebug.ini || return 0
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@@ -37,15 +37,15 @@ script:
|
|||||||
|
|
||||||
after_success:
|
after_success:
|
||||||
- rm -f build/clover.xml
|
- rm -f build/clover.xml
|
||||||
- wget https://phar.phpunit.de/phpcov-6.0.1.phar
|
- wget https://phar.phpunit.de/phpcov-7.0.2.phar
|
||||||
- phpdbg -qrr phpcov-6.0.1.phar merge build --clover build/clover.xml
|
- phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml
|
||||||
- wget https://scrutinizer-ci.com/ocular.phar
|
- wget https://scrutinizer-ci.com/ocular.phar
|
||||||
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
||||||
|
|
||||||
# Before deploying, build dist file for current travis tag
|
# Before deploying, build dist file for current travis tag
|
||||||
before_deploy:
|
before_deploy:
|
||||||
- rm -f ocular.phar
|
- rm -f ocular.phar
|
||||||
- ./build.sh ${TRAVIS_TAG#?}
|
- if [[ ! -z $TRAVIS_TAG && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
- provider: releases
|
- provider: releases
|
||||||
@@ -56,3 +56,9 @@ deploy:
|
|||||||
on:
|
on:
|
||||||
tags: true
|
tags: true
|
||||||
php: '7.4'
|
php: '7.4'
|
||||||
|
- provider: script
|
||||||
|
script: bash ./docker/build
|
||||||
|
on:
|
||||||
|
all_branches: true
|
||||||
|
condition: $TRAVIS_PULL_REQUEST == 'false'
|
||||||
|
php: '7.4'
|
||||||
|
|||||||
255
CHANGELOG.md
255
CHANGELOG.md
@@ -4,6 +4,261 @@ 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).
|
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## 2.1.4 - 2020-04-30
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#742](https://github.com/shlinkio/shlink/issues/742) Allowed a custom GeoLite2 license key to be provided, in order to avoid download limits.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.1.3 - 2020-04-09
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#712](https://github.com/shlinkio/shlink/issues/712) Fixed app set-up not clearing entities metadata cache.
|
||||||
|
* [#711](https://github.com/shlinkio/shlink/issues/711) Fixed `HEAD` requests returning a duplicated `Content-Length` header.
|
||||||
|
* [#716](https://github.com/shlinkio/shlink/issues/716) Fixed Twitter not properly displaying preview for final long URL.
|
||||||
|
* [#717](https://github.com/shlinkio/shlink/issues/717) Fixed DB connection expiring on task workers when using swoole.
|
||||||
|
* [#705](https://github.com/shlinkio/shlink/issues/705) Fixed how the short URL domain is inferred when generating QR codes, making sure the configured domain is respected even if the request is performed using a different one, and only when a custom domain is used, then that one is used instead.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.1.2 - 2020-03-29
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#696](https://github.com/shlinkio/shlink/issues/696) Updated to infection v0.16.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#700](https://github.com/shlinkio/shlink/issues/700) Fixed migration not working with postgres.
|
||||||
|
* [#690](https://github.com/shlinkio/shlink/issues/690) Fixed tags being incorrectly sluggified when filtering short URL lists, making results not be the expected.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.1.1 - 2020-03-28
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#697](https://github.com/shlinkio/shlink/issues/697) Recovered `.htaccess` file that was unintentionally removed in v2.1.0, making Shlink unusable with Apache.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.1.0 - 2020-03-28
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#626](https://github.com/shlinkio/shlink/issues/626) Added support for Microsoft SQL Server.
|
||||||
|
* [#556](https://github.com/shlinkio/shlink/issues/556) Short code lengths can now be customized, both globally and on a per-short URL basis.
|
||||||
|
* [#541](https://github.com/shlinkio/shlink/issues/541) Added a request ID that is returned on `X-Request-Id` header, can be provided from outside and is set in log entries.
|
||||||
|
* [#642](https://github.com/shlinkio/shlink/issues/642) IP geolocation is now performed over the non-anonymized IP address when using swoole.
|
||||||
|
* [#521](https://github.com/shlinkio/shlink/issues/521) The long URL for any existing short URL can now be edited using the `PATCH /short-urls/{shortCode}` endpoint.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#656](https://github.com/shlinkio/shlink/issues/656) Updated to PHPUnit 9.
|
||||||
|
* [#641](https://github.com/shlinkio/shlink/issues/641) Added two new flags to the `visit:locate` command, `--retry` and `--all`.
|
||||||
|
|
||||||
|
* When `--retry` is provided, it will try to re-locate visits which IP address was originally considered not found, in case it was a temporal issue.
|
||||||
|
* When `--all` is provided together with `--retry`, it will try to re-locate all existing visits. A warning and confirmation are displayed, as this can have side effects.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#665](https://github.com/shlinkio/shlink/issues/665) Fixed `base_url_redirect_to` simplified config option not being properly parsed.
|
||||||
|
* [#663](https://github.com/shlinkio/shlink/issues/663) Fixed Shlink allowing short URLs to be created with an empty custom slug.
|
||||||
|
* [#678](https://github.com/shlinkio/shlink/issues/678) Fixed `db` commands not running in a non-interactive way.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.0.5 - 2020-02-09
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#651](https://github.com/shlinkio/shlink/issues/651) Documented how Shlink behaves when using multiple domains.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#648](https://github.com/shlinkio/shlink/issues/648) Ensured any user can write in log files, in case shlink is run by several system users.
|
||||||
|
* [#650](https://github.com/shlinkio/shlink/issues/650) Ensured default domain is ignored when trying to create a short URL.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.0.4 - 2020-02-02
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#577](https://github.com/shlinkio/shlink/issues/577) Wrapped params used to customize short URL lists into a DTO with implicit validation.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#620](https://github.com/shlinkio/shlink/issues/620) Ensured "controlled" errors (like validation errors and such) won't be logged with error level, preventing logs to be polluted.
|
||||||
|
* [#637](https://github.com/shlinkio/shlink/issues/637) Fixed several work flows in which short URLs with domain are handled form the API.
|
||||||
|
* [#644](https://github.com/shlinkio/shlink/issues/644) Fixed visits to short URL on non-default domain being linked to the URL on default domain with the same short code.
|
||||||
|
* [#643](https://github.com/shlinkio/shlink/issues/643) Fixed searching on short URL lists not taking into consideration the domain name.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.0.3 - 2020-01-27
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#624](https://github.com/shlinkio/shlink/issues/624) Fixed order in which headers for remote IP detection are inspected.
|
||||||
|
* [#623](https://github.com/shlinkio/shlink/issues/623) Fixed short URLs metadata being impossible to reset.
|
||||||
|
* [#628](https://github.com/shlinkio/shlink/issues/628) Fixed `GET /short-urls/{shortCode}` REST endpoint returning a 404 for short URLs which are not enabled.
|
||||||
|
* [#621](https://github.com/shlinkio/shlink/issues/621) Fixed permission denied error when updating same GeoLite file version more than once.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.0.2 - 2020-01-12
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#614](https://github.com/shlinkio/shlink/issues/614) Fixed `OPTIONS` requests including the `Origin` header not always returning an empty body with status 2xx.
|
||||||
|
* [#615](https://github.com/shlinkio/shlink/issues/615) Fixed query args with no value being lost from the long URL when users are redirected.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.0.1 - 2020-01-10
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#607](https://github.com/shlinkio/shlink/issues/607) Added missing info on UPGRADE.md doc.
|
||||||
|
* [#610](https://github.com/shlinkio/shlink/issues/610) Fixed use of hardcoded quotes on a database migration which makes it fail on postgres.
|
||||||
|
* [#605](https://github.com/shlinkio/shlink/issues/605) Fixed crashes occurring when migrating from old Shlink versions with nullable DB columns that are assigned to non-nullable entity typed props.
|
||||||
|
|
||||||
|
|
||||||
## 2.0.0 - 2020-01-08
|
## 2.0.0 - 2020-01-08
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|||||||
45
Dockerfile
45
Dockerfile
@@ -1,15 +1,14 @@
|
|||||||
FROM php:7.4.1-alpine3.10
|
FROM php:7.4.2-alpine3.11 as base
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
|
||||||
|
|
||||||
ARG SHLINK_VERSION=2.0.0
|
ARG SHLINK_VERSION=2.0.5
|
||||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||||
ENV SWOOLE_VERSION 4.4.12
|
ENV SWOOLE_VERSION 4.4.15
|
||||||
ENV COMPOSER_VERSION 1.9.1
|
ENV LC_ALL "C"
|
||||||
|
|
||||||
WORKDIR /etc/shlink
|
WORKDIR /etc/shlink
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
# Install mysl and calendar
|
# Install mysql and calendar
|
||||||
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
|
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
|
||||||
# Install sqlite
|
# Install sqlite
|
||||||
apk add --no-cache sqlite-libs sqlite-dev && \
|
apk add --no-cache sqlite-libs sqlite-dev && \
|
||||||
@@ -24,24 +23,36 @@ RUN \
|
|||||||
apk add --no-cache libzip-dev zlib-dev libpng-dev && \
|
apk add --no-cache libzip-dev zlib-dev libpng-dev && \
|
||||||
docker-php-ext-install -j"$(nproc)" zip gd
|
docker-php-ext-install -j"$(nproc)" zip gd
|
||||||
|
|
||||||
# Install swoole
|
# Install swoole and sqlsrv driver
|
||||||
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
|
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||||
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
|
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||||
pecl install swoole-${SWOOLE_VERSION} && \
|
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||||
docker-php-ext-enable swoole && \
|
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||||
apk del .phpize-deps
|
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||||
|
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||||
|
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||||
|
apk del .phpize-deps && \
|
||||||
|
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||||
|
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||||
|
|
||||||
|
|
||||||
# Install shlink
|
# Install shlink
|
||||||
|
FROM base as builder
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN rm -rf ./docker && \
|
COPY --from=composer:1.10.1 /usr/bin/composer ./composer.phar
|
||||||
wget https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar && \
|
RUN apk add --no-cache git && \
|
||||||
php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \
|
php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \
|
||||||
php composer.phar clear-cache && \
|
php composer.phar clear-cache && \
|
||||||
rm composer.*
|
rm -r docker composer.* && \
|
||||||
|
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
|
||||||
|
|
||||||
# Add shlink to the path to ease running it after container is created
|
|
||||||
|
# Prepare final image
|
||||||
|
FROM base
|
||||||
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
|
|
||||||
|
COPY --from=builder /etc/shlink .
|
||||||
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
|
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
|
||||||
RUN sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
|
|
||||||
|
|
||||||
# Expose swoole port
|
# Expose swoole port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
79
README.md
79
README.md
@@ -4,8 +4,9 @@
|
|||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||||
[](https://packagist.org/packages/shlinkio/shlink)
|
[](https://packagist.org/packages/shlinkio/shlink)
|
||||||
|
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||||
[](https://github.com/shlinkio/shlink/blob/master/LICENSE)
|
[](https://github.com/shlinkio/shlink/blob/master/LICENSE)
|
||||||
[](https://acel.me/donate)
|
[](https://slnk.to/donate)
|
||||||
|
|
||||||
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
|
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
|
||||||
|
|
||||||
@@ -22,6 +23,10 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
|||||||
- [Using a docker image](#using-a-docker-image)
|
- [Using a docker image](#using-a-docker-image)
|
||||||
- [Using shlink](#using-shlink)
|
- [Using shlink](#using-shlink)
|
||||||
- [Shlink CLI Help](#shlink-cli-help)
|
- [Shlink CLI Help](#shlink-cli-help)
|
||||||
|
- [Multiple domains](#multiple-domains)
|
||||||
|
- [Management](#management)
|
||||||
|
- [Visits](#visits)
|
||||||
|
- [Special redirects](#special-redirects)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -31,8 +36,8 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
|||||||
|
|
||||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||||
|
|
||||||
* PHP 7.4 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
|
* PHP 7.4 or greater with JSON, curl, PDO and gd extensions enabled.
|
||||||
* MySQL, MariaDB, PostgreSQL or SQLite.
|
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
|
||||||
* The web server of your choice with PHP integration (Apache or Nginx recommended).
|
* The web server of your choice with PHP integration (Apache or Nginx recommended).
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
@@ -63,7 +68,7 @@ In order to run Shlink, you will need a built version of the project. There are
|
|||||||
|
|
||||||
Despite how you built the project, you now need to configure it, by following these steps:
|
Despite how you built the project, you now need to configure it, by following these steps:
|
||||||
|
|
||||||
* If you are going to use MySQL, MariaDB or PostgreSQL, create an empty database with the name of your choice.
|
* If you are going to use MySQL, MariaDB, PostgreSQL or Microsoft SQL Server, create an empty database with the name of your choice.
|
||||||
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
|
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
|
||||||
* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
|
* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
|
||||||
* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API.
|
* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API.
|
||||||
@@ -92,7 +97,7 @@ Once Shlink is configured, you need to expose it to the web, either by using a t
|
|||||||
|
|
||||||
location ~ \.php$ {
|
location ~ \.php$ {
|
||||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||||
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
|
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
|
||||||
fastcgi_index index.php;
|
fastcgi_index index.php;
|
||||||
include fastcgi.conf;
|
include fastcgi.conf;
|
||||||
}
|
}
|
||||||
@@ -234,7 +239,7 @@ Once shlink is installed, there are two main ways to interact with it:
|
|||||||
|
|
||||||
It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory.
|
It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory.
|
||||||
|
|
||||||
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal.
|
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/documentation/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal.
|
||||||
|
|
||||||
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too.
|
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too.
|
||||||
|
|
||||||
@@ -280,4 +285,66 @@ Available commands:
|
|||||||
visit:locate Resolves visits origin locations.
|
visit:locate Resolves visits origin locations.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multiple domains
|
||||||
|
|
||||||
|
While in many cases you will just have one short domain and you'll want all your short URLs to be served from it, there are some cases in which you might want to have multiple short domains served from the same Shlink instance.
|
||||||
|
|
||||||
|
If that's the case, you need to understand how Shlink will behave when managing your short URLs or any of them is visited.
|
||||||
|
|
||||||
|
### Management
|
||||||
|
|
||||||
|
When you create a short URL it is possible to optionally pass a `domain` param. If you don't pass it, the short URL will be created for the default domain (the one provided during Shlink's installation or in the `SHORT_DOMAIN_HOST` env var when using the docker image).
|
||||||
|
|
||||||
|
However, if you pass it, the short URL will be "linked" to that domain.
|
||||||
|
|
||||||
|
> Note that, if the default domain is passed, Shlink will ignore it and will behave as if no `domain` param was provided.
|
||||||
|
|
||||||
|
The main benefit of being able to pass the domain is that Shlink will allow the same custom slug to be used in multiple short URLs, as long as the domain is different (like `example.com/my-compaign`, `another.com/my-compaign` and `foo.com/my-compaign`).
|
||||||
|
|
||||||
|
Then, each short URL will be tracked separately and you will be able to define specific tags and metadata for each one of them.
|
||||||
|
|
||||||
|
However, this has a side effect. When you try to interact with an existing short URL (editing tags, editing meta, resolving it or deleting it), either from the REST API or the CLI tool, you will have to provide the domain appropriately.
|
||||||
|
|
||||||
|
Let's imagine this situation. Shlink's default domain is `example.com`, and you have the next short URLs:
|
||||||
|
|
||||||
|
* `https://example.com/abc123` -> a regular short URL where no domain was provided.
|
||||||
|
* `https://example.com/my-campaign` -> a regular short URL where no domain was provided, but it has a custom slug.
|
||||||
|
* `https://another.com/my-campaign` -> a short URL where the `another.com` domain was provided, and it has a custom slug.
|
||||||
|
* `https://another.com/def456` -> a short URL where the `another.com` domain was provided.
|
||||||
|
|
||||||
|
These are some of the results you will get when trying to interact with them, depending on the params you provide:
|
||||||
|
|
||||||
|
* Providing just the `abc123` short code -> the first URL will be matched.
|
||||||
|
* Providing just the `my-campaign` short code -> the second URL will be matched, since you did not specify a domain, therefor, Shlink looks for the one with the short code/slug `my-campaign` which is also linked to default domain (or not linked to any domain, to be more accurate).
|
||||||
|
* Providing the `my-campaign` short code and the `another.com` domain -> The third one will be matched.
|
||||||
|
* Providing just the `def456` short code -> Shlink will fail/not find any short URL, since there's none with the short code `def456` linked to default domain.
|
||||||
|
* Providing the `def456` short code and the `another.com` domain -> The fourth short URL will be matched.
|
||||||
|
* Providing any short code and the `foo.com` domain -> Again, no short URL will be found, as there's none linked to `foo.com` domain.
|
||||||
|
|
||||||
|
### Visits
|
||||||
|
|
||||||
|
Before adding support for multiple domains, you could point as many domains as you wanted to Shlink, and they would have always worked for existing short codes/slugs.
|
||||||
|
|
||||||
|
In order to keep backwards compatibility, Shlink's behavior when a short URL is visited is slightly different, getting to fallback in some cases.
|
||||||
|
|
||||||
|
Let's continue with previous example, and also consider we have three domains that will resolve to our Shlink instance, which are `example.com`, `another.com` and `foo.com`.
|
||||||
|
|
||||||
|
With that in mind, this is how Shlink will behave when the next short URLs are visited:
|
||||||
|
|
||||||
|
* `https://another.com/abc123` -> There was no short URL specifically defined for domain `another.com` and short code `abc123`, but it exists for default domain (`example.com`), so it will fall back to it and redirect to where `example.com/abc123` is configured to redirect.
|
||||||
|
* `https://example.com/def456` -> The fall-back does not happen from default domain to specific ones, only the other way around (like in previous case). Because of that, this one will result in a not-found URL, even though the `def456` short code exists for `another.com` domain.
|
||||||
|
* `https://foo.com/abc123` -> This will also fall-back to `example.com/abc123`, like in the first case.
|
||||||
|
* `https://another.com/non-existing` -> The combination of `another.com` domain with the `non-existing` slug does not exist, so Shlink will try to fall-back to the same but for default domain (`example.com`). However, since that combination does not exist either, it will result in a not-found URL.
|
||||||
|
* Any other short URL visited exactly as it was configured will, of course, resolve as expected.
|
||||||
|
|
||||||
|
### Special redirects
|
||||||
|
|
||||||
|
It is currently possible to configure some special redirects when the base domain is visited, a URL does not match, or an invalid/disabled short URL is visited.
|
||||||
|
|
||||||
|
Those are configured during Shlink's installation or via env vars when using the docker image.
|
||||||
|
|
||||||
|
Currently those are all shared for all domains serving the same Shlink instance, but the plan is to update that and allow specific ones for every existing domain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
||||||
|
|||||||
18
UPGRADE.md
18
UPGRADE.md
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## From v1.x to v2.x
|
## From v1.x to v2.x
|
||||||
|
|
||||||
|
### PHP 7.4 required
|
||||||
|
|
||||||
|
This new version takes advantage of several new features introduced in PHP 7.4.
|
||||||
|
|
||||||
|
Thanks to that, the code is more reliable and robust, and easier to maintain and improve.
|
||||||
|
|
||||||
|
However, that means that any previous PHP version is no longer supported.
|
||||||
|
|
||||||
### Preview generation
|
### Preview generation
|
||||||
|
|
||||||
The ability to generate website previews has been completely removed and has no replacement.
|
The ability to generate website previews has been completely removed and has no replacement.
|
||||||
@@ -43,6 +51,16 @@ Endpoints need to provide a version in the path now. Previously, not providing a
|
|||||||
|
|
||||||
The only exception is the `/rest/health` endpoint, which will continue working without the version.
|
The only exception is the `/rest/health` endpoint, which will continue working without the version.
|
||||||
|
|
||||||
|
### API errors
|
||||||
|
|
||||||
|
Shlink v1.21.0 introduced support for API errors using the Problem Details format, as well as the v2 of the API.
|
||||||
|
|
||||||
|
For backwards compatibility reasons, requests performed to v1 continued to return the old `error` and `message` properties.
|
||||||
|
|
||||||
|
Starting with Shlink v2.0.0, both versions of the API will no longer return those two properties.
|
||||||
|
|
||||||
|
As a replacement, use `type` instead of `error`, and `detail` instead of `message`.
|
||||||
|
|
||||||
### Changes in models
|
### Changes in models
|
||||||
|
|
||||||
The next REST API models have changed:
|
The next REST API models have changed:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ echo 'Starting server...'
|
|||||||
vendor/bin/mezzio-swoole start -d
|
vendor/bin/mezzio-swoole start -d
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
||||||
testsExitCode=$?
|
testsExitCode=$?
|
||||||
|
|
||||||
vendor/bin/mezzio-swoole stop
|
vendor/bin/mezzio-swoole stop
|
||||||
|
|||||||
3
build.sh
3
build.sh
@@ -19,13 +19,14 @@ mkdir -p "${builtcontent}"
|
|||||||
rsync -av * "${builtcontent}" \
|
rsync -av * "${builtcontent}" \
|
||||||
--exclude=*docker* \
|
--exclude=*docker* \
|
||||||
--exclude=Dockerfile \
|
--exclude=Dockerfile \
|
||||||
|
--include=.htaccess \
|
||||||
--exclude-from=./.dockerignore
|
--exclude-from=./.dockerignore
|
||||||
cd "${builtcontent}"
|
cd "${builtcontent}"
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
echo "Installing dependencies with $composerBin..."
|
echo "Installing dependencies with $composerBin..."
|
||||||
${composerBin} self-update
|
${composerBin} self-update
|
||||||
${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction
|
${composerBin} install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction
|
||||||
|
|
||||||
# Delete development files
|
# Delete development files
|
||||||
echo 'Deleting dev files...'
|
echo 'Deleting dev files...'
|
||||||
|
|||||||
@@ -40,17 +40,20 @@
|
|||||||
"mezzio/mezzio-helpers": "^5.3",
|
"mezzio/mezzio-helpers": "^5.3",
|
||||||
"mezzio/mezzio-platesrenderer": "^2.1",
|
"mezzio/mezzio-platesrenderer": "^2.1",
|
||||||
"mezzio/mezzio-problem-details": "^1.1",
|
"mezzio/mezzio-problem-details": "^1.1",
|
||||||
"mezzio/mezzio-swoole": "^2.4",
|
"mezzio/mezzio-swoole": "^2.6",
|
||||||
"monolog/monolog": "^2.0",
|
"monolog/monolog": "^2.0",
|
||||||
"nikolaposa/monolog-factory": "^3.0",
|
"nikolaposa/monolog-factory": "^3.0",
|
||||||
"ocramius/proxy-manager": "^2.6.0",
|
"ocramius/proxy-manager": "^2.7.0",
|
||||||
"phly/phly-event-dispatcher": "^1.0",
|
"phly/phly-event-dispatcher": "^1.0",
|
||||||
|
"php-middleware/request-id": "^4.0",
|
||||||
"predis/predis": "^1.1",
|
"predis/predis": "^1.1",
|
||||||
"pugx/shortid-php": "^0.5",
|
"pugx/shortid-php": "^0.5",
|
||||||
"shlinkio/shlink-common": "^2.5",
|
"ramsey/uuid": "^3.9",
|
||||||
"shlinkio/shlink-event-dispatcher": "^1.3",
|
"shlinkio/shlink-common": "^3.0",
|
||||||
"shlinkio/shlink-installer": "^4.0",
|
"shlinkio/shlink-config": "^1.0",
|
||||||
"shlinkio/shlink-ip-geolocation": "^1.3",
|
"shlinkio/shlink-event-dispatcher": "^1.4",
|
||||||
|
"shlinkio/shlink-installer": "^4.4.0",
|
||||||
|
"shlinkio/shlink-ip-geolocation": "^1.4",
|
||||||
"symfony/console": "^5.0",
|
"symfony/console": "^5.0",
|
||||||
"symfony/filesystem": "^5.0",
|
"symfony/filesystem": "^5.0",
|
||||||
"symfony/lock": "^5.0",
|
"symfony/lock": "^5.0",
|
||||||
@@ -58,13 +61,14 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"devster/ubench": "^2.0",
|
"devster/ubench": "^2.0",
|
||||||
|
"dms/phpunit-arraysubset-asserts": "^0.2.0",
|
||||||
"eaglewu/swoole-ide-helper": "dev-master",
|
"eaglewu/swoole-ide-helper": "dev-master",
|
||||||
"infection/infection": "^0.15.0",
|
"infection/infection": "^0.16.1",
|
||||||
"phpstan/phpstan": "^0.12.3",
|
"phpstan/phpstan": "^0.12.18",
|
||||||
"phpunit/phpunit": "^8.3",
|
"phpunit/phpunit": "~9.0.1",
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
"shlinkio/php-coding-standard": "~2.1.0",
|
"shlinkio/php-coding-standard": "~2.1.0",
|
||||||
"shlinkio/shlink-test-utils": "^1.3",
|
"shlinkio/shlink-test-utils": "^1.4",
|
||||||
"symfony/var-dumper": "^5.0"
|
"symfony/var-dumper": "^5.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -97,7 +101,7 @@
|
|||||||
],
|
],
|
||||||
"cs": "phpcs",
|
"cs": "phpcs",
|
||||||
"cs:fix": "phpcbf",
|
"cs:fix": "phpcbf",
|
||||||
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config --level=5 -c phpstan.neon",
|
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config --level=6",
|
||||||
"test": [
|
"test": [
|
||||||
"@test:unit",
|
"@test:unit",
|
||||||
"@test:db",
|
"@test:db",
|
||||||
@@ -106,7 +110,7 @@
|
|||||||
"test:ci": [
|
"test:ci": [
|
||||||
"@test:unit:ci",
|
"@test:unit:ci",
|
||||||
"@test:db:ci",
|
"@test:db:ci",
|
||||||
"@test:api"
|
"@test:api:ci"
|
||||||
],
|
],
|
||||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||||
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
|
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
|
||||||
@@ -114,7 +118,8 @@
|
|||||||
"@test:db:sqlite",
|
"@test:db:sqlite",
|
||||||
"@test:db:mysql",
|
"@test:db:mysql",
|
||||||
"@test:db:maria",
|
"@test:db:maria",
|
||||||
"@test:db:postgres"
|
"@test:db:postgres",
|
||||||
|
"@test:db:ms"
|
||||||
],
|
],
|
||||||
"test:db:ci": [
|
"test:db:ci": [
|
||||||
"@test:db:sqlite",
|
"@test:db:sqlite",
|
||||||
@@ -125,10 +130,12 @@
|
|||||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
||||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
||||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
"test:db: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:api": "bin/test/run-api-tests.sh",
|
||||||
|
"test:api:ci": "@test:api --coverage-php build/coverage-api.cov",
|
||||||
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
|
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
|
||||||
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
||||||
"infect:ci": "@infect --coverage=build",
|
"infect:ci": "@infect --coverage=build --skip-initial-tests",
|
||||||
"infect:show": "@infect --show-mutations",
|
"infect:show": "@infect --show-mutations",
|
||||||
"infect:test": [
|
"infect:test": [
|
||||||
"@test:unit:ci",
|
"@test:unit:ci",
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ return [
|
|||||||
'ip_address_resolution' => [
|
'ip_address_resolution' => [
|
||||||
'headers_to_inspect' => [
|
'headers_to_inspect' => [
|
||||||
'CF-Connecting-IP',
|
'CF-Connecting-IP',
|
||||||
'True-Client-IP',
|
|
||||||
'X-Real-IP',
|
|
||||||
'Forwarded',
|
|
||||||
'X-Forwarded-For',
|
'X-Forwarded-For',
|
||||||
'X-Forwarded',
|
'X-Forwarded',
|
||||||
|
'Forwarded',
|
||||||
|
'True-Client-IP',
|
||||||
|
'X-Real-IP',
|
||||||
'X-Cluster-Client-Ip',
|
'X-Cluster-Client-Ip',
|
||||||
'Client-Ip',
|
'Client-Ip',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Laminas\ConfigAggregator\ConfigAggregator;
|
use Laminas\ConfigAggregator\ConfigAggregator;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ return [
|
|||||||
'entity_manager' => [
|
'entity_manager' => [
|
||||||
'orm' => [
|
'orm' => [
|
||||||
'proxies_dir' => 'data/proxies',
|
'proxies_dir' => 'data/proxies',
|
||||||
|
'load_mappings_using_functional_style' => true,
|
||||||
],
|
],
|
||||||
'connection' => [
|
'connection' => [
|
||||||
'user' => '',
|
'user' => '',
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ return [
|
|||||||
'geolite2' => [
|
'geolite2' => [
|
||||||
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
|
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
|
||||||
'temp_dir' => sys_get_temp_dir(),
|
'temp_dir' => sys_get_temp_dir(),
|
||||||
'download_from' =>
|
'license_key' => 'G4Lm0C60yJsnkdPi',
|
||||||
'https://download.maxmind.com/app/geoip_download'
|
|
||||||
. '?edition_id=GeoLite2-City&license_key=G4Lm0C60yJsnkdPi&suffix=tar.gz',
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ return [
|
|||||||
Option\TaskWorkerNumConfigOption::class,
|
Option\TaskWorkerNumConfigOption::class,
|
||||||
Option\WebWorkerNumConfigOption::class,
|
Option\WebWorkerNumConfigOption::class,
|
||||||
Option\RedisServersConfigOption::class,
|
Option\RedisServersConfigOption::class,
|
||||||
|
Option\ShortCodeLengthOption::class,
|
||||||
|
Option\GeoLiteLicenseKeyConfigOption::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'installation_commands' => [
|
'installation_commands' => [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
|
|||||||
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
|
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
|
||||||
use Symfony\Component\Lock;
|
use Symfony\Component\Lock;
|
||||||
|
|
||||||
$localLockFactory = 'Shlinkio\Shlink\LocalLockFactory';
|
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ return [
|
|||||||
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
|
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
|
||||||
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
|
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
|
||||||
Lock\LockFactory::class => ConfigAbstractFactory::class,
|
Lock\LockFactory::class => ConfigAbstractFactory::class,
|
||||||
$localLockFactory => ConfigAbstractFactory::class,
|
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
|
||||||
],
|
],
|
||||||
'aliases' => [
|
'aliases' => [
|
||||||
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
|
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
|
||||||
@@ -44,7 +44,7 @@ return [
|
|||||||
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
|
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
|
||||||
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
|
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
|
||||||
Lock\LockFactory::class => ['lock_store'],
|
Lock\LockFactory::class => ['lock_store'],
|
||||||
$localLockFactory => ['local_lock_store'],
|
LOCAL_LOCK_FACTORY => ['local_lock_store'],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Monolog\Handler;
|
|||||||
use Monolog\Logger;
|
use Monolog\Logger;
|
||||||
use Monolog\Processor;
|
use Monolog\Processor;
|
||||||
use MonologFactory\DiContainerLoggerFactory;
|
use MonologFactory\DiContainerLoggerFactory;
|
||||||
|
use PhpMiddleware\RequestId;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
use const PHP_EOL;
|
use const PHP_EOL;
|
||||||
@@ -20,11 +21,12 @@ $processors = [
|
|||||||
'psr3' => [
|
'psr3' => [
|
||||||
'name' => Processor\PsrLogMessageProcessor::class,
|
'name' => Processor\PsrLogMessageProcessor::class,
|
||||||
],
|
],
|
||||||
|
'request_id' => RequestId\MonologProcessor::class,
|
||||||
];
|
];
|
||||||
$formatter = [
|
$formatter = [
|
||||||
'name' => Formatter\LineFormatter::class,
|
'name' => Formatter\LineFormatter::class,
|
||||||
'params' => [
|
'params' => [
|
||||||
'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL,
|
'format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%' . PHP_EOL,
|
||||||
'allow_inline_line_breaks' => true,
|
'allow_inline_line_breaks' => true,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -41,6 +43,7 @@ return [
|
|||||||
'level' => Logger::INFO,
|
'level' => Logger::INFO,
|
||||||
'filename' => 'data/log/shlink_log.log',
|
'filename' => 'data/log/shlink_log.log',
|
||||||
'max_files' => 30,
|
'max_files' => 30,
|
||||||
|
'file_permission' => 0666,
|
||||||
],
|
],
|
||||||
'formatter' => $formatter,
|
'formatter' => $formatter,
|
||||||
],
|
],
|
||||||
@@ -79,6 +82,7 @@ return [
|
|||||||
'swoole-http-server' => [
|
'swoole-http-server' => [
|
||||||
'logger' => [
|
'logger' => [
|
||||||
'logger-name' => 'Logger_Access',
|
'logger-name' => 'Logger_Access',
|
||||||
|
'format' => '%h %l %u "%r" %>s %b',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink;
|
namespace Shlinkio\Shlink;
|
||||||
|
|
||||||
use Laminas\Stratigility\Middleware\ErrorHandler;
|
use Laminas\Stratigility\Middleware\ErrorHandler;
|
||||||
use Mezzio;
|
use Mezzio\Helper;
|
||||||
use Mezzio\ProblemDetails;
|
use Mezzio\ProblemDetails;
|
||||||
|
use Mezzio\Router;
|
||||||
|
use PhpMiddleware\RequestId\RequestIdMiddleware;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'middleware_pipeline' => [
|
'middleware_pipeline' => [
|
||||||
'error-handler' => [
|
'error-handler' => [
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Mezzio\Helper\ContentLengthMiddleware::class,
|
Helper\ContentLengthMiddleware::class,
|
||||||
ErrorHandler::class,
|
ErrorHandler::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -21,6 +23,7 @@ return [
|
|||||||
'path' => '/rest',
|
'path' => '/rest',
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Rest\Middleware\CrossDomainMiddleware::class,
|
Rest\Middleware\CrossDomainMiddleware::class,
|
||||||
|
RequestIdMiddleware::class,
|
||||||
ProblemDetails\ProblemDetailsMiddleware::class,
|
ProblemDetails\ProblemDetailsMiddleware::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -33,14 +36,15 @@ return [
|
|||||||
|
|
||||||
'routing' => [
|
'routing' => [
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Mezzio\Router\Middleware\RouteMiddleware::class,
|
Router\Middleware\RouteMiddleware::class,
|
||||||
|
Router\Middleware\ImplicitHeadMiddleware::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'rest' => [
|
'rest' => [
|
||||||
'path' => '/rest',
|
'path' => '/rest',
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Mezzio\Router\Middleware\ImplicitOptionsMiddleware::class,
|
Router\Middleware\ImplicitOptionsMiddleware::class,
|
||||||
Rest\Middleware\BodyParserMiddleware::class,
|
Rest\Middleware\BodyParserMiddleware::class,
|
||||||
Rest\Middleware\AuthenticationMiddleware::class,
|
Rest\Middleware\AuthenticationMiddleware::class,
|
||||||
],
|
],
|
||||||
@@ -48,7 +52,7 @@ return [
|
|||||||
|
|
||||||
'dispatch' => [
|
'dispatch' => [
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Mezzio\Router\Middleware\DispatchMiddleware::class,
|
Router\Middleware\DispatchMiddleware::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -65,4 +69,5 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
38
config/autoload/request_id.global.php
Normal file
38
config/autoload/request_id.global.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||||
|
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||||
|
use PhpMiddleware\RequestId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'request_id' => [
|
||||||
|
'allow_override' => true,
|
||||||
|
'header_name' => 'X-Request-Id',
|
||||||
|
],
|
||||||
|
|
||||||
|
'dependencies' => [
|
||||||
|
'factories' => [
|
||||||
|
RequestId\Generator\RamseyUuid4StaticGenerator::class => InvokableFactory::class,
|
||||||
|
RequestId\RequestIdProviderFactory::class => ConfigAbstractFactory::class,
|
||||||
|
RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class,
|
||||||
|
RequestId\MonologProcessor::class => ConfigAbstractFactory::class,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
ConfigAbstractFactory::class => [
|
||||||
|
RequestId\RequestIdProviderFactory::class => [
|
||||||
|
RequestId\Generator\RamseyUuid4StaticGenerator::class,
|
||||||
|
'config.request_id.allow_override',
|
||||||
|
'config.request_id.header_name',
|
||||||
|
],
|
||||||
|
RequestId\RequestIdMiddleware::class => [
|
||||||
|
RequestId\RequestIdProviderFactory::class,
|
||||||
|
'config.request_id.header_name',
|
||||||
|
],
|
||||||
|
RequestId\MonologProcessor::class => [RequestId\RequestIdMiddleware::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Mezzio\Router\FastRouteRouter;
|
use Mezzio\Router\FastRouteRouter;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'router' => [
|
'router' => [
|
||||||
|
// 'base_path' => '',
|
||||||
'fastroute' => [
|
'fastroute' => [
|
||||||
FastRouteRouter::CONFIG_CACHE_ENABLED => false,
|
FastRouteRouter::CONFIG_CACHE_ENABLED => false,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Mezzio\Swoole\HotCodeReload\FileWatcher\InotifyFileWatcher;
|
|
||||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||||
|
use Mezzio\Swoole\HotCodeReload\FileWatcher\InotifyFileWatcher;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'url_shortener' => [
|
'url_shortener' => [
|
||||||
@@ -11,6 +13,7 @@ return [
|
|||||||
],
|
],
|
||||||
'validate_url' => false,
|
'validate_url' => false,
|
||||||
'visits_webhooks' => [],
|
'visits_webhooks' => [],
|
||||||
|
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ return (new ConfigAggregator\ConfigAggregator([
|
|||||||
Mezzio\Swoole\ConfigProvider::class,
|
Mezzio\Swoole\ConfigProvider::class,
|
||||||
ProblemDetails\ConfigProvider::class,
|
ProblemDetails\ConfigProvider::class,
|
||||||
Common\ConfigProvider::class,
|
Common\ConfigProvider::class,
|
||||||
|
Config\ConfigProvider::class,
|
||||||
IpGeolocation\ConfigProvider::class,
|
IpGeolocation\ConfigProvider::class,
|
||||||
|
EventDispatcher\ConfigProvider::class,
|
||||||
Core\ConfigProvider::class,
|
Core\ConfigProvider::class,
|
||||||
CLI\ConfigProvider::class,
|
CLI\ConfigProvider::class,
|
||||||
Rest\ConfigProvider::class,
|
Rest\ConfigProvider::class,
|
||||||
EventDispatcher\ConfigProvider::class,
|
|
||||||
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
|
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
|
||||||
env('APP_ENV') === 'test'
|
env('APP_ENV') === 'test'
|
||||||
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
|
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ declare(strict_types=1);
|
|||||||
use Laminas\ServiceManager\ServiceManager;
|
use Laminas\ServiceManager\ServiceManager;
|
||||||
use Symfony\Component\Lock;
|
use Symfony\Component\Lock;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||||
|
|
||||||
chdir(dirname(__DIR__));
|
chdir(dirname(__DIR__));
|
||||||
|
|
||||||
require 'vendor/autoload.php';
|
require 'vendor/autoload.php';
|
||||||
|
|
||||||
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
|
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
|
||||||
if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) {
|
// It needs to be placed here as individual config files will not be loaded once config is cached
|
||||||
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
|
if (! class_exists(LOCAL_LOCK_FACTORY)) {
|
||||||
|
class_alias(Lock\LockFactory::class, LOCAL_LOCK_FACTORY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build container
|
// Build container
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ $buildDbConnection = function (): array {
|
|||||||
'dbname' => 'shlink_test',
|
'dbname' => 'shlink_test',
|
||||||
'charset' => 'utf8',
|
'charset' => 'utf8',
|
||||||
],
|
],
|
||||||
|
'mssql' => [
|
||||||
|
'driver' => 'pdo_sqlsrv',
|
||||||
|
'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms',
|
||||||
|
'user' => 'sa',
|
||||||
|
'password' => $isCi ? '' : 'Passw0rd!',
|
||||||
|
'dbname' => 'shlink_test',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
|
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM php:7.4.1-fpm-alpine3.10
|
FROM php:7.4.2-fpm-alpine3.11
|
||||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||||
|
|
||||||
ENV APCU_VERSION 5.1.18
|
ENV APCU_VERSION 5.1.18
|
||||||
@@ -65,6 +65,18 @@ RUN docker-php-ext-configure xdebug\
|
|||||||
# cleanup
|
# cleanup
|
||||||
RUN rm /tmp/xdebug.tar.gz
|
RUN rm /tmp/xdebug.tar.gz
|
||||||
|
|
||||||
|
# Install 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 && \
|
||||||
|
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||||
|
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||||
|
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||||
|
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||||
|
pecl install pdo_sqlsrv && \
|
||||||
|
docker-php-ext-enable pdo_sqlsrv && \
|
||||||
|
apk del .phpize-deps && \
|
||||||
|
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||||
|
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||||
|
|
||||||
# Install composer
|
# Install composer
|
||||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||||
RUN chmod +x composer.phar
|
RUN chmod +x composer.phar
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
FROM php:7.4.1-alpine3.10
|
FROM php:7.4.2-alpine3.11
|
||||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||||
|
|
||||||
ENV APCU_VERSION 5.1.18
|
ENV APCU_VERSION 5.1.18
|
||||||
ENV APCU_BC_VERSION 1.0.5
|
ENV APCU_BC_VERSION 1.0.5
|
||||||
ENV INOTIFY_VERSION 2.0.0
|
ENV INOTIFY_VERSION 2.0.0
|
||||||
ENV SWOOLE_VERSION 4.4.12
|
ENV SWOOLE_VERSION 4.4.15
|
||||||
|
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
|
||||||
@@ -66,12 +66,17 @@ RUN docker-php-ext-configure inotify\
|
|||||||
# cleanup
|
# cleanup
|
||||||
RUN rm /tmp/inotify.tar.gz
|
RUN rm /tmp/inotify.tar.gz
|
||||||
|
|
||||||
# Install swoole
|
# Install swoole and mssql driver
|
||||||
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
|
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||||
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
|
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||||
pecl install swoole-${SWOOLE_VERSION} && \
|
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||||
docker-php-ext-enable swoole && \
|
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||||
apk del .phpize-deps
|
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||||
|
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||||
|
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||||
|
apk del .phpize-deps && \
|
||||||
|
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||||
|
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||||
|
|
||||||
# Install composer
|
# Install composer
|
||||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||||
|
|||||||
@@ -9,18 +9,35 @@ use Doctrine\DBAL\Schema\Schema;
|
|||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
use function Functional\some;
|
||||||
|
|
||||||
final class Version20200105165647 extends AbstractMigration
|
final class Version20200105165647 extends AbstractMigration
|
||||||
{
|
{
|
||||||
private const COLUMNS = ['lat' => 'latitude', 'lon' => 'longitude'];
|
private const COLUMNS = ['lat' => 'latitude', 'lon' => 'longitude'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws DBALException
|
||||||
|
*/
|
||||||
public function preUp(Schema $schema): void
|
public function preUp(Schema $schema): void
|
||||||
{
|
{
|
||||||
|
$visitLocations = $schema->getTable('visit_locations');
|
||||||
|
$this->skipIf(some(
|
||||||
|
self::COLUMNS,
|
||||||
|
fn (string $v, string $newColName) => $visitLocations->hasColumn($newColName),
|
||||||
|
), 'New columns already exist');
|
||||||
|
|
||||||
foreach (self::COLUMNS as $columnName) {
|
foreach (self::COLUMNS as $columnName) {
|
||||||
$qb = $this->connection->createQueryBuilder();
|
$qb = $this->connection->createQueryBuilder();
|
||||||
$qb->update('visit_locations')
|
$qb->update('visit_locations')
|
||||||
->set($columnName, '"0"')
|
->set($columnName, ':zeroValue')
|
||||||
->where($columnName . '=""')
|
->where($qb->expr()->orX(
|
||||||
->orWhere($columnName . ' IS NULL')
|
$qb->expr()->eq($columnName, ':emptyString'),
|
||||||
|
$qb->expr()->isNull($columnName),
|
||||||
|
))
|
||||||
|
->setParameters([
|
||||||
|
'zeroValue' => '0',
|
||||||
|
'emptyString' => '',
|
||||||
|
])
|
||||||
->execute();
|
->execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,16 +50,24 @@ final class Version20200105165647 extends AbstractMigration
|
|||||||
$visitLocations = $schema->getTable('visit_locations');
|
$visitLocations = $schema->getTable('visit_locations');
|
||||||
|
|
||||||
foreach (self::COLUMNS as $newName => $oldName) {
|
foreach (self::COLUMNS as $newName => $oldName) {
|
||||||
$visitLocations->addColumn($newName, Types::FLOAT);
|
$visitLocations->addColumn($newName, Types::FLOAT, [
|
||||||
|
'default' => '0.0',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws DBALException
|
||||||
|
*/
|
||||||
public function postUp(Schema $schema): void
|
public function postUp(Schema $schema): void
|
||||||
{
|
{
|
||||||
|
$platformName = $this->connection->getDatabasePlatform()->getName();
|
||||||
|
$castType = $platformName === 'postgres' ? 'DOUBLE PRECISION' : 'DECIMAL(9,2)';
|
||||||
|
|
||||||
foreach (self::COLUMNS as $newName => $oldName) {
|
foreach (self::COLUMNS as $newName => $oldName) {
|
||||||
$qb = $this->connection->createQueryBuilder();
|
$qb = $this->connection->createQueryBuilder();
|
||||||
$qb->update('visit_locations')
|
$qb->update('visit_locations')
|
||||||
->set($newName, $oldName)
|
->set($newName, 'CAST(' . $oldName . ' AS ' . $castType . ')')
|
||||||
->execute();
|
->execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use Doctrine\DBAL\Schema\Schema;
|
|||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
use function Functional\none;
|
||||||
|
|
||||||
final class Version20200106215144 extends AbstractMigration
|
final class Version20200106215144 extends AbstractMigration
|
||||||
{
|
{
|
||||||
private const COLUMNS = ['latitude', 'longitude'];
|
private const COLUMNS = ['latitude', 'longitude'];
|
||||||
@@ -19,6 +21,10 @@ final class Version20200106215144 extends AbstractMigration
|
|||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
$visitLocations = $schema->getTable('visit_locations');
|
$visitLocations = $schema->getTable('visit_locations');
|
||||||
|
$this->skipIf(none(
|
||||||
|
self::COLUMNS,
|
||||||
|
fn (string $oldColName) => $visitLocations->hasColumn($oldColName),
|
||||||
|
), 'Old columns do not exist');
|
||||||
|
|
||||||
foreach (self::COLUMNS as $colName) {
|
foreach (self::COLUMNS as $colName) {
|
||||||
$visitLocations->dropColumn($colName);
|
$visitLocations->dropColumn($colName);
|
||||||
|
|||||||
53
data/migrations/Version20200110182849.php
Normal file
53
data/migrations/Version20200110182849.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
use function Functional\each;
|
||||||
|
use function Functional\partial_left;
|
||||||
|
|
||||||
|
final class Version20200110182849 extends AbstractMigration
|
||||||
|
{
|
||||||
|
private const DEFAULT_EMPTY_VALUE = '';
|
||||||
|
private const COLUMN_DEFAULTS_MAP = [
|
||||||
|
'visits' => [
|
||||||
|
'referer',
|
||||||
|
'user_agent',
|
||||||
|
],
|
||||||
|
'visit_locations' => [
|
||||||
|
'timezone',
|
||||||
|
'country_code',
|
||||||
|
'country_name',
|
||||||
|
'region_name',
|
||||||
|
'city_name',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
each(
|
||||||
|
self::COLUMN_DEFAULTS_MAP,
|
||||||
|
fn (array $columns, string $tableName) =>
|
||||||
|
each($columns, partial_left([$this, 'setDefaultValueForColumnInTable'], $tableName)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDefaultValueForColumnInTable(string $tableName, string $columnName): void
|
||||||
|
{
|
||||||
|
$qb = $this->connection->createQueryBuilder();
|
||||||
|
$qb->update($tableName)
|
||||||
|
->set($columnName, ':emptyValue')
|
||||||
|
->setParameter('emptyValue', self::DEFAULT_EMPTY_VALUE)
|
||||||
|
->where($qb->expr()->isNull($columnName))
|
||||||
|
->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// No need (and no way) to undo this migration
|
||||||
|
}
|
||||||
|
}
|
||||||
45
data/migrations/Version20200323190014.php
Normal file
45
data/migrations/Version20200323190014.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20200323190014 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$visitLocations = $schema->getTable('visit_locations');
|
||||||
|
$this->skipIf($visitLocations->hasColumn('is_empty'));
|
||||||
|
|
||||||
|
$visitLocations->addColumn('is_empty', Types::BOOLEAN, ['default' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
$qb = $this->connection->createQueryBuilder();
|
||||||
|
$qb->update('visit_locations')
|
||||||
|
->set('is_empty', ':isEmpty')
|
||||||
|
->where($qb->expr()->eq('country_code', ':emptyString'))
|
||||||
|
->andWhere($qb->expr()->eq('country_name', ':emptyString'))
|
||||||
|
->andWhere($qb->expr()->eq('region_name', ':emptyString'))
|
||||||
|
->andWhere($qb->expr()->eq('city_name', ':emptyString'))
|
||||||
|
->andWhere($qb->expr()->eq('timezone', ':emptyString'))
|
||||||
|
->andWhere($qb->expr()->eq('lat', 0))
|
||||||
|
->andWhere($qb->expr()->eq('lon', 0))
|
||||||
|
->setParameter('isEmpty', true)
|
||||||
|
->setParameter('emptyString', '')
|
||||||
|
->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$visitLocations = $schema->getTable('visit_locations');
|
||||||
|
$this->skipIf(!$visitLocations->hasColumn('is_empty'));
|
||||||
|
|
||||||
|
$visitLocations->dropColumn('is_empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,10 @@ services:
|
|||||||
- shlink_db
|
- shlink_db
|
||||||
- shlink_db_postgres
|
- shlink_db_postgres
|
||||||
- shlink_db_maria
|
- shlink_db_maria
|
||||||
|
- shlink_db_ms
|
||||||
- shlink_redis
|
- shlink_redis
|
||||||
|
environment:
|
||||||
|
LC_ALL: C
|
||||||
|
|
||||||
shlink_swoole:
|
shlink_swoole:
|
||||||
container_name: shlink_swoole
|
container_name: shlink_swoole
|
||||||
@@ -42,7 +45,10 @@ services:
|
|||||||
- shlink_db
|
- shlink_db
|
||||||
- shlink_db_postgres
|
- shlink_db_postgres
|
||||||
- shlink_db_maria
|
- shlink_db_maria
|
||||||
|
- shlink_db_ms
|
||||||
- shlink_redis
|
- shlink_redis
|
||||||
|
environment:
|
||||||
|
LC_ALL: C
|
||||||
|
|
||||||
shlink_db:
|
shlink_db:
|
||||||
container_name: shlink_db
|
container_name: shlink_db
|
||||||
@@ -82,6 +88,15 @@ services:
|
|||||||
MYSQL_DATABASE: shlink
|
MYSQL_DATABASE: shlink
|
||||||
MYSQL_INITDB_SKIP_TZINFO: 1
|
MYSQL_INITDB_SKIP_TZINFO: 1
|
||||||
|
|
||||||
|
shlink_db_ms:
|
||||||
|
container_name: shlink_db_ms
|
||||||
|
image: mcr.microsoft.com/mssql/server:2019-latest
|
||||||
|
ports:
|
||||||
|
- "1433:1433"
|
||||||
|
environment:
|
||||||
|
ACCEPT_EULA: Y
|
||||||
|
SA_PASSWORD: "Passw0rd!"
|
||||||
|
|
||||||
shlink_redis:
|
shlink_redis:
|
||||||
container_name: shlink_redis
|
container_name: shlink_redis
|
||||||
image: redis:5.0-alpine
|
image: redis:5.0-alpine
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Shlink Docker image
|
# Shlink Docker image
|
||||||
|
|
||||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
|
||||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||||
|
|
||||||
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
|
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
|
||||||
@@ -19,7 +18,7 @@ It also expects these two env vars to be provided, in order to properly generate
|
|||||||
So based on this, to run shlink on a local docker service, you should run a command like this:
|
So based on this, to run shlink on a local docker service, you should run a command like this:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink:stable
|
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 shlinkio/shlink:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interact with shlink's CLI on a running container.
|
### Interact with shlink's CLI on a running container.
|
||||||
@@ -38,10 +37,10 @@ Or you can list all tags with:
|
|||||||
docker exec -it shlink_container shlink tag:list
|
docker exec -it shlink_container shlink tag:list
|
||||||
```
|
```
|
||||||
|
|
||||||
Or process remaining visits with:
|
Or locate remaining visits with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it shlink_container shlink visit:process
|
docker exec -it shlink_container shlink visit:locate
|
||||||
```
|
```
|
||||||
|
|
||||||
All shlink commands will work the same way.
|
All shlink commands will work the same way.
|
||||||
@@ -56,9 +55,9 @@ docker exec -it shlink_container shlink
|
|||||||
|
|
||||||
The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
|
The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
|
||||||
|
|
||||||
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB or PostgreSQL database.
|
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB, PostgreSQL or Microsoft SQL Server database.
|
||||||
|
|
||||||
* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria** or **postgres** to prevent the sqlite database to be used.
|
* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria**, **postgres** or **mssql** to prevent the sqlite database to be used.
|
||||||
* `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**.
|
* `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**.
|
||||||
* `DB_USER`: **[Mandatory]**. The username credential for the database server.
|
* `DB_USER`: **[Mandatory]**. The username credential for the database server.
|
||||||
* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server.
|
* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server.
|
||||||
@@ -67,8 +66,9 @@ It is possible to use a set of env vars to make this shlink instance interact wi
|
|||||||
* Default value is based on the value provided for `DB_DRIVER`:
|
* Default value is based on the value provided for `DB_DRIVER`:
|
||||||
* **mysql** or **maria** -> `3306`
|
* **mysql** or **maria** -> `3306`
|
||||||
* **postgres** -> `5432`
|
* **postgres** -> `5432`
|
||||||
|
* **mssql** -> `1433`
|
||||||
|
|
||||||
> PostgreSQL is supported since v1.16.1 of this image. Do not try to use it with previous versions.
|
> PostgreSQL is supported since v1.16.1 and Microsoft SQL server since v2.1.0. Do not try to use them with previous versions.
|
||||||
|
|
||||||
Taking this into account, you could run shlink on a local docker service like this:
|
Taking this into account, you could run shlink on a local docker service like this:
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ This is the complete list of supported env vars:
|
|||||||
|
|
||||||
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
|
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
|
||||||
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
|
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
|
||||||
* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria** or **postgres**.
|
* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria**, **postgres** or **mssql**.
|
||||||
* `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**.
|
* `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**.
|
||||||
* `DB_USER`: The username credential to be used when using an external database driver.
|
* `DB_USER`: The username credential to be used when using an external database driver.
|
||||||
* `DB_PASSWORD`: The password credential to be used when using an external database driver.
|
* `DB_PASSWORD`: The password credential to be used when using an external database driver.
|
||||||
@@ -101,6 +101,7 @@ This is the complete list of supported env vars:
|
|||||||
* Default value is based on the value provided for `DB_DRIVER`:
|
* Default value is based on the value provided for `DB_DRIVER`:
|
||||||
* **mysql** or **maria** -> `3306`
|
* **mysql** or **maria** -> `3306`
|
||||||
* **postgres** -> `5432`
|
* **postgres** -> `5432`
|
||||||
|
* **mssql** -> `1433`
|
||||||
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
|
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
|
||||||
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
|
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
|
||||||
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`.
|
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`.
|
||||||
@@ -111,6 +112,7 @@ This is the complete list of supported env vars:
|
|||||||
* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16.
|
* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16.
|
||||||
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
|
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
|
||||||
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
|
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
|
||||||
|
* `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4.
|
||||||
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
|
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
|
||||||
|
|
||||||
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
|
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
|
||||||
@@ -119,6 +121,8 @@ This is the complete list of supported env vars:
|
|||||||
|
|
||||||
In the future, these redis servers could be used for other caching operations performed by shlink.
|
In the future, these redis servers could be used for other caching operations performed by shlink.
|
||||||
|
|
||||||
|
* `GEOLITE_LICENSE_KEY`: The license key used to download new GeoLite2 database files. This is not mandatory, as a default license key is provided, but it is **strongly recommended** that you provide your own. Go to [https://shlink.io/documentation/geolite-license-key](https://shlink.io/documentation/geolite-license-key) to know how to generate it.
|
||||||
|
|
||||||
An example using all env vars could look like this:
|
An example using all env vars could look like this:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -144,6 +148,8 @@ docker run \
|
|||||||
-e WEB_WORKER_NUM=64 \
|
-e WEB_WORKER_NUM=64 \
|
||||||
-e TASK_WORKER_NUM=32 \
|
-e TASK_WORKER_NUM=32 \
|
||||||
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
|
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
|
||||||
|
-e DEFAULT_SHORT_CODES_LENGTH=6 \
|
||||||
|
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
|
||||||
shlinkio/shlink:stable
|
shlinkio/shlink:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -168,6 +174,7 @@ The whole configuration should have this format, but it can be split into multip
|
|||||||
"base_path": "/my-campaign",
|
"base_path": "/my-campaign",
|
||||||
"web_worker_num": 64,
|
"web_worker_num": 64,
|
||||||
"task_worker_num": 32,
|
"task_worker_num": 32,
|
||||||
|
"default_short_codes_length": 6,
|
||||||
"redis_servers": [
|
"redis_servers": [
|
||||||
"tcp://172.20.0.1:6379",
|
"tcp://172.20.0.1:6379",
|
||||||
"tcp://172.20.0.2:6379"
|
"tcp://172.20.0.2:6379"
|
||||||
@@ -183,7 +190,8 @@ The whole configuration should have this format, but it can be split into multip
|
|||||||
"password": "123abc",
|
"password": "123abc",
|
||||||
"host": "something.rds.amazonaws.com",
|
"host": "something.rds.amazonaws.com",
|
||||||
"port": "3306"
|
"port": "3306"
|
||||||
}
|
},
|
||||||
|
"geolite_license_key": "kjh23ljkbndskj345"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
15
docker/build
Executable file
15
docker/build
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||||
|
|
||||||
|
# If there is a tag, regardless the branch, build that docker tag and also "stable"
|
||||||
|
if [[ ! -z $TRAVIS_TAG ]]; then
|
||||||
|
docker build --build-arg SHLINK_VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink:${TRAVIS_TAG#?} -t shlinkio/shlink:stable .
|
||||||
|
docker push shlinkio/shlink:${TRAVIS_TAG#?}
|
||||||
|
docker push shlinkio/shlink:stable
|
||||||
|
# If build branch is develop, build latest (on master, when there's no tag, do not build anything)
|
||||||
|
elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then
|
||||||
|
docker build -t shlinkio/shlink:latest .
|
||||||
|
docker push shlinkio/shlink:latest
|
||||||
|
fi
|
||||||
@@ -11,16 +11,21 @@ use function explode;
|
|||||||
use function Functional\contains;
|
use function Functional\contains;
|
||||||
use function Shlinkio\Shlink\Common\env;
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||||
|
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||||
|
|
||||||
$helper = new class {
|
$helper = new class {
|
||||||
private const DB_DRIVERS_MAP = [
|
private const DB_DRIVERS_MAP = [
|
||||||
'mysql' => 'pdo_mysql',
|
'mysql' => 'pdo_mysql',
|
||||||
'maria' => 'pdo_mysql',
|
'maria' => 'pdo_mysql',
|
||||||
'postgres' => 'pdo_pgsql',
|
'postgres' => 'pdo_pgsql',
|
||||||
|
'mssql' => 'pdo_sqlsrv',
|
||||||
];
|
];
|
||||||
private const DB_PORTS_MAP = [
|
private const DB_PORTS_MAP = [
|
||||||
'mysql' => '3306',
|
'mysql' => '3306',
|
||||||
'maria' => '3306',
|
'maria' => '3306',
|
||||||
'postgres' => '5432',
|
'postgres' => '5432',
|
||||||
|
'mssql' => '1433',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getDbConfig(): array
|
public function getDbConfig(): array
|
||||||
@@ -68,6 +73,12 @@ $helper = new class {
|
|||||||
$redisServers = env('REDIS_SERVERS');
|
$redisServers = env('REDIS_SERVERS');
|
||||||
return $redisServers === null ? null : ['servers' => $redisServers];
|
return $redisServers === null ? null : ['servers' => $redisServers];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDefaultShortCodesLength(): int
|
||||||
|
{
|
||||||
|
$value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
|
||||||
|
return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -94,6 +105,7 @@ return [
|
|||||||
],
|
],
|
||||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
||||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||||
|
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||||
],
|
],
|
||||||
|
|
||||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||||
@@ -135,4 +147,8 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'geolite2' => [
|
||||||
|
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ php bin/cli db:migrate -n -q
|
|||||||
echo "Generating proxies..."
|
echo "Generating proxies..."
|
||||||
php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
|
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
|
||||||
|
|
||||||
# When restarting the container, swoole might think it is already in execution
|
# 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
|
# This forces the app to be started every second until the exit code is 0
|
||||||
until php vendor/mezzio/mezzio-swoole/bin/mezzio-swoole start; do sleep 1 ; done
|
until php vendor/mezzio/mezzio-swoole/bin/mezzio-swoole start; do sleep 1 ; done
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"$ref": "./ShortUrlMeta.json"
|
"$ref": "./ShortUrlMeta.json"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
docs/swagger/parameters/domain.json
Normal file
9
docs/swagger/parameters/domain.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "domain",
|
||||||
|
"description": "The domain in which the short code should be searched for.",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,7 +123,8 @@
|
|||||||
"validSince": "2017-01-21T00:00:00+02:00",
|
"validSince": "2017-01-21T00:00:00+02:00",
|
||||||
"validUntil": null,
|
"validUntil": null,
|
||||||
"maxVisits": 100
|
"maxVisits": 100
|
||||||
}
|
},
|
||||||
|
"domain": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"shortCode": "12Kb3",
|
"shortCode": "12Kb3",
|
||||||
@@ -138,11 +139,12 @@
|
|||||||
"validSince": null,
|
"validSince": null,
|
||||||
"validUntil": null,
|
"validUntil": null,
|
||||||
"maxVisits": null
|
"maxVisits": null
|
||||||
}
|
},
|
||||||
|
"domain": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"shortCode": "123bA",
|
"shortCode": "123bA",
|
||||||
"shortUrl": "https://doma.in/123bA",
|
"shortUrl": "https://example.com/123bA",
|
||||||
"longUrl": "https://www.google.com",
|
"longUrl": "https://www.google.com",
|
||||||
"dateCreated": "2015-10-01T20:34:16+02:00",
|
"dateCreated": "2015-10-01T20:34:16+02:00",
|
||||||
"visitsCount": 25,
|
"visitsCount": 25,
|
||||||
@@ -151,7 +153,8 @@
|
|||||||
"validSince": "2017-01-21T00:00:00+02:00",
|
"validSince": "2017-01-21T00:00:00+02:00",
|
||||||
"validUntil": null,
|
"validUntil": null,
|
||||||
"maxVisits": null
|
"maxVisits": null
|
||||||
}
|
},
|
||||||
|
"domain": "example.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pagination": {
|
"pagination": {
|
||||||
@@ -240,6 +243,10 @@
|
|||||||
"domain": {
|
"domain": {
|
||||||
"description": "The domain to which the short URL will be attached",
|
"description": "The domain to which the short URL will be attached",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"shortCodeLength": {
|
||||||
|
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
|
||||||
|
"type": "number"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,7 +278,8 @@
|
|||||||
"validSince": "2017-01-21T00:00:00+02:00",
|
"validSince": "2017-01-21T00:00:00+02:00",
|
||||||
"validUntil": null,
|
"validUntil": null,
|
||||||
"maxVisits": 500
|
"maxVisits": 500
|
||||||
}
|
},
|
||||||
|
"domain": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,7 +72,8 @@
|
|||||||
"validSince": "2017-01-21T00:00:00+02:00",
|
"validSince": "2017-01-21T00:00:00+02:00",
|
||||||
"validUntil": null,
|
"validUntil": null,
|
||||||
"maxVisits": 100
|
"maxVisits": 100
|
||||||
}
|
},
|
||||||
|
"domain": null
|
||||||
},
|
},
|
||||||
"text/plain": "https://doma.in/abc123"
|
"text/plain": "https://doma.in/abc123"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "domain",
|
"$ref": "../parameters/domain.json"
|
||||||
"in": "query",
|
|
||||||
"description": "The domain in which the short code should be searched for. Will fall back to default domain if not found.",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": [
|
"security": [
|
||||||
@@ -58,7 +52,8 @@
|
|||||||
"validSince": "2017-01-21T00:00:00+02:00",
|
"validSince": "2017-01-21T00:00:00+02:00",
|
||||||
"validUntil": null,
|
"validUntil": null,
|
||||||
"maxVisits": 100
|
"maxVisits": 100
|
||||||
}
|
},
|
||||||
|
"domain": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -104,6 +99,9 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/domain.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -114,6 +112,10 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"longUrl": {
|
||||||
|
"description": "The long URL this short URL will redirect to",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"validSince": {
|
"validSince": {
|
||||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -159,6 +161,7 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
"longUrl",
|
||||||
"validSince",
|
"validSince",
|
||||||
"validUntil",
|
"validUntil",
|
||||||
"maxVisits"
|
"maxVisits"
|
||||||
@@ -214,6 +217,9 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/domain.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": [
|
"security": [
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/domain.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/domain.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "startDate",
|
"name": "startDate",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"externalDocs": {
|
"externalDocs": {
|
||||||
"url": "https://shlink.io/api-docs",
|
"url": "https://shlink.io/documentation/api-docs",
|
||||||
"description": "Find more info on how to start using this API here"
|
"description": "Find more info on how to start using this API here"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
10
hooks/build
10
hooks/build
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -ex
|
|
||||||
|
|
||||||
if [[ ${SOURCE_BRANCH} == 'develop' ]]; then
|
|
||||||
SHLINK_RELEASE='latest'
|
|
||||||
else
|
|
||||||
SHLINK_RELEASE=${SOURCE_BRANCH#?}
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker build --build-arg SHLINK_VERSION=${SHLINK_RELEASE} -t ${IMAGE_NAME} .
|
|
||||||
@@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
|
|||||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||||
use Shlinkio\Shlink\Core\Service;
|
use Shlinkio\Shlink\Core\Service;
|
||||||
|
use Shlinkio\Shlink\Core\Visit;
|
||||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||||
@@ -19,6 +20,8 @@ use Symfony\Component\Console as SymfonyCli;
|
|||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'dependencies' => [
|
'dependencies' => [
|
||||||
@@ -52,16 +55,20 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
ConfigAbstractFactory::class => [
|
ConfigAbstractFactory::class => [
|
||||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'],
|
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
|
||||||
|
|
||||||
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
|
Command\ShortUrl\GenerateShortUrlCommand::class => [
|
||||||
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class],
|
Service\UrlShortener::class,
|
||||||
|
'config.url_shortener.domain',
|
||||||
|
'config.url_shortener.default_short_codes_length',
|
||||||
|
],
|
||||||
|
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
|
||||||
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
|
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
|
||||||
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
|
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
|
||||||
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||||
|
|
||||||
Command\Visit\LocateVisitsCommand::class => [
|
Command\Visit\LocateVisitsCommand::class => [
|
||||||
Service\VisitService::class,
|
Visit\VisitLocator::class,
|
||||||
IpLocationResolverInterface::class,
|
IpLocationResolverInterface::class,
|
||||||
LockFactory::class,
|
LockFactory::class,
|
||||||
GeolocationDbUpdater::class,
|
GeolocationDbUpdater::class,
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
use function array_unshift;
|
|
||||||
|
|
||||||
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||||
{
|
{
|
||||||
private ProcessHelper $processHelper;
|
private ProcessHelper $processHelper;
|
||||||
@@ -27,7 +25,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
|||||||
|
|
||||||
protected function runPhpCommand(OutputInterface $output, array $command): void
|
protected function runPhpCommand(OutputInterface $output, array $command): void
|
||||||
{
|
{
|
||||||
array_unshift($command, $this->phpBinary);
|
$command = [$this->phpBinary, ...$command, '--no-interaction'];
|
||||||
$this->processHelper->mustRun($output, $command);
|
$this->processHelper->mustRun($output, $command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
|||||||
|
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\Core\Exception;
|
use Shlinkio\Shlink\Core\Exception;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
@@ -40,33 +41,39 @@ class DeleteShortUrlCommand extends Command
|
|||||||
InputOption::VALUE_NONE,
|
InputOption::VALUE_NONE,
|
||||||
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
|
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
|
||||||
. 'accidentally deleted',
|
. 'accidentally deleted',
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
'domain',
|
||||||
|
'd',
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'The domain if the short code does not belong to the default one',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$shortCode = $input->getArgument('shortCode');
|
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||||
$ignoreThreshold = $input->getOption('ignore-threshold');
|
$ignoreThreshold = $input->getOption('ignore-threshold');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->runDelete($io, $shortCode, $ignoreThreshold);
|
$this->runDelete($io, $identifier, $ignoreThreshold);
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
} catch (Exception\ShortUrlNotFoundException $e) {
|
} catch (Exception\ShortUrlNotFoundException $e) {
|
||||||
$io->error($e->getMessage());
|
$io->error($e->getMessage());
|
||||||
return ExitCodes::EXIT_FAILURE;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
} catch (Exception\DeleteShortUrlException $e) {
|
} catch (Exception\DeleteShortUrlException $e) {
|
||||||
return $this->retry($io, $shortCode, $e->getMessage());
|
return $this->retry($io, $identifier, $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function retry(SymfonyStyle $io, string $shortCode, string $warningMsg): int
|
private function retry(SymfonyStyle $io, ShortUrlIdentifier $identifier, string $warningMsg): int
|
||||||
{
|
{
|
||||||
$io->writeln(sprintf('<bg=yellow>%s</>', $warningMsg));
|
$io->writeln(sprintf('<bg=yellow>%s</>', $warningMsg));
|
||||||
$forceDelete = $io->confirm('Do you want to delete it anyway?', false);
|
$forceDelete = $io->confirm('Do you want to delete it anyway?', false);
|
||||||
|
|
||||||
if ($forceDelete) {
|
if ($forceDelete) {
|
||||||
$this->runDelete($io, $shortCode, true);
|
$this->runDelete($io, $identifier, true);
|
||||||
} else {
|
} else {
|
||||||
$io->warning('Short URL was not deleted.');
|
$io->warning('Short URL was not deleted.');
|
||||||
}
|
}
|
||||||
@@ -74,9 +81,9 @@ class DeleteShortUrlCommand extends Command
|
|||||||
return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING;
|
return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function runDelete(SymfonyStyle $io, string $shortCode, bool $ignoreThreshold): void
|
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void
|
||||||
{
|
{
|
||||||
$this->deleteShortUrlService->deleteByShortCode($shortCode, $ignoreThreshold);
|
$this->deleteShortUrlService->deleteByShortCode($identifier, $ignoreThreshold);
|
||||||
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode));
|
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
|||||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
@@ -29,12 +30,14 @@ class GenerateShortUrlCommand extends Command
|
|||||||
|
|
||||||
private UrlShortenerInterface $urlShortener;
|
private UrlShortenerInterface $urlShortener;
|
||||||
private array $domainConfig;
|
private array $domainConfig;
|
||||||
|
private int $defaultShortCodeLength;
|
||||||
|
|
||||||
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
|
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->urlShortener = $urlShortener;
|
$this->urlShortener = $urlShortener;
|
||||||
$this->domainConfig = $domainConfig;
|
$this->domainConfig = $domainConfig;
|
||||||
|
$this->defaultShortCodeLength = $defaultShortCodeLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
@@ -86,6 +89,12 @@ class GenerateShortUrlCommand extends Command
|
|||||||
'd',
|
'd',
|
||||||
InputOption::VALUE_REQUIRED,
|
InputOption::VALUE_REQUIRED,
|
||||||
'The domain to which this short URL will be attached.',
|
'The domain to which this short URL will be attached.',
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
'shortCodeLength',
|
||||||
|
'l',
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'The length for generated short code (it will be ignored if --customSlug was provided).',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,19 +125,21 @@ class GenerateShortUrlCommand extends Command
|
|||||||
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
|
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
|
||||||
$customSlug = $input->getOption('customSlug');
|
$customSlug = $input->getOption('customSlug');
|
||||||
$maxVisits = $input->getOption('maxVisits');
|
$maxVisits = $input->getOption('maxVisits');
|
||||||
|
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||||
new Uri($longUrl),
|
new Uri($longUrl),
|
||||||
$tags,
|
$tags,
|
||||||
ShortUrlMeta::createFromParams(
|
ShortUrlMeta::fromRawData([
|
||||||
$input->getOption('validSince'),
|
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
|
||||||
$input->getOption('validUntil'),
|
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
|
||||||
$customSlug,
|
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||||
$maxVisits !== null ? (int) $maxVisits : null,
|
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||||
$input->getOption('findIfExists'),
|
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||||
$input->getOption('domain'),
|
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||||
),
|
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
$io->writeln([
|
$io->writeln([
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
|||||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
@@ -36,7 +39,8 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
|||||||
$this
|
$this
|
||||||
->setName(self::NAME)
|
->setName(self::NAME)
|
||||||
->setDescription('Returns the detailed visits information for provided short code')
|
->setDescription('Returns the detailed visits information for provided short code')
|
||||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get');
|
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get')
|
||||||
|
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getStartDateDesc(): string
|
protected function getStartDateDesc(): string
|
||||||
@@ -65,15 +69,15 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
|||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
{
|
{
|
||||||
$shortCode = $input->getArgument('shortCode');
|
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||||
$startDate = $this->getDateOption($input, $output, 'startDate');
|
$startDate = $this->getDateOption($input, $output, 'startDate');
|
||||||
$endDate = $this->getDateOption($input, $output, 'endDate');
|
$endDate = $this->getDateOption($input, $output, 'endDate');
|
||||||
|
|
||||||
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange($startDate, $endDate)));
|
$paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate)));
|
||||||
|
|
||||||
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
|
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
|
||||||
$rowData = $visit->jsonSerialize();
|
$rowData = $visit->jsonSerialize();
|
||||||
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
|
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
|
||||||
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
||||||
});
|
});
|
||||||
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
|
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||||
|
|
||||||
use Cake\Chronos\Chronos;
|
|
||||||
use Laminas\Paginator\Paginator;
|
use Laminas\Paginator\Paginator;
|
||||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||||
|
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
@@ -108,7 +109,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
|||||||
$orderBy = $this->processOrderBy($input);
|
$orderBy = $this->processOrderBy($input);
|
||||||
|
|
||||||
do {
|
do {
|
||||||
$result = $this->renderPage($output, $page, $searchTerm, $tags, $showTags, $startDate, $endDate, $orderBy);
|
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData([
|
||||||
|
ShortUrlsParamsInputFilter::PAGE => $page,
|
||||||
|
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,
|
||||||
|
]));
|
||||||
$page++;
|
$page++;
|
||||||
|
|
||||||
$continue = $this->isLastPage($result)
|
$continue = $this->isLastPage($result)
|
||||||
@@ -122,26 +130,9 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
|||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params): Paginator
|
||||||
* @param string|array|null $orderBy
|
{
|
||||||
*/
|
$result = $this->shortUrlService->listShortUrls($params);
|
||||||
private function renderPage(
|
|
||||||
OutputInterface $output,
|
|
||||||
int $page,
|
|
||||||
?string $searchTerm,
|
|
||||||
array $tags,
|
|
||||||
bool $showTags,
|
|
||||||
?Chronos $startDate,
|
|
||||||
?Chronos $endDate,
|
|
||||||
$orderBy
|
|
||||||
): Paginator {
|
|
||||||
$result = $this->shortUrlService->listShortUrls(
|
|
||||||
$page,
|
|
||||||
$searchTerm,
|
|
||||||
$tags,
|
|
||||||
$orderBy,
|
|
||||||
new DateRange($startDate, $endDate),
|
|
||||||
);
|
|
||||||
|
|
||||||
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
|
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
|
||||||
if ($showTags) {
|
if ($showTags) {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
|||||||
|
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
@@ -20,12 +21,12 @@ class ResolveUrlCommand extends Command
|
|||||||
{
|
{
|
||||||
public const NAME = 'short-url:parse';
|
public const NAME = 'short-url:parse';
|
||||||
|
|
||||||
private UrlShortenerInterface $urlShortener;
|
private ShortUrlResolverInterface $urlResolver;
|
||||||
|
|
||||||
public function __construct(UrlShortenerInterface $urlShortener)
|
public function __construct(ShortUrlResolverInterface $urlResolver)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->urlShortener = $urlShortener;
|
$this->urlResolver = $urlResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
@@ -54,11 +55,9 @@ class ResolveUrlCommand extends Command
|
|||||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$shortCode = $input->getArgument('shortCode');
|
|
||||||
$domain = $input->getOption('domain');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input));
|
||||||
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
|
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
} catch (ShortUrlNotFoundException $e) {
|
} catch (ShortUrlNotFoundException $e) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||||
|
|
||||||
use Exception;
|
|
||||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
||||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||||
@@ -14,12 +13,15 @@ use Shlinkio\Shlink\Common\Util\IpAddress;
|
|||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||||
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
|
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\VisitLocatorInterface;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||||
|
use Symfony\Component\Console\Exception\RuntimeException;
|
||||||
use Symfony\Component\Console\Helper\ProgressBar;
|
use Symfony\Component\Console\Helper\ProgressBar;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
@@ -27,11 +29,11 @@ use Throwable;
|
|||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class LocateVisitsCommand extends AbstractLockedCommand
|
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
|
||||||
{
|
{
|
||||||
public const NAME = 'visit:locate';
|
public const NAME = 'visit:locate';
|
||||||
|
|
||||||
private VisitServiceInterface $visitService;
|
private VisitLocatorInterface $visitLocator;
|
||||||
private IpLocationResolverInterface $ipLocationResolver;
|
private IpLocationResolverInterface $ipLocationResolver;
|
||||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
private GeolocationDbUpdaterInterface $dbUpdater;
|
||||||
|
|
||||||
@@ -39,13 +41,13 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
|||||||
private ?ProgressBar $progressBar = null;
|
private ?ProgressBar $progressBar = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
VisitServiceInterface $visitService,
|
VisitLocatorInterface $visitLocator,
|
||||||
IpLocationResolverInterface $ipLocationResolver,
|
IpLocationResolverInterface $ipLocationResolver,
|
||||||
LockFactory $locker,
|
LockFactory $locker,
|
||||||
GeolocationDbUpdaterInterface $dbUpdater
|
GeolocationDbUpdaterInterface $dbUpdater
|
||||||
) {
|
) {
|
||||||
parent::__construct($locker);
|
parent::__construct($locker);
|
||||||
$this->visitService = $visitService;
|
$this->visitLocator = $visitLocator;
|
||||||
$this->ipLocationResolver = $ipLocationResolver;
|
$this->ipLocationResolver = $ipLocationResolver;
|
||||||
$this->dbUpdater = $dbUpdater;
|
$this->dbUpdater = $dbUpdater;
|
||||||
}
|
}
|
||||||
@@ -54,32 +56,79 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
|||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->setName(self::NAME)
|
->setName(self::NAME)
|
||||||
->setDescription('Resolves visits origin locations.');
|
->setDescription('Resolves visits origin locations.')
|
||||||
|
->addOption(
|
||||||
|
'retry',
|
||||||
|
'r',
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Will retry the location of visits that were located with a not-found location, in case it was due to '
|
||||||
|
. 'a temporal issue.',
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
'all',
|
||||||
|
'a',
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'When provided together with --retry, will locate all existing visits, regardless the fact that they '
|
||||||
|
. 'have already been located.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function initialize(InputInterface $input, OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$this->io = new SymfonyStyle($input, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$retry = $input->getOption('retry');
|
||||||
|
$all = $input->getOption('all');
|
||||||
|
|
||||||
|
if ($all && !$retry) {
|
||||||
|
$this->io->writeln(
|
||||||
|
'<comment>The <fg=yellow;options=bold>--all</> flag has no effect on its own. You have to provide it '
|
||||||
|
. 'together with <fg=yellow;options=bold>--retry</>.</comment>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
|
||||||
|
throw new RuntimeException('Execution aborted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warnAndVerifyContinue(): bool
|
||||||
|
{
|
||||||
|
$this->io->warning([
|
||||||
|
'You are about to process the location of all existing visits your short URLs received.',
|
||||||
|
'Since shlink saves visitors IP addresses anonymized, you could end up losing precision on some of '
|
||||||
|
. 'your visits.',
|
||||||
|
'Also, if you have a large amount of visits, this can be a very time consuming process. '
|
||||||
|
. 'Continue at your own risk.',
|
||||||
|
]);
|
||||||
|
return $this->io->confirm('Do you want to proceed?', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
|
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$this->io = new SymfonyStyle($input, $output);
|
$retry = $input->getOption('retry');
|
||||||
|
$all = $retry && $input->getOption('all');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->checkDbUpdate();
|
$this->checkDbUpdate();
|
||||||
|
|
||||||
$this->visitService->locateUnlocatedVisits(
|
if ($all) {
|
||||||
[$this, 'getGeolocationDataForVisit'],
|
$this->visitLocator->locateAllVisits($this);
|
||||||
static function (VisitLocation $location) use ($output): void {
|
} else {
|
||||||
if (!$location->isEmpty()) {
|
$this->visitLocator->locateUnlocatedVisits($this);
|
||||||
$output->writeln(
|
if ($retry) {
|
||||||
sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName()),
|
$this->visitLocator->locateVisitsWithEmptyLocation($this);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->io->success('Finished processing all IPs');
|
$this->io->success('Finished locating visits');
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->io->error($e->getMessage());
|
$this->io->error($e->getMessage());
|
||||||
if ($e instanceof Exception && $this->io->isVerbose()) {
|
if ($e instanceof Throwable && $this->io->isVerbose()) {
|
||||||
$this->getApplication()->renderThrowable($e, $this->io);
|
$this->getApplication()->renderThrowable($e, $this->io);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +136,10 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getGeolocationDataForVisit(Visit $visit): Location
|
/**
|
||||||
|
* @throws IpCannotBeLocatedException
|
||||||
|
*/
|
||||||
|
public function geolocateVisit(Visit $visit): Location
|
||||||
{
|
{
|
||||||
if (! $visit->hasRemoteAddr()) {
|
if (! $visit->hasRemoteAddr()) {
|
||||||
$this->io->writeln(
|
$this->io->writeln(
|
||||||
@@ -116,6 +168,14 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
|
||||||
|
{
|
||||||
|
$message = ! $visitLocation->isEmpty()
|
||||||
|
? sprintf(' [<info>Address located in "%s"</info>]', $visitLocation->getCountryName())
|
||||||
|
: ' [<comment>Address not found</comment>]';
|
||||||
|
$this->io->writeln($message);
|
||||||
|
}
|
||||||
|
|
||||||
private function checkDbUpdate(): void
|
private function checkDbUpdate(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\CLI;
|
namespace Shlinkio\Shlink\CLI;
|
||||||
|
|
||||||
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
|
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
|
||||||
|
|
||||||
class ConfigProvider
|
class ConfigProvider
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use Symfony\Component\Console\Tester\CommandTester;
|
|||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Lock\LockInterface;
|
use Symfony\Component\Lock\LockInterface;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
class CreateDatabaseCommandTest extends TestCase
|
class CreateDatabaseCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -114,7 +115,8 @@ class CreateDatabaseCommandTest extends TestCase
|
|||||||
'/usr/local/bin/php',
|
'/usr/local/bin/php',
|
||||||
CreateDatabaseCommand::DOCTRINE_SCRIPT,
|
CreateDatabaseCommand::DOCTRINE_SCRIPT,
|
||||||
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
|
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
|
||||||
], Argument::cetera());
|
'--no-interaction',
|
||||||
|
], Argument::cetera())->willReturn(new Process([]));
|
||||||
|
|
||||||
$this->commandTester->execute([]);
|
$this->commandTester->execute([]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Symfony\Component\Console\Tester\CommandTester;
|
|||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Lock\LockInterface;
|
use Symfony\Component\Lock\LockInterface;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
class MigrateDatabaseCommandTest extends TestCase
|
class MigrateDatabaseCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -53,7 +54,8 @@ class MigrateDatabaseCommandTest extends TestCase
|
|||||||
'/usr/local/bin/php',
|
'/usr/local/bin/php',
|
||||||
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
|
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
|
||||||
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
|
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
|
||||||
], Argument::cetera());
|
'--no-interaction',
|
||||||
|
], Argument::cetera())->willReturn(new Process([]));
|
||||||
|
|
||||||
$this->commandTester->execute([]);
|
$this->commandTester->execute([]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Prophecy\Argument;
|
|||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
|
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
|
||||||
use Shlinkio\Shlink\Core\Exception;
|
use Shlinkio\Shlink\Core\Exception;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
@@ -38,8 +39,10 @@ class DeleteShortUrlCommandTest extends TestCase
|
|||||||
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
|
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->will(function (): void {
|
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->will(
|
||||||
});
|
function (): void {
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
@@ -55,8 +58,9 @@ class DeleteShortUrlCommandTest extends TestCase
|
|||||||
public function invalidShortCodePrintsMessage(): void
|
public function invalidShortCodePrintsMessage(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
|
$identifier = new ShortUrlIdentifier($shortCode);
|
||||||
Exception\ShortUrlNotFoundException::fromNotFoundShortCode($shortCode),
|
$deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow(
|
||||||
|
Exception\ShortUrlNotFoundException::fromNotFound($identifier),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||||
@@ -76,7 +80,8 @@ class DeleteShortUrlCommandTest extends TestCase
|
|||||||
string $expectedMessage
|
string $expectedMessage
|
||||||
): void {
|
): void {
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
|
$identifier = new ShortUrlIdentifier($shortCode);
|
||||||
|
$deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will(
|
||||||
function (array $args) use ($shortCode): void {
|
function (array $args) use ($shortCode): void {
|
||||||
$ignoreThreshold = array_pop($args);
|
$ignoreThreshold = array_pop($args);
|
||||||
|
|
||||||
@@ -109,7 +114,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
|||||||
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
|
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
|
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow(
|
||||||
Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode),
|
Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode),
|
||||||
);
|
);
|
||||||
$this->commandTester->setInputs(['no']);
|
$this->commandTester->setInputs(['no']);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class GenerateShortUrlCommandTest extends TestCase
|
|||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||||
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG);
|
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5);
|
||||||
$app = new Application();
|
$app = new Application();
|
||||||
$app->add($command);
|
$app->add($command);
|
||||||
$this->commandTester = new CommandTester($command);
|
$this->commandTester = new CommandTester($command);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
|||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||||
@@ -42,9 +43,12 @@ class GetVisitsCommandTest extends TestCase
|
|||||||
public function noDateFlagsTriesToListWithoutDateRange(): void
|
public function noDateFlagsTriesToListWithoutDateRange(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$this->visitsTracker->info($shortCode, new VisitsParams(new DateRange(null, null)))->willReturn(
|
$this->visitsTracker->info(
|
||||||
new Paginator(new ArrayAdapter([])),
|
new ShortUrlIdentifier($shortCode),
|
||||||
)->shouldBeCalledOnce();
|
new VisitsParams(new DateRange(null, null)),
|
||||||
|
)
|
||||||
|
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||||
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||||
}
|
}
|
||||||
@@ -56,7 +60,7 @@ class GetVisitsCommandTest extends TestCase
|
|||||||
$startDate = '2016-01-01';
|
$startDate = '2016-01-01';
|
||||||
$endDate = '2016-02-01';
|
$endDate = '2016-02-01';
|
||||||
$this->visitsTracker->info(
|
$this->visitsTracker->info(
|
||||||
$shortCode,
|
new ShortUrlIdentifier($shortCode),
|
||||||
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
|
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
|
||||||
)
|
)
|
||||||
->willReturn(new Paginator(new ArrayAdapter([])))
|
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||||
@@ -74,7 +78,7 @@ class GetVisitsCommandTest extends TestCase
|
|||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$startDate = 'foo';
|
$startDate = 'foo';
|
||||||
$info = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange()))
|
$info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange()))
|
||||||
->willReturn(new Paginator(new ArrayAdapter([])));
|
->willReturn(new Paginator(new ArrayAdapter([])));
|
||||||
|
|
||||||
$this->commandTester->execute([
|
$this->commandTester->execute([
|
||||||
@@ -94,7 +98,7 @@ class GetVisitsCommandTest extends TestCase
|
|||||||
public function outputIsProperlyGenerated(): void
|
public function outputIsProperlyGenerated(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$this->visitsTracker->info($shortCode, Argument::any())->willReturn(
|
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
|
||||||
new Paginator(new ArrayAdapter([
|
new Paginator(new ArrayAdapter([
|
||||||
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
|
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
|
||||||
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
|
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use Prophecy\Argument;
|
use Prophecy\Argument;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
@@ -64,7 +64,7 @@ class ListShortUrlsCommandTest extends TestCase
|
|||||||
$data[] = new ShortUrl('url_' . $i);
|
$data[] = new ShortUrl('url_' . $i);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->shortUrlService->listShortUrls(1, null, [], null, new DateRange())
|
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
||||||
->willReturn(new Paginator(new ArrayAdapter($data)))
|
->willReturn(new Paginator(new ArrayAdapter($data)))
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ class ListShortUrlsCommandTest extends TestCase
|
|||||||
public function passingPageWillMakeListStartOnThatPage(): void
|
public function passingPageWillMakeListStartOnThatPage(): void
|
||||||
{
|
{
|
||||||
$page = 5;
|
$page = 5;
|
||||||
$this->shortUrlService->listShortUrls($page, null, [], null, new DateRange())
|
$this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page]))
|
||||||
->willReturn(new Paginator(new ArrayAdapter()))
|
->willReturn(new Paginator(new ArrayAdapter()))
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class ListShortUrlsCommandTest extends TestCase
|
|||||||
/** @test */
|
/** @test */
|
||||||
public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void
|
public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void
|
||||||
{
|
{
|
||||||
$this->shortUrlService->listShortUrls(1, null, [], null, new DateRange())
|
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
||||||
->willReturn(new Paginator(new ArrayAdapter()))
|
->willReturn(new Paginator(new ArrayAdapter()))
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
@@ -115,10 +115,16 @@ class ListShortUrlsCommandTest extends TestCase
|
|||||||
?int $page,
|
?int $page,
|
||||||
?string $searchTerm,
|
?string $searchTerm,
|
||||||
array $tags,
|
array $tags,
|
||||||
?DateRange $dateRange
|
?string $startDate = null,
|
||||||
|
?string $endDate = null
|
||||||
): void {
|
): void {
|
||||||
$listShortUrls = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, null, $dateRange)
|
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||||
->willReturn(new Paginator(new ArrayAdapter()));
|
'page' => $page,
|
||||||
|
'searchTerm' => $searchTerm,
|
||||||
|
'tags' => $tags,
|
||||||
|
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
|
||||||
|
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
|
||||||
|
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||||
|
|
||||||
$this->commandTester->setInputs(['n']);
|
$this->commandTester->setInputs(['n']);
|
||||||
$this->commandTester->execute($commandArgs);
|
$this->commandTester->execute($commandArgs);
|
||||||
@@ -128,36 +134,37 @@ class ListShortUrlsCommandTest extends TestCase
|
|||||||
|
|
||||||
public function provideArgs(): iterable
|
public function provideArgs(): iterable
|
||||||
{
|
{
|
||||||
yield [[], 1, null, [], new DateRange()];
|
yield [[], 1, null, []];
|
||||||
yield [['--page' => $page = 3], $page, null, [], new DateRange()];
|
yield [['--page' => $page = 3], $page, null, []];
|
||||||
yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, [], new DateRange()];
|
yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, []];
|
||||||
yield [
|
yield [
|
||||||
['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
||||||
$page,
|
$page,
|
||||||
$searchTerm,
|
$searchTerm,
|
||||||
explode(',', $tags),
|
explode(',', $tags),
|
||||||
new DateRange(),
|
|
||||||
];
|
];
|
||||||
yield [
|
yield [
|
||||||
['--startDate' => $startDate = '2019-01-01'],
|
['--startDate' => $startDate = '2019-01-01'],
|
||||||
1,
|
1,
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
new DateRange(Chronos::parse($startDate)),
|
$startDate,
|
||||||
];
|
];
|
||||||
yield [
|
yield [
|
||||||
['--endDate' => $endDate = '2020-05-23'],
|
['--endDate' => $endDate = '2020-05-23'],
|
||||||
1,
|
1,
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
new DateRange(null, Chronos::parse($endDate)),
|
null,
|
||||||
|
$endDate,
|
||||||
];
|
];
|
||||||
yield [
|
yield [
|
||||||
['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'],
|
['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'],
|
||||||
1,
|
1,
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)),
|
$startDate,
|
||||||
|
$endDate,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +175,9 @@ class ListShortUrlsCommandTest extends TestCase
|
|||||||
*/
|
*/
|
||||||
public function orderByIsProperlyComputed(array $commandArgs, $expectedOrderBy): void
|
public function orderByIsProperlyComputed(array $commandArgs, $expectedOrderBy): void
|
||||||
{
|
{
|
||||||
$listShortUrls = $this->shortUrlService->listShortUrls(1, null, [], $expectedOrderBy, new DateRange())
|
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||||
->willReturn(new Paginator(new ArrayAdapter()));
|
'orderBy' => $expectedOrderBy,
|
||||||
|
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||||
|
|
||||||
$this->commandTester->setInputs(['n']);
|
$this->commandTester->setInputs(['n']);
|
||||||
$this->commandTester->execute($commandArgs);
|
$this->commandTester->execute($commandArgs);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use Prophecy\Prophecy\ObjectProphecy;
|
|||||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
|
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
@@ -20,12 +21,12 @@ use const PHP_EOL;
|
|||||||
class ResolveUrlCommandTest extends TestCase
|
class ResolveUrlCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
private CommandTester $commandTester;
|
private CommandTester $commandTester;
|
||||||
private ObjectProphecy $urlShortener;
|
private ObjectProphecy $urlResolver;
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||||
$command = new ResolveUrlCommand($this->urlShortener->reveal());
|
$command = new ResolveUrlCommand($this->urlResolver->reveal());
|
||||||
$app = new Application();
|
$app = new Application();
|
||||||
$app->add($command);
|
$app->add($command);
|
||||||
|
|
||||||
@@ -38,8 +39,8 @@ class ResolveUrlCommandTest extends TestCase
|
|||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$expectedUrl = 'http://domain.com/foo/bar';
|
$expectedUrl = 'http://domain.com/foo/bar';
|
||||||
$shortUrl = new ShortUrl($expectedUrl);
|
$shortUrl = new ShortUrl($expectedUrl);
|
||||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willReturn($shortUrl)
|
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl)
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
@@ -49,9 +50,11 @@ class ResolveUrlCommandTest extends TestCase
|
|||||||
/** @test */
|
/** @test */
|
||||||
public function incorrectShortCodeOutputsErrorMessage(): void
|
public function incorrectShortCodeOutputsErrorMessage(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$identifier = new ShortUrlIdentifier('abc123');
|
||||||
$this->urlShortener->shortCodeToUrl($shortCode, null)
|
$shortCode = $identifier->shortCode();
|
||||||
->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode))
|
|
||||||
|
$this->urlResolver->resolveShortUrl($identifier)
|
||||||
|
->willThrow(ShortUrlNotFoundException::fromNotFound($identifier))
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||||
|
|||||||
@@ -15,18 +15,21 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
|||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Service\VisitService;
|
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\VisitLocator;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
|
use Symfony\Component\Console\Exception\RuntimeException;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
use Symfony\Component\Lock;
|
use Symfony\Component\Lock;
|
||||||
|
|
||||||
use function array_shift;
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
|
use const PHP_EOL;
|
||||||
|
|
||||||
class LocateVisitsCommandTest extends TestCase
|
class LocateVisitsCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
private CommandTester $commandTester;
|
private CommandTester $commandTester;
|
||||||
@@ -38,7 +41,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->visitService = $this->prophesize(VisitService::class);
|
$this->visitService = $this->prophesize(VisitLocator::class);
|
||||||
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
|
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
|
||||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||||
|
|
||||||
@@ -61,31 +64,53 @@ class LocateVisitsCommandTest extends TestCase
|
|||||||
$this->commandTester = new CommandTester($command);
|
$this->commandTester = new CommandTester($command);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/**
|
||||||
public function allPendingVisitsAreProcessed(): void
|
* @test
|
||||||
{
|
* @dataProvider provideArgs
|
||||||
|
*/
|
||||||
|
public function expectedSetOfVisitsIsProcessedBasedOnArgs(
|
||||||
|
int $expectedUnlocatedCalls,
|
||||||
|
int $expectedEmptyCalls,
|
||||||
|
int $expectedAllCalls,
|
||||||
|
bool $expectWarningPrint,
|
||||||
|
array $args
|
||||||
|
): void {
|
||||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
||||||
$location = new VisitLocation(Location::emptyInstance());
|
$location = new VisitLocation(Location::emptyInstance());
|
||||||
|
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||||
|
|
||||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
|
||||||
function (array $args) use ($visit, $location): void {
|
$locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will(
|
||||||
$firstCallback = array_shift($args);
|
$mockMethodBehavior,
|
||||||
$firstCallback($visit);
|
|
||||||
|
|
||||||
$secondCallback = array_shift($args);
|
|
||||||
$secondCallback($location, $visit);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
$locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior);
|
||||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
||||||
Location::emptyInstance(),
|
Location::emptyInstance(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->commandTester->execute([]);
|
$this->commandTester->setInputs(['y']);
|
||||||
|
$this->commandTester->execute($args);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
|
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
|
||||||
$locateVisits->shouldHaveBeenCalledOnce();
|
if ($expectWarningPrint) {
|
||||||
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
$this->assertStringContainsString('Continue at your own risk', $output);
|
||||||
|
} else {
|
||||||
|
$this->assertStringNotContainsString('Continue at your own risk', $output);
|
||||||
|
}
|
||||||
|
$locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls);
|
||||||
|
$locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls);
|
||||||
|
$locateAllVisits->shouldHaveBeenCalledTimes($expectedAllCalls);
|
||||||
|
$resolveIpLocation->shouldHaveBeenCalledTimes(
|
||||||
|
$expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provideArgs(): iterable
|
||||||
|
{
|
||||||
|
yield 'no args' => [1, 0, 0, false, []];
|
||||||
|
yield 'retry' => [1, 1, 0, false, ['--retry' => true]];
|
||||||
|
yield 'all' => [0, 0, 1, true, ['--retry' => true, '--all' => true]];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,13 +123,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||||||
$location = new VisitLocation(Location::emptyInstance());
|
$location = new VisitLocation(Location::emptyInstance());
|
||||||
|
|
||||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||||
function (array $args) use ($visit, $location): void {
|
$this->invokeHelperMethods($visit, $location),
|
||||||
$firstCallback = array_shift($args);
|
|
||||||
$firstCallback($visit);
|
|
||||||
|
|
||||||
$secondCallback = array_shift($args);
|
|
||||||
$secondCallback($location, $visit);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
||||||
Location::emptyInstance(),
|
Location::emptyInstance(),
|
||||||
@@ -137,13 +156,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||||||
$location = new VisitLocation(Location::emptyInstance());
|
$location = new VisitLocation(Location::emptyInstance());
|
||||||
|
|
||||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||||
function (array $args) use ($visit, $location): void {
|
$this->invokeHelperMethods($visit, $location),
|
||||||
$firstCallback = array_shift($args);
|
|
||||||
$firstCallback($visit);
|
|
||||||
|
|
||||||
$secondCallback = array_shift($args);
|
|
||||||
$secondCallback($location, $visit);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
|
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
|
||||||
|
|
||||||
@@ -156,6 +169,17 @@ class LocateVisitsCommandTest extends TestCase
|
|||||||
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function invokeHelperMethods(Visit $visit, VisitLocation $location): callable
|
||||||
|
{
|
||||||
|
return function (array $args) use ($visit, $location): void {
|
||||||
|
/** @var VisitGeolocationHelperInterface $helper */
|
||||||
|
[$helper] = $args;
|
||||||
|
|
||||||
|
$helper->geolocateVisit($visit);
|
||||||
|
$helper->onVisitLocated($location, $visit);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function noActionIsPerformedIfLockIsAcquired(): void
|
public function noActionIsPerformedIfLockIsAcquired(): void
|
||||||
{
|
{
|
||||||
@@ -212,4 +236,33 @@ class LocateVisitsCommandTest extends TestCase
|
|||||||
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
|
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
|
||||||
yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
|
yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function providingAllFlagOnItsOwnDisplaysNotice(): void
|
||||||
|
{
|
||||||
|
$this->commandTester->execute(['--all' => true]);
|
||||||
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('The --all flag has no effect on its own', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
* @dataProvider provideAbortInputs
|
||||||
|
*/
|
||||||
|
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
|
||||||
|
{
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('Execution aborted');
|
||||||
|
|
||||||
|
$this->commandTester->setInputs($inputs);
|
||||||
|
$this->commandTester->execute(['--all' => true, '--retry' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provideAbortInputs(): iterable
|
||||||
|
{
|
||||||
|
yield 'n' => [['n']];
|
||||||
|
yield 'no' => [['no']];
|
||||||
|
yield 'default' => [[PHP_EOL]];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core;
|
namespace Shlinkio\Shlink\Core;
|
||||||
|
|
||||||
use Doctrine\Common\Cache\Cache;
|
|
||||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||||
use Mezzio\Router\RouterInterface;
|
|
||||||
use Mezzio\Template\TemplateRendererInterface;
|
use Mezzio\Template\TemplateRendererInterface;
|
||||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Domain\Resolver;
|
||||||
use Shlinkio\Shlink\Core\ErrorHandler;
|
use Shlinkio\Shlink\Core\ErrorHandler;
|
||||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||||
|
|
||||||
@@ -27,9 +26,10 @@ return [
|
|||||||
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
||||||
Service\VisitsTracker::class => ConfigAbstractFactory::class,
|
Service\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||||
Service\VisitService::class => ConfigAbstractFactory::class,
|
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||||
Service\Tag\TagService::class => ConfigAbstractFactory::class,
|
Service\Tag\TagService::class => ConfigAbstractFactory::class,
|
||||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||||
|
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Util\UrlValidator::class => ConfigAbstractFactory::class,
|
Util\UrlValidator::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ return [
|
|||||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
|
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -50,30 +50,39 @@ return [
|
|||||||
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
|
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
|
||||||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||||
|
|
||||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Options\UrlShortenerOptions::class],
|
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class],
|
||||||
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
|
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
|
||||||
Service\ShortUrlService::class => ['em'],
|
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
|
||||||
Service\VisitService::class => ['em'],
|
Visit\VisitLocator::class => ['em'],
|
||||||
Service\Tag\TagService::class => ['em'],
|
Service\Tag\TagService::class => ['em'],
|
||||||
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
|
Service\ShortUrl\DeleteShortUrlService::class => [
|
||||||
|
'em',
|
||||||
|
Options\DeleteShortUrlsOptions::class,
|
||||||
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
|
],
|
||||||
|
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
||||||
|
|
||||||
Util\UrlValidator::class => ['httpClient'],
|
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||||
|
|
||||||
Action\RedirectAction::class => [
|
Action\RedirectAction::class => [
|
||||||
Service\UrlShortener::class,
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
Service\VisitsTracker::class,
|
Service\VisitsTracker::class,
|
||||||
Options\AppOptions::class,
|
Options\AppOptions::class,
|
||||||
'Logger_Shlink',
|
'Logger_Shlink',
|
||||||
],
|
],
|
||||||
Action\PixelAction::class => [
|
Action\PixelAction::class => [
|
||||||
Service\UrlShortener::class,
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
Service\VisitsTracker::class,
|
Service\VisitsTracker::class,
|
||||||
Options\AppOptions::class,
|
Options\AppOptions::class,
|
||||||
'Logger_Shlink',
|
'Logger_Shlink',
|
||||||
],
|
],
|
||||||
Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'],
|
Action\QrCodeAction::class => [
|
||||||
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
|
'config.url_shortener.domain',
|
||||||
|
'Logger_Shlink',
|
||||||
|
],
|
||||||
|
|
||||||
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
|
Resolver\PersistenceDomainResolver::class => ['em'],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,20 +6,21 @@ namespace Shlinkio\Shlink\Core;
|
|||||||
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||||
|
|
||||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||||
$builder = new ClassMetadataBuilder($metadata);
|
$builder = new ClassMetadataBuilder($metadata);
|
||||||
|
|
||||||
$builder->setTable('domains');
|
$builder->setTable(determineTableName('domains', $emConfig));
|
||||||
|
|
||||||
$builder->createField('id', Types::BIGINT)
|
$builder->createField('id', Types::BIGINT)
|
||||||
->columnName('id')
|
->columnName('id')
|
||||||
->makePrimaryKey()
|
->makePrimaryKey()
|
||||||
->generatedValue('IDENTITY')
|
->generatedValue('IDENTITY')
|
||||||
->option('unsigned', true)
|
->option('unsigned', true)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('authority', Types::STRING)
|
$builder->createField('authority', Types::STRING)
|
||||||
->unique()
|
->unique()
|
||||||
->build();
|
->build();
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,65 +6,66 @@ namespace Shlinkio\Shlink\Core;
|
|||||||
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||||
|
|
||||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||||
$builder = new ClassMetadataBuilder($metadata);
|
$builder = new ClassMetadataBuilder($metadata);
|
||||||
|
|
||||||
$builder->setTable('short_urls')
|
$builder->setTable(determineTableName('short_urls', $emConfig))
|
||||||
->setCustomRepositoryClass(Repository\ShortUrlRepository::class);
|
->setCustomRepositoryClass(Repository\ShortUrlRepository::class);
|
||||||
|
|
||||||
$builder->createField('id', Types::BIGINT)
|
$builder->createField('id', Types::BIGINT)
|
||||||
->columnName('id')
|
->columnName('id')
|
||||||
->makePrimaryKey()
|
->makePrimaryKey()
|
||||||
->generatedValue('IDENTITY')
|
->generatedValue('IDENTITY')
|
||||||
->option('unsigned', true)
|
->option('unsigned', true)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('longUrl', Types::STRING)
|
$builder->createField('longUrl', Types::STRING)
|
||||||
->columnName('original_url')
|
->columnName('original_url')
|
||||||
->length(2048)
|
->length(2048)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('shortCode', Types::STRING)
|
$builder->createField('shortCode', Types::STRING)
|
||||||
->columnName('short_code')
|
->columnName('short_code')
|
||||||
->length(255)
|
->length(255)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
|
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||||
->columnName('date_created')
|
->columnName('date_created')
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('validSince', ChronosDateTimeType::CHRONOS_DATETIME)
|
$builder->createField('validSince', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||||
->columnName('valid_since')
|
->columnName('valid_since')
|
||||||
->nullable()
|
->nullable()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('validUntil', ChronosDateTimeType::CHRONOS_DATETIME)
|
$builder->createField('validUntil', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||||
->columnName('valid_until')
|
->columnName('valid_until')
|
||||||
->nullable()
|
->nullable()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('maxVisits', Types::INTEGER)
|
$builder->createField('maxVisits', Types::INTEGER)
|
||||||
->columnName('max_visits')
|
->columnName('max_visits')
|
||||||
->nullable()
|
->nullable()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createOneToMany('visits', Entity\Visit::class)
|
$builder->createOneToMany('visits', Entity\Visit::class)
|
||||||
->mappedBy('shortUrl')
|
->mappedBy('shortUrl')
|
||||||
->fetchExtraLazy()
|
->fetchExtraLazy()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createManyToMany('tags', Entity\Tag::class)
|
$builder->createManyToMany('tags', Entity\Tag::class)
|
||||||
->setJoinTable('short_urls_in_tags')
|
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
|
||||||
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
|
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
|
||||||
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createManyToOne('domain', Entity\Domain::class)
|
$builder->createManyToOne('domain', Entity\Domain::class)
|
||||||
->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT')
|
->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT')
|
||||||
->cascadePersist()
|
->cascadePersist()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
|
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,21 +6,22 @@ namespace Shlinkio\Shlink\Core;
|
|||||||
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||||
|
|
||||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||||
$builder = new ClassMetadataBuilder($metadata);
|
$builder = new ClassMetadataBuilder($metadata);
|
||||||
|
|
||||||
$builder->setTable('tags')
|
$builder->setTable(determineTableName('tags', $emConfig))
|
||||||
->setCustomRepositoryClass(Repository\TagRepository::class);
|
->setCustomRepositoryClass(Repository\TagRepository::class);
|
||||||
|
|
||||||
$builder->createField('id', Types::BIGINT)
|
$builder->createField('id', Types::BIGINT)
|
||||||
->columnName('id')
|
->columnName('id')
|
||||||
->makePrimaryKey()
|
->makePrimaryKey()
|
||||||
->generatedValue('IDENTITY')
|
->generatedValue('IDENTITY')
|
||||||
->option('unsigned', true)
|
->option('unsigned', true)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('name', Types::STRING)
|
$builder->createField('name', Types::STRING)
|
||||||
->unique()
|
->unique()
|
||||||
->build();
|
->build();
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,49 +6,50 @@ namespace Shlinkio\Shlink\Core;
|
|||||||
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
|
|
||||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||||
$builder = new ClassMetadataBuilder($metadata);
|
$builder = new ClassMetadataBuilder($metadata);
|
||||||
|
|
||||||
$builder->setTable('visits')
|
$builder->setTable(determineTableName('visits', $emConfig))
|
||||||
->setCustomRepositoryClass(Repository\VisitRepository::class);
|
->setCustomRepositoryClass(Repository\VisitRepository::class);
|
||||||
|
|
||||||
$builder->createField('id', Types::BIGINT)
|
$builder->createField('id', Types::BIGINT)
|
||||||
->columnName('id')
|
->columnName('id')
|
||||||
->makePrimaryKey()
|
->makePrimaryKey()
|
||||||
->generatedValue('IDENTITY')
|
->generatedValue('IDENTITY')
|
||||||
->option('unsigned', true)
|
->option('unsigned', true)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('referer', Types::STRING)
|
$builder->createField('referer', Types::STRING)
|
||||||
->nullable()
|
->nullable()
|
||||||
->length(Visitor::REFERER_MAX_LENGTH)
|
->length(Visitor::REFERER_MAX_LENGTH)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('date', ChronosDateTimeType::CHRONOS_DATETIME)
|
$builder->createField('date', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||||
->columnName('`date`')
|
->columnName('`date`')
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('remoteAddr', Types::STRING)
|
$builder->createField('remoteAddr', Types::STRING)
|
||||||
->columnName('remote_addr')
|
->columnName('remote_addr')
|
||||||
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)
|
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)
|
||||||
->nullable()
|
->nullable()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createField('userAgent', Types::STRING)
|
$builder->createField('userAgent', Types::STRING)
|
||||||
->columnName('user_agent')
|
->columnName('user_agent')
|
||||||
->length(Visitor::USER_AGENT_MAX_LENGTH)
|
->length(Visitor::USER_AGENT_MAX_LENGTH)
|
||||||
->nullable()
|
->nullable()
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
|
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
|
||||||
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
|
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
|
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
|
||||||
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
|
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
|
||||||
->cascadePersist()
|
->cascadePersist()
|
||||||
->build();
|
->build();
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,41 +6,48 @@ namespace Shlinkio\Shlink\Core;
|
|||||||
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||||
|
|
||||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||||
$builder = new ClassMetadataBuilder($metadata);
|
$builder = new ClassMetadataBuilder($metadata);
|
||||||
|
|
||||||
$builder->setTable('visit_locations');
|
$builder->setTable(determineTableName('visit_locations', $emConfig));
|
||||||
|
|
||||||
$builder->createField('id', Types::BIGINT)
|
$builder->createField('id', Types::BIGINT)
|
||||||
->columnName('id')
|
->columnName('id')
|
||||||
->makePrimaryKey()
|
->makePrimaryKey()
|
||||||
->generatedValue('IDENTITY')
|
->generatedValue('IDENTITY')
|
||||||
->option('unsigned', true)
|
->option('unsigned', true)
|
||||||
->build();
|
|
||||||
|
|
||||||
$columns = [
|
|
||||||
'country_code' => 'countryCode',
|
|
||||||
'country_name' => 'countryName',
|
|
||||||
'region_name' => 'regionName',
|
|
||||||
'city_name' => 'cityName',
|
|
||||||
'timezone' => 'timezone',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($columns as $columnName => $fieldName) {
|
|
||||||
$builder->createField($fieldName, Types::STRING)
|
|
||||||
->columnName($columnName)
|
|
||||||
->nullable()
|
|
||||||
->build();
|
->build();
|
||||||
}
|
|
||||||
|
|
||||||
$builder->createField('latitude', Types::FLOAT)
|
$columns = [
|
||||||
->columnName('lat')
|
'country_code' => 'countryCode',
|
||||||
->nullable(false)
|
'country_name' => 'countryName',
|
||||||
->build();
|
'region_name' => 'regionName',
|
||||||
|
'city_name' => 'cityName',
|
||||||
|
'timezone' => 'timezone',
|
||||||
|
];
|
||||||
|
|
||||||
$builder->createField('longitude', Types::FLOAT)
|
foreach ($columns as $columnName => $fieldName) {
|
||||||
->columnName('lon')
|
$builder->createField($fieldName, Types::STRING)
|
||||||
->nullable(false)
|
->columnName($columnName)
|
||||||
->build();
|
->nullable()
|
||||||
|
->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->createField('latitude', Types::FLOAT)
|
||||||
|
->columnName('lat')
|
||||||
|
->nullable(false)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$builder->createField('longitude', Types::FLOAT)
|
||||||
|
->columnName('lon')
|
||||||
|
->nullable(false)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$builder->createField('isEmpty', Types::BOOLEAN)
|
||||||
|
->columnName('is_empty')
|
||||||
|
->option('default', false)
|
||||||
|
->nullable(false)
|
||||||
|
->build();
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
|
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
|
||||||
use RKA\Middleware\IpAddress;
|
use RKA\Middleware\IpAddress;
|
||||||
use Shlinkio\Shlink\Core\Action;
|
use Shlinkio\Shlink\Core\Action;
|
||||||
use Shlinkio\Shlink\Core\Middleware;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ return [
|
|||||||
'name' => Action\QrCodeAction::class,
|
'name' => Action\QrCodeAction::class,
|
||||||
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
|
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Middleware\QrCodeCacheMiddleware::class,
|
|
||||||
Action\QrCodeAction::class,
|
Action\QrCodeAction::class,
|
||||||
],
|
],
|
||||||
'allowed_methods' => [RequestMethod::METHOD_GET],
|
'allowed_methods' => [RequestMethod::METHOD_GET],
|
||||||
|
|||||||
@@ -4,9 +4,17 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core;
|
namespace Shlinkio\Shlink\Core;
|
||||||
|
|
||||||
|
use Cake\Chronos\Chronos;
|
||||||
|
use DateTimeInterface;
|
||||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||||
|
|
||||||
function generateRandomShortCode(int $length = 5): string
|
use function sprintf;
|
||||||
|
|
||||||
|
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||||
|
const MIN_SHORT_CODES_LENGTH = 4;
|
||||||
|
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||||
|
|
||||||
|
function generateRandomShortCode(int $length): string
|
||||||
{
|
{
|
||||||
static $shortIdFactory;
|
static $shortIdFactory;
|
||||||
if ($shortIdFactory === null) {
|
if ($shortIdFactory === null) {
|
||||||
@@ -16,3 +24,36 @@ function generateRandomShortCode(int $length = 5): string
|
|||||||
$alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
$alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
return $shortIdFactory->generate($length, $alphabet)->serialize();
|
return $shortIdFactory->generate($length, $alphabet)->serialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDateFromQuery(array $query, string $dateName): ?Chronos
|
||||||
|
{
|
||||||
|
return ! isset($query[$dateName]) || empty($query[$dateName]) ? null : Chronos::parse($query[$dateName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|DateTimeInterface|Chronos|null $date
|
||||||
|
*/
|
||||||
|
function parseDateField($date): ?Chronos
|
||||||
|
{
|
||||||
|
if ($date === null || $date instanceof Chronos) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($date instanceof DateTimeInterface) {
|
||||||
|
return Chronos::instance($date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Chronos::parse($date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineTableName(string $tableName, array $emConfig = []): string
|
||||||
|
{
|
||||||
|
$schema = $emConfig['connection']['schema'] ?? null;
|
||||||
|
// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO
|
||||||
|
|
||||||
|
if ($schema === null) {
|
||||||
|
return $tableName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s.%s', $schema, $tableName);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Action;
|
namespace Shlinkio\Shlink\Core\Action;
|
||||||
|
|
||||||
|
use Fig\Http\Message\RequestMethodInterface;
|
||||||
use Laminas\Diactoros\Uri;
|
use Laminas\Diactoros\Uri;
|
||||||
|
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
@@ -13,57 +15,50 @@ use Psr\Log\LoggerInterface;
|
|||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||||
|
|
||||||
use function array_key_exists;
|
use function array_key_exists;
|
||||||
use function array_merge;
|
use function array_merge;
|
||||||
|
use function GuzzleHttp\Psr7\build_query;
|
||||||
use function GuzzleHttp\Psr7\parse_query;
|
use function GuzzleHttp\Psr7\parse_query;
|
||||||
use function http_build_query;
|
|
||||||
|
|
||||||
abstract class AbstractTrackingAction implements MiddlewareInterface
|
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
|
||||||
{
|
{
|
||||||
private UrlShortenerInterface $urlShortener;
|
private ShortUrlResolverInterface $urlResolver;
|
||||||
private VisitsTrackerInterface $visitTracker;
|
private VisitsTrackerInterface $visitTracker;
|
||||||
private AppOptions $appOptions;
|
private AppOptions $appOptions;
|
||||||
private LoggerInterface $logger;
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
UrlShortenerInterface $urlShortener,
|
ShortUrlResolverInterface $urlResolver,
|
||||||
VisitsTrackerInterface $visitTracker,
|
VisitsTrackerInterface $visitTracker,
|
||||||
AppOptions $appOptions,
|
AppOptions $appOptions,
|
||||||
?LoggerInterface $logger = null
|
?LoggerInterface $logger = null
|
||||||
) {
|
) {
|
||||||
$this->urlShortener = $urlShortener;
|
$this->urlResolver = $urlResolver;
|
||||||
$this->visitTracker = $visitTracker;
|
$this->visitTracker = $visitTracker;
|
||||||
$this->appOptions = $appOptions;
|
$this->appOptions = $appOptions;
|
||||||
$this->logger = $logger ?: new NullLogger();
|
$this->logger = $logger ?: new NullLogger();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process an incoming server request and return a response, optionally delegating
|
|
||||||
* to the next middleware component to create the response.
|
|
||||||
*
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
{
|
{
|
||||||
$shortCode = $request->getAttribute('shortCode', '');
|
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
||||||
$domain = $request->getUri()->getAuthority();
|
|
||||||
$query = $request->getQueryParams();
|
$query = $request->getQueryParams();
|
||||||
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||||
|
|
||||||
// Track visit to this short code
|
if ($this->shouldTrackRequest($request, $query, $disableTrackParam)) {
|
||||||
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {
|
$this->visitTracker->track($shortUrl, Visitor::fromRequest($request));
|
||||||
$this->visitTracker->track($shortCode, Visitor::fromRequest($request));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
|
return $this->createSuccessResp($this->buildUrlToRedirectTo($shortUrl, $query, $disableTrackParam));
|
||||||
} catch (ShortUrlNotFoundException $e) {
|
} catch (ShortUrlNotFoundException $e) {
|
||||||
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
|
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
|
||||||
return $this->createErrorResp($request, $handler);
|
return $this->createErrorResp($request, $handler);
|
||||||
@@ -79,7 +74,17 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
|||||||
}
|
}
|
||||||
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
|
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
|
||||||
|
|
||||||
return (string) $uri->withQuery(http_build_query($mergedQuery));
|
return (string) $uri->withQuery(build_query($mergedQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool
|
||||||
|
{
|
||||||
|
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
|
||||||
|
if ($forwardedMethod === self::METHOD_HEAD) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract protected function createSuccessResp(string $longUrl): ResponseInterface;
|
abstract protected function createSuccessResp(string $longUrl): ResponseInterface;
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Action;
|
namespace Shlinkio\Shlink\Core\Action;
|
||||||
|
|
||||||
use Endroid\QrCode\QrCode;
|
use Endroid\QrCode\QrCode;
|
||||||
use Mezzio\Router\Exception\RuntimeException;
|
|
||||||
use Mezzio\Router\RouterInterface;
|
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
@@ -15,7 +13,8 @@ use Psr\Log\LoggerInterface;
|
|||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
|
|
||||||
class QrCodeAction implements MiddlewareInterface
|
class QrCodeAction implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
@@ -23,52 +22,38 @@ class QrCodeAction implements MiddlewareInterface
|
|||||||
private const MIN_SIZE = 50;
|
private const MIN_SIZE = 50;
|
||||||
private const MAX_SIZE = 1000;
|
private const MAX_SIZE = 1000;
|
||||||
|
|
||||||
private RouterInterface $router;
|
private ShortUrlResolverInterface $urlResolver;
|
||||||
private UrlShortenerInterface $urlShortener;
|
private array $domainConfig;
|
||||||
private LoggerInterface $logger;
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
RouterInterface $router,
|
ShortUrlResolverInterface $urlResolver,
|
||||||
UrlShortenerInterface $urlShortener,
|
array $domainConfig,
|
||||||
?LoggerInterface $logger = null
|
?LoggerInterface $logger = null
|
||||||
) {
|
) {
|
||||||
$this->router = $router;
|
$this->urlResolver = $urlResolver;
|
||||||
$this->urlShortener = $urlShortener;
|
$this->domainConfig = $domainConfig;
|
||||||
$this->logger = $logger ?: new NullLogger();
|
$this->logger = $logger ?: new NullLogger();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process an incoming server request and return a response, optionally delegating
|
|
||||||
* to the next middleware component to create the response.
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException
|
|
||||||
* @throws RuntimeException
|
|
||||||
*/
|
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
{
|
{
|
||||||
// Make sure the short URL exists for this short code
|
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
||||||
$shortCode = $request->getAttribute('shortCode');
|
|
||||||
$domain = $request->getUri()->getAuthority();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||||
} catch (ShortUrlNotFoundException $e) {
|
} catch (ShortUrlNotFoundException $e) {
|
||||||
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
|
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $this->router->generateUri(RedirectAction::class, ['shortCode' => $shortCode]);
|
$qrCode = new QrCode($shortUrl->toString($this->domainConfig));
|
||||||
$size = $this->getSizeParam($request);
|
$qrCode->setSize($this->getSizeParam($request));
|
||||||
|
|
||||||
$qrCode = new QrCode((string) $request->getUri()->withPath($path)->withQuery(''));
|
|
||||||
$qrCode->setSize($size);
|
|
||||||
$qrCode->setMargin(0);
|
$qrCode->setMargin(0);
|
||||||
|
|
||||||
return new QrCodeResponse($qrCode);
|
return new QrCodeResponse($qrCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*/
|
|
||||||
private function getSizeParam(Request $request): int
|
private function getSizeParam(Request $request): int
|
||||||
{
|
{
|
||||||
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
|
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Config;
|
namespace Shlinkio\Shlink\Core\Config;
|
||||||
|
|
||||||
use Laminas\Stdlib\ArrayUtils;
|
use Laminas\Stdlib\ArrayUtils;
|
||||||
use Shlinkio\Shlink\Installer\Util\PathCollection;
|
use Shlinkio\Shlink\Config\Collection\PathCollection;
|
||||||
|
|
||||||
use function array_flip;
|
use function array_flip;
|
||||||
use function array_intersect_key;
|
use function array_intersect_key;
|
||||||
@@ -24,7 +24,7 @@ class SimplifiedConfigParser
|
|||||||
'validate_url' => ['url_shortener', 'validate_url'],
|
'validate_url' => ['url_shortener', 'validate_url'],
|
||||||
'invalid_short_url_redirect_to' => ['not_found_redirects', 'invalid_short_url'],
|
'invalid_short_url_redirect_to' => ['not_found_redirects', 'invalid_short_url'],
|
||||||
'regular_404_redirect_to' => ['not_found_redirects', 'regular_404'],
|
'regular_404_redirect_to' => ['not_found_redirects', 'regular_404'],
|
||||||
'base_url_redirect_to' => ['not_found_redirects', 'base_path'],
|
'base_url_redirect_to' => ['not_found_redirects', 'base_url'],
|
||||||
'db_config' => ['entity_manager', 'connection'],
|
'db_config' => ['entity_manager', 'connection'],
|
||||||
'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
|
'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
|
||||||
'redis_servers' => ['cache', 'redis', 'servers'],
|
'redis_servers' => ['cache', 'redis', 'servers'],
|
||||||
@@ -32,6 +32,8 @@ class SimplifiedConfigParser
|
|||||||
'web_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'worker_num'],
|
'web_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'worker_num'],
|
||||||
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
|
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
|
||||||
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
|
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
|
||||||
|
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
|
||||||
|
'geolite_license_key' => ['geolite2', 'license_key'],
|
||||||
];
|
];
|
||||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||||
'delete_short_url_threshold' => [
|
'delete_short_url_threshold' => [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core;
|
namespace Shlinkio\Shlink\Core;
|
||||||
|
|
||||||
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
|
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
|
||||||
|
|
||||||
class ConfigProvider
|
class ConfigProvider
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Entity;
|
namespace Shlinkio\Shlink\Core\Entity;
|
||||||
|
|
||||||
|
use JsonSerializable;
|
||||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||||
|
|
||||||
class Domain extends AbstractEntity
|
class Domain extends AbstractEntity implements JsonSerializable
|
||||||
{
|
{
|
||||||
private string $authority;
|
private string $authority;
|
||||||
|
|
||||||
@@ -19,4 +20,9 @@ class Domain extends AbstractEntity
|
|||||||
{
|
{
|
||||||
return $this->authority;
|
return $this->authority;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): string
|
||||||
|
{
|
||||||
|
return $this->getAuthority();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
|||||||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
|
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||||
|
|
||||||
use function array_reduce;
|
use function array_reduce;
|
||||||
@@ -29,11 +30,12 @@ class ShortUrl extends AbstractEntity
|
|||||||
private Collection $visits;
|
private Collection $visits;
|
||||||
/** @var Collection|Tag[] */
|
/** @var Collection|Tag[] */
|
||||||
private Collection $tags;
|
private Collection $tags;
|
||||||
private ?Chronos $validSince;
|
private ?Chronos $validSince = null;
|
||||||
private ?Chronos $validUntil;
|
private ?Chronos $validUntil = null;
|
||||||
private ?int $maxVisits;
|
private ?int $maxVisits = null;
|
||||||
private ?Domain $domain;
|
private ?Domain $domain = null;
|
||||||
private bool $customSlugWasProvided;
|
private bool $customSlugWasProvided;
|
||||||
|
private int $shortCodeLength;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $longUrl,
|
string $longUrl,
|
||||||
@@ -50,7 +52,8 @@ class ShortUrl extends AbstractEntity
|
|||||||
$this->validUntil = $meta->getValidUntil();
|
$this->validUntil = $meta->getValidUntil();
|
||||||
$this->maxVisits = $meta->getMaxVisits();
|
$this->maxVisits = $meta->getMaxVisits();
|
||||||
$this->customSlugWasProvided = $meta->hasCustomSlug();
|
$this->customSlugWasProvided = $meta->hasCustomSlug();
|
||||||
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode();
|
$this->shortCodeLength = $meta->getShortCodeLength();
|
||||||
|
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
|
||||||
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
|
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +72,11 @@ class ShortUrl extends AbstractEntity
|
|||||||
return $this->dateCreated;
|
return $this->dateCreated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDomain(): ?Domain
|
||||||
|
{
|
||||||
|
return $this->domain;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection|Tag[]
|
* @return Collection|Tag[]
|
||||||
*/
|
*/
|
||||||
@@ -86,16 +94,19 @@ class ShortUrl extends AbstractEntity
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateMeta(ShortUrlMeta $shortCodeMeta): void
|
public function update(ShortUrlEdit $shortUrlEdit): void
|
||||||
{
|
{
|
||||||
if ($shortCodeMeta->hasValidSince()) {
|
if ($shortUrlEdit->hasValidSince()) {
|
||||||
$this->validSince = $shortCodeMeta->getValidSince();
|
$this->validSince = $shortUrlEdit->validSince();
|
||||||
}
|
}
|
||||||
if ($shortCodeMeta->hasValidUntil()) {
|
if ($shortUrlEdit->hasValidUntil()) {
|
||||||
$this->validUntil = $shortCodeMeta->getValidUntil();
|
$this->validUntil = $shortUrlEdit->validUntil();
|
||||||
}
|
}
|
||||||
if ($shortCodeMeta->hasMaxVisits()) {
|
if ($shortUrlEdit->hasMaxVisits()) {
|
||||||
$this->maxVisits = $shortCodeMeta->getMaxVisits();
|
$this->maxVisits = $shortUrlEdit->maxVisits();
|
||||||
|
}
|
||||||
|
if ($shortUrlEdit->hasLongUrl()) {
|
||||||
|
$this->longUrl = $shortUrlEdit->longUrl();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +125,7 @@ class ShortUrl extends AbstractEntity
|
|||||||
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
|
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->shortCode = generateRandomShortCode();
|
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +146,6 @@ class ShortUrl extends AbstractEntity
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection|Visit[] $visits
|
* @param Collection|Visit[] $visits
|
||||||
* @return ShortUrl
|
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
public function setVisits(Collection $visits): self
|
public function setVisits(Collection $visits): self
|
||||||
@@ -149,9 +159,25 @@ class ShortUrl extends AbstractEntity
|
|||||||
return $this->maxVisits;
|
return $this->maxVisits;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function maxVisitsReached(): bool
|
public function isEnabled(): bool
|
||||||
{
|
{
|
||||||
return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
|
$maxVisitsReached = $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
|
||||||
|
if ($maxVisitsReached) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = Chronos::now();
|
||||||
|
$beforeValidSince = $this->validSince !== null && $this->validSince->gt($now);
|
||||||
|
if ($beforeValidSince) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$afterValidUntil = $this->validUntil !== null && $this->validUntil->lt($now);
|
||||||
|
if ($afterValidUntil) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toString(array $domainConfig): string
|
public function toString(array $domainConfig): string
|
||||||
@@ -186,12 +212,10 @@ class ShortUrl extends AbstractEntity
|
|||||||
}
|
}
|
||||||
|
|
||||||
$shortUrlTags = invoke($this->getTags(), '__toString');
|
$shortUrlTags = invoke($this->getTags(), '__toString');
|
||||||
$hasAllTags = count($shortUrlTags) === count($tags) && array_reduce(
|
return count($shortUrlTags) === count($tags) && array_reduce(
|
||||||
$tags,
|
$tags,
|
||||||
fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag),
|
fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $hasAllTags;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,14 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
|||||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
||||||
|
|
||||||
class Visit extends AbstractEntity implements JsonSerializable
|
class Visit extends AbstractEntity implements JsonSerializable
|
||||||
{
|
{
|
||||||
private string $referer = '';
|
private string $referer;
|
||||||
private Chronos $date;
|
private Chronos $date;
|
||||||
private ?string $remoteAddr = null;
|
private ?string $remoteAddr = null;
|
||||||
private string $userAgent = '';
|
private string $userAgent;
|
||||||
private ShortUrl $shortUrl;
|
private ShortUrl $shortUrl;
|
||||||
private ?VisitLocation $visitLocation = null;
|
private ?VisitLocation $visitLocation = null;
|
||||||
|
|
||||||
@@ -60,9 +59,9 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||||||
return $this->shortUrl;
|
return $this->shortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getVisitLocation(): VisitLocationInterface
|
public function getVisitLocation(): ?VisitLocationInterface
|
||||||
{
|
{
|
||||||
return $this->visitLocation ?? new UnknownVisitLocation();
|
return $this->visitLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isLocatable(): bool
|
public function isLocatable(): bool
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||||||
private float $latitude;
|
private float $latitude;
|
||||||
private float $longitude;
|
private float $longitude;
|
||||||
private string $timezone;
|
private string $timezone;
|
||||||
|
private bool $isEmpty;
|
||||||
|
|
||||||
public function __construct(Location $location)
|
public function __construct(Location $location)
|
||||||
{
|
{
|
||||||
@@ -43,6 +44,11 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||||||
return $this->cityName;
|
return $this->cityName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isEmpty(): bool
|
||||||
|
{
|
||||||
|
return $this->isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
private function exchangeLocationInfo(Location $info): void
|
private function exchangeLocationInfo(Location $info): void
|
||||||
{
|
{
|
||||||
$this->countryCode = $info->countryCode();
|
$this->countryCode = $info->countryCode();
|
||||||
@@ -52,6 +58,15 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||||||
$this->latitude = $info->latitude();
|
$this->latitude = $info->latitude();
|
||||||
$this->longitude = $info->longitude();
|
$this->longitude = $info->longitude();
|
||||||
$this->timezone = $info->timeZone();
|
$this->timezone = $info->timeZone();
|
||||||
|
$this->isEmpty = (
|
||||||
|
$this->countryCode === '' &&
|
||||||
|
$this->countryName === '' &&
|
||||||
|
$this->regionName === '' &&
|
||||||
|
$this->cityName === '' &&
|
||||||
|
$this->latitude === 0.0 &&
|
||||||
|
$this->longitude === 0.0 &&
|
||||||
|
$this->timezone === ''
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
@@ -64,18 +79,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||||||
'latitude' => $this->latitude,
|
'latitude' => $this->latitude,
|
||||||
'longitude' => $this->longitude,
|
'longitude' => $this->longitude,
|
||||||
'timezone' => $this->timezone,
|
'timezone' => $this->timezone,
|
||||||
|
'isEmpty' => $this->isEmpty,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isEmpty(): bool
|
|
||||||
{
|
|
||||||
return
|
|
||||||
$this->countryCode === '' &&
|
|
||||||
$this->countryName === '' &&
|
|
||||||
$this->regionName === '' &&
|
|
||||||
$this->cityName === '' &&
|
|
||||||
$this->latitude === 0.0 &&
|
|
||||||
$this->longitude === 0.0 &&
|
|
||||||
$this->timezone === '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Psr\EventDispatcher\EventDispatcherInterface;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||||
|
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManager;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||||
@@ -41,22 +42,35 @@ class LocateShortUrlVisit
|
|||||||
|
|
||||||
public function __invoke(ShortUrlVisited $shortUrlVisited): void
|
public function __invoke(ShortUrlVisited $shortUrlVisited): void
|
||||||
{
|
{
|
||||||
|
// FIXME Temporarily handling DB connection reset here to fix https://github.com/shlinkio/shlink/issues/717
|
||||||
|
// Remove when https://github.com/shlinkio/shlink-event-dispatcher/issues/23 is implemented
|
||||||
|
if ($this->em instanceof ReopeningEntityManager) {
|
||||||
|
$this->em->open();
|
||||||
|
}
|
||||||
|
|
||||||
$visitId = $shortUrlVisited->visitId();
|
$visitId = $shortUrlVisited->visitId();
|
||||||
|
|
||||||
/** @var Visit|null $visit */
|
try {
|
||||||
$visit = $this->em->find(Visit::class, $visitId);
|
/** @var Visit|null $visit */
|
||||||
if ($visit === null) {
|
$visit = $this->em->find(Visit::class, $visitId);
|
||||||
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
|
if ($visit === null) {
|
||||||
'visitId' => $visitId,
|
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
|
||||||
]);
|
'visitId' => $visitId,
|
||||||
return;
|
]);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
|
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
|
||||||
$this->locateVisit($visitId, $visit);
|
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
|
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
|
||||||
|
} finally {
|
||||||
|
// FIXME Temporarily handling DB connection reset here to fix https://github.com/shlinkio/shlink/issues/717
|
||||||
|
// Remove when https://github.com/shlinkio/shlink-event-dispatcher/issues/23 is implemented
|
||||||
|
$this->em->getConnection()->close();
|
||||||
|
$this->em->clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function downloadOrUpdateGeoLiteDb(string $visitId): bool
|
private function downloadOrUpdateGeoLiteDb(string $visitId): bool
|
||||||
@@ -80,12 +94,13 @@ class LocateShortUrlVisit
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function locateVisit(string $visitId, Visit $visit): void
|
private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void
|
||||||
{
|
{
|
||||||
|
$isLocatable = $originalIpAddress !== null || $visit->isLocatable();
|
||||||
|
$addr = $originalIpAddress ?? $visit->getRemoteAddr();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$location = $visit->isLocatable()
|
$location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance();
|
||||||
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
|
|
||||||
: Location::emptyInstance();
|
|
||||||
|
|
||||||
$visit->locate(new VisitLocation($location));
|
$visit->locate(new VisitLocation($location));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ use JsonSerializable;
|
|||||||
final class ShortUrlVisited implements JsonSerializable
|
final class ShortUrlVisited implements JsonSerializable
|
||||||
{
|
{
|
||||||
private string $visitId;
|
private string $visitId;
|
||||||
|
private ?string $originalIpAddress;
|
||||||
|
|
||||||
public function __construct(string $visitId)
|
public function __construct(string $visitId, ?string $originalIpAddress = null)
|
||||||
{
|
{
|
||||||
$this->visitId = $visitId;
|
$this->visitId = $visitId;
|
||||||
|
$this->originalIpAddress = $originalIpAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function visitId(): string
|
public function visitId(): string
|
||||||
@@ -20,8 +22,13 @@ final class ShortUrlVisited implements JsonSerializable
|
|||||||
return $this->visitId;
|
return $this->visitId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function originalIpAddress(): ?string
|
||||||
|
{
|
||||||
|
return $this->originalIpAddress;
|
||||||
|
}
|
||||||
|
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
{
|
{
|
||||||
return ['visitId' => $this->visitId];
|
return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception;
|
|||||||
use Fig\Http\Message\StatusCodeInterface;
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
@@ -17,8 +18,10 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail
|
|||||||
private const TITLE = 'Short URL not found';
|
private const TITLE = 'Short URL not found';
|
||||||
private const TYPE = 'INVALID_SHORTCODE';
|
private const TYPE = 'INVALID_SHORTCODE';
|
||||||
|
|
||||||
public static function fromNotFoundShortCode(string $shortCode, ?string $domain = null): self
|
public static function fromNotFound(ShortUrlIdentifier $identifier): self
|
||||||
{
|
{
|
||||||
|
$shortCode = $identifier->shortCode();
|
||||||
|
$domain = $identifier->domain();
|
||||||
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
|
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
|
||||||
$e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix));
|
$e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix));
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Middleware;
|
|
||||||
|
|
||||||
use Doctrine\Common\Cache\Cache;
|
|
||||||
use Laminas\Diactoros\Response as DiactResp;
|
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
|
||||||
|
|
||||||
class QrCodeCacheMiddleware implements MiddlewareInterface
|
|
||||||
{
|
|
||||||
private Cache $cache;
|
|
||||||
|
|
||||||
public function __construct(Cache $cache)
|
|
||||||
{
|
|
||||||
$this->cache = $cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process an incoming server request and return a response, optionally delegating
|
|
||||||
* to the next middleware component to create the response.
|
|
||||||
*
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
|
||||||
{
|
|
||||||
$cacheKey = $request->getUri()->getPath();
|
|
||||||
|
|
||||||
// If this QR code is already cached, just return it
|
|
||||||
if ($this->cache->contains($cacheKey)) {
|
|
||||||
$qrData = $this->cache->fetch($cacheKey);
|
|
||||||
$response = new DiactResp();
|
|
||||||
$response->getBody()->write($qrData['body']);
|
|
||||||
return $response->withHeader('Content-Type', $qrData['content-type']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not, call the next middleware and cache it
|
|
||||||
/** @var Response $resp */
|
|
||||||
$resp = $handler->handle($request);
|
|
||||||
$this->cache->save($cacheKey, [
|
|
||||||
'body' => $resp->getBody()->__toString(),
|
|
||||||
'content-type' => $resp->getHeaderLine('Content-Type'),
|
|
||||||
]);
|
|
||||||
return $resp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
106
module/Core/src/Model/ShortUrlEdit.php
Normal file
106
module/Core/src/Model/ShortUrlEdit.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Model;
|
||||||
|
|
||||||
|
use Cake\Chronos\Chronos;
|
||||||
|
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||||
|
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||||
|
|
||||||
|
use function array_key_exists;
|
||||||
|
use function Shlinkio\Shlink\Core\parseDateField;
|
||||||
|
|
||||||
|
final class ShortUrlEdit
|
||||||
|
{
|
||||||
|
private bool $longUrlPropWasProvided = false;
|
||||||
|
private ?string $longUrl = null;
|
||||||
|
private bool $validSincePropWasProvided = false;
|
||||||
|
private ?Chronos $validSince = null;
|
||||||
|
private bool $validUntilPropWasProvided = false;
|
||||||
|
private ?Chronos $validUntil = null;
|
||||||
|
private bool $maxVisitsPropWasProvided = false;
|
||||||
|
private ?int $maxVisits = null;
|
||||||
|
|
||||||
|
// Enforce named constructors
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public static function fromRawData(array $data): self
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$instance->validateAndInit($data);
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
private function validateAndInit(array $data): void
|
||||||
|
{
|
||||||
|
$inputFilter = new ShortUrlMetaInputFilter($data);
|
||||||
|
if (! $inputFilter->isValid()) {
|
||||||
|
throw ValidationException::fromInputFilter($inputFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->longUrlPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::LONG_URL, $data);
|
||||||
|
$this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
|
||||||
|
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
|
||||||
|
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
|
||||||
|
|
||||||
|
$this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL);
|
||||||
|
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||||
|
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||||
|
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
|
||||||
|
{
|
||||||
|
$value = $inputFilter->getValue($fieldName);
|
||||||
|
return $value !== null ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function longUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->longUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasLongUrl(): bool
|
||||||
|
{
|
||||||
|
return $this->longUrlPropWasProvided && $this->longUrl !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validSince(): ?Chronos
|
||||||
|
{
|
||||||
|
return $this->validSince;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasValidSince(): bool
|
||||||
|
{
|
||||||
|
return $this->validSincePropWasProvided;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validUntil(): ?Chronos
|
||||||
|
{
|
||||||
|
return $this->validUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasValidUntil(): bool
|
||||||
|
{
|
||||||
|
return $this->validUntilPropWasProvided;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxVisits(): ?int
|
||||||
|
{
|
||||||
|
return $this->maxVisits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasMaxVisits(): bool
|
||||||
|
{
|
||||||
|
return $this->maxVisitsPropWasProvided;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
module/Core/src/Model/ShortUrlIdentifier.php
Normal file
54
module/Core/src/Model/ShortUrlIdentifier.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Model;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
final class ShortUrlIdentifier
|
||||||
|
{
|
||||||
|
private string $shortCode;
|
||||||
|
private ?string $domain;
|
||||||
|
|
||||||
|
public function __construct(string $shortCode, ?string $domain = null)
|
||||||
|
{
|
||||||
|
$this->shortCode = $shortCode;
|
||||||
|
$this->domain = $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromApiRequest(ServerRequestInterface $request): self
|
||||||
|
{
|
||||||
|
$shortCode = $request->getAttribute('shortCode', '');
|
||||||
|
$domain = $request->getQueryParams()['domain'] ?? null;
|
||||||
|
|
||||||
|
return new self($shortCode, $domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromRedirectRequest(ServerRequestInterface $request): self
|
||||||
|
{
|
||||||
|
$shortCode = $request->getAttribute('shortCode', '');
|
||||||
|
$domain = $request->getUri()->getAuthority();
|
||||||
|
|
||||||
|
return new self($shortCode, $domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromCli(InputInterface $input): self
|
||||||
|
{
|
||||||
|
$shortCode = $input->getArguments()['shortCode'] ?? '';
|
||||||
|
$domain = $input->getOptions()['domain'] ?? null;
|
||||||
|
|
||||||
|
return new self($shortCode, $domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shortCode(): string
|
||||||
|
{
|
||||||
|
return $this->shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domain(): ?string
|
||||||
|
{
|
||||||
|
return $this->domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Model;
|
namespace Shlinkio\Shlink\Core\Model;
|
||||||
|
|
||||||
use Cake\Chronos\Chronos;
|
use Cake\Chronos\Chronos;
|
||||||
use DateTimeInterface;
|
|
||||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Core\parseDateField;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||||
|
|
||||||
final class ShortUrlMeta
|
final class ShortUrlMeta
|
||||||
{
|
{
|
||||||
private ?Chronos $validSince = null;
|
private ?Chronos $validSince = null;
|
||||||
@@ -17,8 +20,9 @@ final class ShortUrlMeta
|
|||||||
private ?int $maxVisits = null;
|
private ?int $maxVisits = null;
|
||||||
private ?bool $findIfExists = null;
|
private ?bool $findIfExists = null;
|
||||||
private ?string $domain = null;
|
private ?string $domain = null;
|
||||||
|
private int $shortCodeLength = 5;
|
||||||
|
|
||||||
// Force named constructors
|
// Enforce named constructors
|
||||||
private function __construct()
|
private function __construct()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -29,81 +33,41 @@ final class ShortUrlMeta
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $data
|
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public static function createFromRawData(array $data): self
|
public static function fromRawData(array $data): self
|
||||||
{
|
{
|
||||||
$instance = new self();
|
$instance = new self();
|
||||||
$instance->validate($data);
|
$instance->validateAndInit($data);
|
||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string|Chronos|null $validSince
|
|
||||||
* @param string|Chronos|null $validUntil
|
|
||||||
* @param string|null $customSlug
|
|
||||||
* @param int|null $maxVisits
|
|
||||||
* @param bool|null $findIfExists
|
|
||||||
* @param string|null $domain
|
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public static function createFromParams( // phpcs:ignore
|
private function validateAndInit(array $data): void
|
||||||
$validSince = null,
|
|
||||||
$validUntil = null,
|
|
||||||
$customSlug = null,
|
|
||||||
$maxVisits = null,
|
|
||||||
$findIfExists = null,
|
|
||||||
$domain = null
|
|
||||||
): self {
|
|
||||||
// We do not type hint the arguments because that will be done by the validation process and we would get a
|
|
||||||
// type error if any of them do not match
|
|
||||||
$instance = new self();
|
|
||||||
$instance->validate([
|
|
||||||
ShortUrlMetaInputFilter::VALID_SINCE => $validSince,
|
|
||||||
ShortUrlMetaInputFilter::VALID_UNTIL => $validUntil,
|
|
||||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
|
||||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits,
|
|
||||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $findIfExists,
|
|
||||||
ShortUrlMetaInputFilter::DOMAIN => $domain,
|
|
||||||
]);
|
|
||||||
return $instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array $data
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
private function validate(array $data): void
|
|
||||||
{
|
{
|
||||||
$inputFilter = new ShortUrlMetaInputFilter($data);
|
$inputFilter = new ShortUrlMetaInputFilter($data);
|
||||||
if (! $inputFilter->isValid()) {
|
if (! $inputFilter->isValid()) {
|
||||||
throw ValidationException::fromInputFilter($inputFilter);
|
throw ValidationException::fromInputFilter($inputFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||||
$this->validUntil = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||||
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
|
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
|
||||||
$maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
|
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||||
$this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null;
|
|
||||||
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
|
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
|
||||||
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
|
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
|
||||||
|
$this->shortCodeLength = $this->getOptionalIntFromInputFilter(
|
||||||
|
$inputFilter,
|
||||||
|
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH,
|
||||||
|
) ?? DEFAULT_SHORT_CODES_LENGTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
|
||||||
* @param string|DateTimeInterface|Chronos|null $date
|
|
||||||
*/
|
|
||||||
private function parseDateField($date): ?Chronos
|
|
||||||
{
|
{
|
||||||
if ($date === null || $date instanceof Chronos) {
|
$value = $inputFilter->getValue($fieldName);
|
||||||
return $date;
|
return $value !== null ? (int) $value : null;
|
||||||
}
|
|
||||||
|
|
||||||
if ($date instanceof DateTimeInterface) {
|
|
||||||
return Chronos::instance($date);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Chronos::parse($date);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getValidSince(): ?Chronos
|
public function getValidSince(): ?Chronos
|
||||||
@@ -160,4 +124,9 @@ final class ShortUrlMeta
|
|||||||
{
|
{
|
||||||
return $this->domain;
|
return $this->domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getShortCodeLength(): int
|
||||||
|
{
|
||||||
|
return $this->shortCodeLength;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
module/Core/src/Model/ShortUrlsOrdering.php
Normal file
68
module/Core/src/Model/ShortUrlsOrdering.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Model;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||||
|
|
||||||
|
use function is_array;
|
||||||
|
use function is_string;
|
||||||
|
use function key;
|
||||||
|
|
||||||
|
final class ShortUrlsOrdering
|
||||||
|
{
|
||||||
|
public const ORDER_BY = 'orderBy';
|
||||||
|
private const DEFAULT_ORDER_DIRECTION = 'ASC';
|
||||||
|
|
||||||
|
private ?string $orderField = null;
|
||||||
|
private string $orderDirection = self::DEFAULT_ORDER_DIRECTION;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public static function fromRawData(array $query): self
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$instance->validateAndInit($query);
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
private function validateAndInit(array $data): void
|
||||||
|
{
|
||||||
|
/** @var string|array|null $orderBy */
|
||||||
|
$orderBy = $data[self::ORDER_BY] ?? null;
|
||||||
|
if ($orderBy === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isArray = is_array($orderBy);
|
||||||
|
if (! $isArray && $orderBy !== null && ! is_string($orderBy)) {
|
||||||
|
throw ValidationException::fromArray([
|
||||||
|
'orderBy' => '"Order by" must be an array, string or null',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->orderField = $isArray ? key($orderBy) : $orderBy;
|
||||||
|
$this->orderDirection = $isArray ? $orderBy[$this->orderField] : self::DEFAULT_ORDER_DIRECTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orderField(): ?string
|
||||||
|
{
|
||||||
|
return $this->orderField;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orderDirection(): string
|
||||||
|
{
|
||||||
|
return $this->orderDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasOrderField(): bool
|
||||||
|
{
|
||||||
|
return $this->orderField !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
module/Core/src/Model/ShortUrlsParams.php
Normal file
85
module/Core/src/Model/ShortUrlsParams.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Model;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
|
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||||
|
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Core\parseDateField;
|
||||||
|
|
||||||
|
final class ShortUrlsParams
|
||||||
|
{
|
||||||
|
private int $page;
|
||||||
|
private ?string $searchTerm;
|
||||||
|
private array $tags;
|
||||||
|
private ShortUrlsOrdering $orderBy;
|
||||||
|
private ?DateRange $dateRange;
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function emptyInstance(): self
|
||||||
|
{
|
||||||
|
return self::fromRawData([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public static function fromRawData(array $query): self
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$instance->validateAndInit($query);
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
private function validateAndInit(array $query): void
|
||||||
|
{
|
||||||
|
$inputFilter = new ShortUrlsParamsInputFilter($query);
|
||||||
|
if (! $inputFilter->isValid()) {
|
||||||
|
throw ValidationException::fromInputFilter($inputFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
|
||||||
|
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
|
||||||
|
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
|
||||||
|
$this->dateRange = new DateRange(
|
||||||
|
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
|
||||||
|
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
|
||||||
|
);
|
||||||
|
$this->orderBy = ShortUrlsOrdering::fromRawData($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function page(): int
|
||||||
|
{
|
||||||
|
return $this->page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchTerm(): ?string
|
||||||
|
{
|
||||||
|
return $this->searchTerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tags(): array
|
||||||
|
{
|
||||||
|
return $this->tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orderBy(): ShortUrlsOrdering
|
||||||
|
{
|
||||||
|
return $this->orderBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dateRange(): ?DateRange
|
||||||
|
{
|
||||||
|
return $this->dateRange;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Model;
|
namespace Shlinkio\Shlink\Core\Model;
|
||||||
|
|
||||||
use Cake\Chronos\Chronos;
|
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Core\parseDateFromQuery;
|
||||||
|
|
||||||
final class VisitsParams
|
final class VisitsParams
|
||||||
{
|
{
|
||||||
private const FIRST_PAGE = 1;
|
private const FIRST_PAGE = 1;
|
||||||
@@ -34,21 +35,13 @@ final class VisitsParams
|
|||||||
|
|
||||||
public static function fromRawData(array $query): self
|
public static function fromRawData(array $query): self
|
||||||
{
|
{
|
||||||
$startDate = self::getDateQueryParam($query, 'startDate');
|
|
||||||
$endDate = self::getDateQueryParam($query, 'endDate');
|
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
new DateRange($startDate, $endDate),
|
new DateRange(parseDateFromQuery($query, 'startDate'), parseDateFromQuery($query, 'endDate')),
|
||||||
(int) ($query['page'] ?? 1),
|
(int) ($query['page'] ?? 1),
|
||||||
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
|
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function getDateQueryParam(array $query, string $key): ?Chronos
|
|
||||||
{
|
|
||||||
return ! isset($query[$key]) || empty($query[$key]) ? null : Chronos::parse($query[$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDateRange(): DateRange
|
public function getDateRange(): DateRange
|
||||||
{
|
{
|
||||||
return $this->dateRange;
|
return $this->dateRange;
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Options;
|
namespace Shlinkio\Shlink\Core\Options;
|
||||||
|
|
||||||
use Laminas\Stdlib\AbstractOptions;
|
use Laminas\Stdlib\AbstractOptions;
|
||||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class AppOptions extends AbstractOptions
|
class AppOptions extends AbstractOptions
|
||||||
{
|
{
|
||||||
use StringUtilsTrait;
|
|
||||||
|
|
||||||
private string $name = '';
|
private string $name = '';
|
||||||
private string $version = '1.0';
|
private string $version = '1.0';
|
||||||
private ?string $disableTrackParam = null;
|
private ?string $disableTrackParam = null;
|
||||||
|
|||||||
@@ -5,38 +5,20 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||||
|
|
||||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||||
|
|
||||||
use function strip_tags;
|
|
||||||
use function trim;
|
|
||||||
|
|
||||||
class ShortUrlRepositoryAdapter implements AdapterInterface
|
class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||||
{
|
{
|
||||||
public const ITEMS_PER_PAGE = 10;
|
public const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
private ShortUrlRepositoryInterface $repository;
|
private ShortUrlRepositoryInterface $repository;
|
||||||
private ?string $searchTerm;
|
private ShortUrlsParams $params;
|
||||||
/** @var null|array|string */
|
|
||||||
private $orderBy;
|
|
||||||
private array $tags;
|
|
||||||
private ?DateRange $dateRange;
|
|
||||||
|
|
||||||
/**
|
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params)
|
||||||
* @param string|array|null $orderBy
|
{
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
ShortUrlRepositoryInterface $repository,
|
|
||||||
?string $searchTerm = null,
|
|
||||||
array $tags = [],
|
|
||||||
$orderBy = null,
|
|
||||||
?DateRange $dateRange = null
|
|
||||||
) {
|
|
||||||
$this->repository = $repository;
|
$this->repository = $repository;
|
||||||
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
|
$this->params = $params;
|
||||||
$this->orderBy = $orderBy;
|
|
||||||
$this->tags = $tags;
|
|
||||||
$this->dateRange = $dateRange;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,10 +32,10 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
|||||||
return $this->repository->findList(
|
return $this->repository->findList(
|
||||||
$itemCountPerPage,
|
$itemCountPerPage,
|
||||||
$offset,
|
$offset,
|
||||||
$this->searchTerm,
|
$this->params->searchTerm(),
|
||||||
$this->tags,
|
$this->params->tags(),
|
||||||
$this->orderBy,
|
$this->params->orderBy(),
|
||||||
$this->dateRange,
|
$this->params->dateRange(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +50,10 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
|||||||
*/
|
*/
|
||||||
public function count(): int
|
public function count(): int
|
||||||
{
|
{
|
||||||
return $this->repository->countList($this->searchTerm, $this->tags, $this->dateRange);
|
return $this->repository->countList(
|
||||||
|
$this->params->searchTerm(),
|
||||||
|
$this->params->tags(),
|
||||||
|
$this->params->dateRange(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,31 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||||
|
|
||||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||||
|
|
||||||
class VisitsPaginatorAdapter implements AdapterInterface
|
class VisitsPaginatorAdapter implements AdapterInterface
|
||||||
{
|
{
|
||||||
private VisitRepositoryInterface $visitRepository;
|
private VisitRepositoryInterface $visitRepository;
|
||||||
private string $shortCode;
|
private ShortUrlIdentifier $identifier;
|
||||||
private VisitsParams $params;
|
private VisitsParams $params;
|
||||||
|
|
||||||
public function __construct(VisitRepositoryInterface $visitRepository, string $shortCode, VisitsParams $params)
|
public function __construct(
|
||||||
{
|
VisitRepositoryInterface $visitRepository,
|
||||||
|
ShortUrlIdentifier $identifier,
|
||||||
|
VisitsParams $params
|
||||||
|
) {
|
||||||
$this->visitRepository = $visitRepository;
|
$this->visitRepository = $visitRepository;
|
||||||
$this->shortCode = $shortCode;
|
|
||||||
$this->params = $params;
|
$this->params = $params;
|
||||||
|
$this->identifier = $identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
|
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
|
||||||
{
|
{
|
||||||
return $this->visitRepository->findVisitsByShortCode(
|
return $this->visitRepository->findVisitsByShortCode(
|
||||||
$this->shortCode,
|
$this->identifier->shortCode(),
|
||||||
|
$this->identifier->domain(),
|
||||||
$this->params->getDateRange(),
|
$this->params->getDateRange(),
|
||||||
$itemCountPerPage,
|
$itemCountPerPage,
|
||||||
$offset,
|
$offset,
|
||||||
@@ -33,6 +38,10 @@ class VisitsPaginatorAdapter implements AdapterInterface
|
|||||||
|
|
||||||
public function count(): int
|
public function count(): int
|
||||||
{
|
{
|
||||||
return $this->visitRepository->countVisitsByShortCode($this->shortCode, $this->params->getDateRange());
|
return $this->visitRepository->countVisitsByShortCode(
|
||||||
|
$this->identifier->shortCode(),
|
||||||
|
$this->identifier->domain(),
|
||||||
|
$this->params->getDateRange(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,20 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Repository;
|
namespace Shlinkio\Shlink\Core\Repository;
|
||||||
|
|
||||||
use Cake\Chronos\Chronos;
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||||
|
|
||||||
use function array_column;
|
use function array_column;
|
||||||
use function array_key_exists;
|
use function array_key_exists;
|
||||||
use function Functional\contains;
|
use function Functional\contains;
|
||||||
use function is_array;
|
|
||||||
use function key;
|
|
||||||
|
|
||||||
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
|
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param string[] $tags
|
* @param string[] $tags
|
||||||
* @param string|array|null $orderBy
|
|
||||||
* @return ShortUrl[]
|
* @return ShortUrl[]
|
||||||
*/
|
*/
|
||||||
public function findList(
|
public function findList(
|
||||||
@@ -28,7 +25,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
|||||||
?int $offset = null,
|
?int $offset = null,
|
||||||
?string $searchTerm = null,
|
?string $searchTerm = null,
|
||||||
array $tags = [],
|
array $tags = [],
|
||||||
$orderBy = null,
|
?ShortUrlsOrdering $orderBy = null,
|
||||||
?DateRange $dateRange = null
|
?DateRange $dateRange = null
|
||||||
): array {
|
): array {
|
||||||
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
|
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
|
||||||
@@ -43,7 +40,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// In case the ordering has been specified, the query could be more complex. Process it
|
// In case the ordering has been specified, the query could be more complex. Process it
|
||||||
if ($orderBy !== null) {
|
if ($orderBy !== null && $orderBy->hasOrderField()) {
|
||||||
return $this->processOrderByForList($qb, $orderBy);
|
return $this->processOrderByForList($qb, $orderBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +49,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
|||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function processOrderByForList(QueryBuilder $qb, ShortUrlsOrdering $orderBy): array
|
||||||
* @param string|array|null $orderBy
|
|
||||||
*/
|
|
||||||
private function processOrderByForList(QueryBuilder $qb, $orderBy): array
|
|
||||||
{
|
{
|
||||||
$isArray = is_array($orderBy);
|
$fieldName = $orderBy->orderField();
|
||||||
$fieldName = $isArray ? key($orderBy) : $orderBy;
|
$order = $orderBy->orderDirection();
|
||||||
$order = $isArray ? $orderBy[$fieldName] : 'ASC';
|
|
||||||
|
|
||||||
if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
|
if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
|
||||||
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
|
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
|
||||||
@@ -97,8 +90,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
|||||||
?DateRange $dateRange = null
|
?DateRange $dateRange = null
|
||||||
): QueryBuilder {
|
): QueryBuilder {
|
||||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
$qb->from(ShortUrl::class, 's');
|
$qb->from(ShortUrl::class, 's')
|
||||||
$qb->where('1=1');
|
->where('1=1');
|
||||||
|
|
||||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||||
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
|
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
|
||||||
@@ -117,12 +110,14 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply search conditions
|
// Apply search conditions
|
||||||
$qb->andWhere($qb->expr()->orX(
|
$qb->leftJoin('s.domain', 'd')
|
||||||
$qb->expr()->like('s.longUrl', ':searchPattern'),
|
->andWhere($qb->expr()->orX(
|
||||||
$qb->expr()->like('s.shortCode', ':searchPattern'),
|
$qb->expr()->like('s.longUrl', ':searchPattern'),
|
||||||
$qb->expr()->like('t.name', ':searchPattern'),
|
$qb->expr()->like('s.shortCode', ':searchPattern'),
|
||||||
));
|
$qb->expr()->like('t.name', ':searchPattern'),
|
||||||
$qb->setParameter('searchPattern', '%' . $searchTerm . '%');
|
$qb->expr()->like('d.authority', ':searchPattern'),
|
||||||
|
))
|
||||||
|
->setParameter('searchPattern', '%' . $searchTerm . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by tags if provided
|
// Filter by tags if provided
|
||||||
@@ -134,7 +129,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
|||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl
|
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl
|
||||||
{
|
{
|
||||||
// When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at
|
// When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at
|
||||||
// the bottom
|
// the bottom
|
||||||
@@ -146,8 +141,6 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
|||||||
FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s
|
FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s
|
||||||
LEFT JOIN s.domain AS d
|
LEFT JOIN s.domain AS d
|
||||||
WHERE s.shortCode = :shortCode
|
WHERE s.shortCode = :shortCode
|
||||||
AND (s.validSince <= :now OR s.validSince IS NULL)
|
|
||||||
AND (s.validUntil >= :now OR s.validUntil IS NULL)
|
|
||||||
AND (s.domain IS NULL OR d.authority = :domain)
|
AND (s.domain IS NULL OR d.authority = :domain)
|
||||||
ORDER BY s.domain {$ordering}
|
ORDER BY s.domain {$ordering}
|
||||||
DQL;
|
DQL;
|
||||||
@@ -156,7 +149,6 @@ DQL;
|
|||||||
$query->setMaxResults(1)
|
$query->setMaxResults(1)
|
||||||
->setParameters([
|
->setParameters([
|
||||||
'shortCode' => $shortCode,
|
'shortCode' => $shortCode,
|
||||||
'now' => Chronos::now(),
|
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -166,19 +158,33 @@ DQL;
|
|||||||
// * The short URL matching the short code but without any domain, or
|
// * The short URL matching the short code but without any domain, or
|
||||||
// * No short URL at all
|
// * No short URL at all
|
||||||
|
|
||||||
/** @var ShortUrl|null $shortUrl */
|
return $query->getOneOrNullResult();
|
||||||
$shortUrl = $query->getOneOrNullResult();
|
}
|
||||||
return $shortUrl !== null && ! $shortUrl->maxVisitsReached() ? $shortUrl : null;
|
|
||||||
|
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl
|
||||||
|
{
|
||||||
|
$qb = $this->createFindOneQueryBuilder($shortCode, $domain);
|
||||||
|
$qb->select('s');
|
||||||
|
|
||||||
|
return $qb->getQuery()->getOneOrNullResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function shortCodeIsInUse(string $slug, ?string $domain = null): bool
|
public function shortCodeIsInUse(string $slug, ?string $domain = null): bool
|
||||||
|
{
|
||||||
|
$qb = $this->createFindOneQueryBuilder($slug, $domain);
|
||||||
|
$qb->select('COUNT(DISTINCT s.id)');
|
||||||
|
|
||||||
|
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createFindOneQueryBuilder(string $slug, ?string $domain = null): QueryBuilder
|
||||||
{
|
{
|
||||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
$qb->select('COUNT(DISTINCT s.id)')
|
$qb->from(ShortUrl::class, 's')
|
||||||
->from(ShortUrl::class, 's')
|
|
||||||
->where($qb->expr()->isNotNull('s.shortCode'))
|
->where($qb->expr()->isNotNull('s.shortCode'))
|
||||||
->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
|
->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
|
||||||
->setParameter('slug', $slug);
|
->setParameter('slug', $slug)
|
||||||
|
->setMaxResults(1);
|
||||||
|
|
||||||
if ($domain !== null) {
|
if ($domain !== null) {
|
||||||
$qb->join('s.domain', 'd')
|
$qb->join('s.domain', 'd')
|
||||||
@@ -188,7 +194,6 @@ DQL;
|
|||||||
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = (int) $qb->getQuery()->getSingleScalarResult();
|
return $qb;
|
||||||
return $result > 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,24 +7,24 @@ namespace Shlinkio\Shlink\Core\Repository;
|
|||||||
use Doctrine\Persistence\ObjectRepository;
|
use Doctrine\Persistence\ObjectRepository;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||||
|
|
||||||
interface ShortUrlRepositoryInterface extends ObjectRepository
|
interface ShortUrlRepositoryInterface extends ObjectRepository
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @param string|array|null $orderBy
|
|
||||||
*/
|
|
||||||
public function findList(
|
public function findList(
|
||||||
?int $limit = null,
|
?int $limit = null,
|
||||||
?int $offset = null,
|
?int $offset = null,
|
||||||
?string $searchTerm = null,
|
?string $searchTerm = null,
|
||||||
array $tags = [],
|
array $tags = [],
|
||||||
$orderBy = null,
|
?ShortUrlsOrdering $orderBy = null,
|
||||||
?DateRange $dateRange = null
|
?DateRange $dateRange = null
|
||||||
): array;
|
): array;
|
||||||
|
|
||||||
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int;
|
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int;
|
||||||
|
|
||||||
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl;
|
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||||
|
|
||||||
|
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||||
|
|
||||||
public function shortCodeIsInUse(string $slug, ?string $domain): bool;
|
public function shortCodeIsInUse(string $slug, ?string $domain): bool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,33 +12,63 @@ use Shlinkio\Shlink\Core\Entity\Visit;
|
|||||||
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* This method will allow you to iterate the whole list of unlocated visits, but loading them into memory in
|
|
||||||
* smaller blocks of a specific size.
|
|
||||||
* This will have side effects if you update those rows while you iterate them.
|
|
||||||
* If you plan to do so, pass the first argument as false in order to disable applying offsets while slicing the
|
|
||||||
* dataset
|
|
||||||
*
|
|
||||||
* @return iterable|Visit[]
|
* @return iterable|Visit[]
|
||||||
*/
|
*/
|
||||||
public function findUnlocatedVisits(bool $applyOffset = true, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||||
{
|
{
|
||||||
$dql = <<<DQL
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
SELECT v FROM Shlinkio\Shlink\Core\Entity\Visit AS v WHERE v.visitLocation IS NULL
|
$qb->select('v')
|
||||||
DQL;
|
->from(Visit::class, 'v')
|
||||||
$query = $this->getEntityManager()->createQuery($dql)
|
->where($qb->expr()->isNull('v.visitLocation'));
|
||||||
->setMaxResults($blockSize);
|
|
||||||
$remainingVisitsToProcess = $this->count(['visitLocation' => null]);
|
|
||||||
$offset = 0;
|
|
||||||
|
|
||||||
while ($remainingVisitsToProcess > 0) {
|
return $this->findVisitsForQuery($qb, $blockSize);
|
||||||
$iterator = $query->setFirstResult($applyOffset ? $offset : null)->iterate();
|
}
|
||||||
foreach ($iterator as $key => [$value]) {
|
|
||||||
yield $key => $value;
|
/**
|
||||||
|
* @return iterable|Visit[]
|
||||||
|
*/
|
||||||
|
public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb->select('v')
|
||||||
|
->from(Visit::class, 'v')
|
||||||
|
->join('v.visitLocation', 'vl')
|
||||||
|
->where($qb->expr()->isNotNull('v.visitLocation'))
|
||||||
|
->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
|
||||||
|
->setParameter('isEmpty', true);
|
||||||
|
|
||||||
|
return $this->findVisitsForQuery($qb, $blockSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb->select('v')
|
||||||
|
->from(Visit::class, 'v');
|
||||||
|
|
||||||
|
return $this->findVisitsForQuery($qb, $blockSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable
|
||||||
|
{
|
||||||
|
$originalQueryBuilder = $qb->setMaxResults($blockSize)
|
||||||
|
->orderBy('v.id', 'ASC');
|
||||||
|
$lastId = '0';
|
||||||
|
|
||||||
|
do {
|
||||||
|
$qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId));
|
||||||
|
$iterator = $qb->getQuery()->iterate();
|
||||||
|
$resultsFound = false;
|
||||||
|
|
||||||
|
/** @var Visit $visit */
|
||||||
|
foreach ($iterator as $key => [$visit]) {
|
||||||
|
$resultsFound = true;
|
||||||
|
yield $key => $visit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remainingVisitsToProcess -= $blockSize;
|
// As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
|
||||||
$offset += $blockSize;
|
$lastId = isset($visit) ? $visit->getId() : $lastId;
|
||||||
}
|
} while ($resultsFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,11 +76,12 @@ DQL;
|
|||||||
*/
|
*/
|
||||||
public function findVisitsByShortCode(
|
public function findVisitsByShortCode(
|
||||||
string $shortCode,
|
string $shortCode,
|
||||||
|
?string $domain = null,
|
||||||
?DateRange $dateRange = null,
|
?DateRange $dateRange = null,
|
||||||
?int $limit = null,
|
?int $limit = null,
|
||||||
?int $offset = null
|
?int $offset = null
|
||||||
): array {
|
): array {
|
||||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $dateRange);
|
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||||
$qb->select('v')
|
$qb->select('v')
|
||||||
->orderBy('v.date', 'DESC');
|
->orderBy('v.date', 'DESC');
|
||||||
|
|
||||||
@@ -64,22 +95,34 @@ DQL;
|
|||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countVisitsByShortCode(string $shortCode, ?DateRange $dateRange = null): int
|
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
|
||||||
{
|
{
|
||||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $dateRange);
|
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||||
$qb->select('COUNT(DISTINCT v.id)');
|
$qb->select('COUNT(DISTINCT v.id)');
|
||||||
|
|
||||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createVisitsByShortCodeQueryBuilder(string $shortCode, ?DateRange $dateRange = null): QueryBuilder
|
private function createVisitsByShortCodeQueryBuilder(
|
||||||
{
|
string $shortCode,
|
||||||
|
?string $domain,
|
||||||
|
?DateRange $dateRange
|
||||||
|
): QueryBuilder {
|
||||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
$qb->from(Visit::class, 'v')
|
$qb->from(Visit::class, 'v')
|
||||||
->join('v.shortUrl', 'su')
|
->join('v.shortUrl', 'su')
|
||||||
->where($qb->expr()->eq('su.shortCode', ':shortCode'))
|
->where($qb->expr()->eq('su.shortCode', ':shortCode'))
|
||||||
->setParameter('shortCode', $shortCode);
|
->setParameter('shortCode', $shortCode);
|
||||||
|
|
||||||
|
// Apply domain filtering
|
||||||
|
if ($domain !== null) {
|
||||||
|
$qb->join('su.domain', 'd')
|
||||||
|
->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||||
|
->setParameter('domain', $domain);
|
||||||
|
} else {
|
||||||
|
$qb->andWhere($qb->expr()->isNull('su.domain'));
|
||||||
|
}
|
||||||
|
|
||||||
// Apply date range filtering
|
// Apply date range filtering
|
||||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||||
$qb->andWhere($qb->expr()->gte('v.date', ':startDate'))
|
$qb->andWhere($qb->expr()->gte('v.date', ':startDate'))
|
||||||
|
|||||||
@@ -13,25 +13,34 @@ interface VisitRepositoryInterface extends ObjectRepository
|
|||||||
public const DEFAULT_BLOCK_SIZE = 10000;
|
public const DEFAULT_BLOCK_SIZE = 10000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method will allow you to iterate the whole list of unlocated visits, but loading them into memory in
|
|
||||||
* smaller blocks of a specific size.
|
|
||||||
* This will have side effects if you update those rows while you iterate them.
|
|
||||||
* If you plan to do so, pass the first argument as false in order to disable applying offsets while slicing the
|
|
||||||
* dataset
|
|
||||||
*
|
|
||||||
* @return iterable|Visit[]
|
* @return iterable|Visit[]
|
||||||
*/
|
*/
|
||||||
public function findUnlocatedVisits(bool $applyOffset = true, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable|Visit[]
|
||||||
|
*/
|
||||||
|
public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable|Visit[]
|
||||||
|
*/
|
||||||
|
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Visit[]
|
* @return Visit[]
|
||||||
*/
|
*/
|
||||||
public function findVisitsByShortCode(
|
public function findVisitsByShortCode(
|
||||||
string $shortCode,
|
string $shortCode,
|
||||||
|
?string $domain = null,
|
||||||
?DateRange $dateRange = null,
|
?DateRange $dateRange = null,
|
||||||
?int $limit = null,
|
?int $limit = null,
|
||||||
?int $offset = null
|
?int $offset = null
|
||||||
): array;
|
): array;
|
||||||
|
|
||||||
public function countVisitsByShortCode(string $shortCode, ?DateRange $dateRange = null): int;
|
public function countVisitsByShortCode(
|
||||||
|
string $shortCode,
|
||||||
|
?string $domain = null,
|
||||||
|
?DateRange $dateRange = null
|
||||||
|
): int;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,28 +7,32 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Exception;
|
use Shlinkio\Shlink\Core\Exception;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
||||||
|
|
||||||
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
||||||
{
|
{
|
||||||
use FindShortCodeTrait;
|
|
||||||
|
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
private DeleteShortUrlsOptions $deleteShortUrlsOptions;
|
private DeleteShortUrlsOptions $deleteShortUrlsOptions;
|
||||||
|
private ShortUrlResolverInterface $urlResolver;
|
||||||
|
|
||||||
public function __construct(EntityManagerInterface $em, DeleteShortUrlsOptions $deleteShortUrlsOptions)
|
public function __construct(
|
||||||
{
|
EntityManagerInterface $em,
|
||||||
|
DeleteShortUrlsOptions $deleteShortUrlsOptions,
|
||||||
|
ShortUrlResolverInterface $urlResolver
|
||||||
|
) {
|
||||||
$this->em = $em;
|
$this->em = $em;
|
||||||
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
|
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
|
||||||
|
$this->urlResolver = $urlResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception\ShortUrlNotFoundException
|
* @throws Exception\ShortUrlNotFoundException
|
||||||
* @throws Exception\DeleteShortUrlException
|
* @throws Exception\DeleteShortUrlException
|
||||||
*/
|
*/
|
||||||
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void
|
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void
|
||||||
{
|
{
|
||||||
$shortUrl = $this->findByShortCode($this->em, $shortCode);
|
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||||
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
|
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
|
||||||
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
|
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
|
||||||
$this->deleteShortUrlsOptions->getVisitsThreshold(),
|
$this->deleteShortUrlsOptions->getVisitsThreshold(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Core\Exception;
|
use Shlinkio\Shlink\Core\Exception;
|
||||||
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
|
|
||||||
interface DeleteShortUrlServiceInterface
|
interface DeleteShortUrlServiceInterface
|
||||||
{
|
{
|
||||||
@@ -12,5 +13,5 @@ interface DeleteShortUrlServiceInterface
|
|||||||
* @throws Exception\ShortUrlNotFoundException
|
* @throws Exception\ShortUrlNotFoundException
|
||||||
* @throws Exception\DeleteShortUrlException
|
* @throws Exception\DeleteShortUrlException
|
||||||
*/
|
*/
|
||||||
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void;
|
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user