Compare commits

...

126 Commits

Author SHA1 Message Date
Alejandro Celaya
e775b0f12f Merge pull request #722 from shlinkio/develop
Release 2.1.3
2020-04-09 12:50:46 +02:00
Alejandro Celaya
3ee5853b32 Merge pull request #721 from acelaya-forks/feature/qr-code-links
Feature/qr code links
2020-04-09 12:40:05 +02:00
Alejandro Celaya
832a24e4c7 Updated changelog 2020-04-09 12:33:00 +02:00
Alejandro Celaya
551368c30d Ensured QR code action respects configured domain 2020-04-09 12:31:03 +02:00
Alejandro Celaya
9f24b8eb76 Merge pull request #720 from acelaya-forks/feature/db-conn-recovery-task-workers
Feature/db conn recovery task workers
2020-04-09 12:01:47 +02:00
Alejandro Celaya
4c83ae2b22 Updated changelog 2020-04-09 11:55:47 +02:00
Alejandro Celaya
28e0fb049b Added check to ensure DB connection is gracefully recovered on swoole task workers 2020-04-09 11:54:54 +02:00
Alejandro Celaya
f79a369884 Merge pull request #719 from acelaya-forks/feature/handle-HEAD-requests
Feature/handle head requests
2020-04-09 00:06:28 +02:00
Alejandro Celaya
34c7b870a7 Removed unnecessary service registration, as it comes preregistered from third party config provider 2020-04-08 23:56:39 +02:00
Alejandro Celaya
ec9f874bb9 Updated changelog 2020-04-08 23:53:23 +02:00
Alejandro Celaya
1980d35691 Ensured redirect requests are not tracked when request is performed using method HEAD 2020-04-08 23:51:57 +02:00
Alejandro Celaya
ec8cbf82e5 Added HEAD request implicit handling 2020-04-08 17:27:26 +02:00
Alejandro Celaya
2b1011de52 Merge pull request #714 from acelaya-forks/feature/metadata-cache-clear
Feature/metadata cache clear
2020-04-06 21:08:46 +02:00
Alejandro Celaya
fa9ace83ad Fixed incorrect use of tilde 2020-04-06 20:59:10 +02:00
Alejandro Celaya
a9a53a9652 Ensured entities metadata cache is cleared during installation and docker start-up 2020-04-06 20:52:33 +02:00
Alejandro Celaya
afca8b2a62 Merge pull request #704 from shlinkio/develop
Release v2.1.2
2020-03-29 13:23:41 +02:00
Alejandro Celaya
daeb293fb9 Merge pull request #703 from acelaya-forks/feature/infection-0.16
Feature/infection 0.16
2020-03-29 13:14:07 +02:00
Alejandro Celaya
1ca50a4a8a Updated changelog 2020-03-29 13:08:21 +02:00
Alejandro Celaya
c6602a81ab Updated to infection 0.16 2020-03-29 13:07:27 +02:00
Alejandro Celaya
46da0e7824 Merge pull request #702 from acelaya-forks/feature/fix-tags-filtering
Feature/fix tags filtering
2020-03-29 13:00:06 +02:00
Alejandro Celaya
e790a38cea Updated changelog 2020-03-29 12:54:09 +02:00
Alejandro Celaya
11879ea377 Ensured tags are not sluggified when using them to filter short URL lists 2020-03-29 12:51:39 +02:00
Alejandro Celaya
7105add009 Merge pull request #701 from acelaya-forks/feature/fix-migration
Fixed query in migration for postgres
2020-03-29 12:25:19 +02:00
Alejandro Celaya
af61fdb52d Updated changelog 2020-03-29 12:15:52 +02:00
Alejandro Celaya
2b4fc354db Fixed query in migration for postgres 2020-03-29 12:13:52 +02:00
Alejandro Celaya
5b72001a8c Merge pull request #699 from shlinkio/develop
V2.1.1
2020-03-28 20:14:35 +01:00
Alejandro Celaya
7c79906ac4 Merge pull request #698 from acelaya-forks/feature/2.1.1
Feature/2.1.1
2020-03-28 20:03:09 +01:00
Alejandro Celaya
e30a724529 Make sure dist files include the htaccess file 2020-03-28 19:54:02 +01:00
Alejandro Celaya
73f97ea874 Ensured releases are published to github before the docker image is built 2020-03-28 19:48:06 +01:00
Alejandro Celaya
09c155b7d3 Merge pull request #695 from shlinkio/develop
v2.1.0
2020-03-28 12:23:41 +01:00
Alejandro Celaya
1e2d115768 Merge pull request #694 from acelaya-forks/feature/process-retry
Feature/process retry
2020-03-28 12:06:09 +01:00
Alejandro Celaya
53ba58d7e9 Moved initialization of the io object in LocateVisitsCommand to the initialize method 2020-03-28 10:37:41 +01:00
Alejandro Celaya
2a30afbe7d Updated changelog 2020-03-28 10:29:12 +01:00
Alejandro Celaya
4d39c7041b Improved LocateVisitsCommandtest so that it covers all possible workflows 2020-03-28 10:23:34 +01:00
Alejandro Celaya
c012b4740d Updated VisitLocator test so that it covers all public methods 2020-03-28 10:01:09 +01:00
Alejandro Celaya
55778eb810 Ensured old visit locations are deleted when relocating a visit that has already been located 2020-03-28 09:27:45 +01:00
Alejandro Celaya
fb8ab0b5fe Implemented how to reprocess the locations of all existing visits 2020-03-28 09:12:15 +01:00
Alejandro Celaya
fcce18b059 Changed VisitLocator signature so that it expects an object implementing an interface instead of two arbitrary callbacks 2020-03-28 08:05:15 +01:00
Alejandro Celaya
43a3d469e7 Improved how visits with some conditions are fetched from the database, so all internal logic is 100% transparent regardless the purpose 2020-03-27 22:01:26 +01:00
Alejandro Celaya
f730c24ecb Created method to return visits with empty location 2020-03-26 22:56:53 +01:00
Alejandro Celaya
b8522b8c17 Created new method to locate empty visits 2020-03-26 22:37:46 +01:00
Alejandro Celaya
b0d96040be Merge pull request #691 from Lynnesbian/develop
Update sample Nginx config to point to PHP 7.4
2020-03-25 08:49:36 +01:00
Lynne
5554675d03 Update sample Nginx config to point to PHP 7.4 2020-03-25 16:48:44 +10:00
Alejandro Celaya
c88401ef29 Added isEmpty column to VisitLocation 2020-03-23 20:42:03 +01:00
Alejandro Celaya
75f77ed929 Merge pull request #689 from acelaya-forks/feature/fake-class-constant
Moved hardcoded class alias to a namespaced constant
2020-03-22 17:50:57 +01:00
Alejandro Celaya
4539ab2dcf Moved hardcoded class alias to a namespaced constant 2020-03-22 17:42:56 +01:00
Alejandro Celaya
9ad0561cac Merge pull request #688 from acelaya-forks/feature/edit-long-url
Feature/edit long url
2020-03-22 17:38:00 +01:00
Alejandro Celaya
774052a983 Updated changelog 2020-03-22 17:31:16 +01:00
Alejandro Celaya
3beb27acc2 Added API tests for the edition of the longURL 2020-03-22 17:30:01 +01:00
Alejandro Celaya
5432eb7b77 Added URL validation to ShortUrl edition, as long URL can now be edited 2020-03-22 17:22:52 +01:00
Alejandro Celaya
181ff16409 Registered PersistenceDomainResolver as a service to avoid instantiating a new one on every ShortUrl creation 2020-03-22 17:05:59 +01:00
Alejandro Celaya
682a0768b7 Moved check for URL validation config option to the UrlValidator itself 2020-03-22 16:58:28 +01:00
Alejandro Celaya
d29ebb706e Documented longUrl param on PATCH short URL endpoint 2020-03-22 14:06:13 +01:00
Alejandro Celaya
4e6836c605 Long URLs can now be edited on existing short URLs 2020-03-22 14:04:01 +01:00
Alejandro Celaya
59c0d36c0b Merge pull request #687 from acelaya-forks/feature/real-ip-geolocation
Feature/real ip geolocation
2020-03-22 11:55:02 +01:00
Alejandro Celaya
e10b2884c0 Added more exclussions to dockerignore 2020-03-22 11:33:00 +01:00
Alejandro Celaya
8fb54e815e Ensured scrutinizer build ignores platform requirements when installing dependencies 2020-03-22 11:27:03 +01:00
Alejandro Celaya
3a14483568 Updated changelog 2020-03-22 11:13:33 +01:00
Alejandro Celaya
fdd8efc12d Added test covering case in which the original address is provided when locating visits 2020-03-22 11:12:30 +01:00
Alejandro Celaya
3fef4b4a28 Ensured non-obfuscated IP address is passed to event listener which geolocates it 2020-03-22 10:48:27 +01:00
Alejandro Celaya
cea50a860e Improved docker image generation 2020-03-22 10:01:34 +01:00
Alejandro Celaya
f9318bb1b3 Merge pull request #686 from acelaya-forks/feature/reduce-docker-image
Feature/reduce docker image
2020-03-21 16:21:25 +01:00
Alejandro Celaya
d22f020eb5 Ensured more non-prod files are ignored/deleted from the final docker image 2020-03-21 16:11:56 +01:00
Alejandro Celaya
c556d8123b Fixed name of the build arg passed when building docker for a specific tag 2020-03-21 15:43:52 +01:00
Alejandro Celaya
a592833bd0 Merge pull request #685 from acelaya-forks/feature/fix-docker-build
Ensured docker build happens in all branches
2020-03-21 15:36:18 +01:00
Alejandro Celaya
881da3db3b Ensured docker build happens in all branches 2020-03-21 15:31:14 +01:00
Alejandro Celaya
32eb9924e5 Merge pull request #684 from acelaya-forks/feature/travis-docker-build
Feature/travis docker build
2020-03-21 15:14:14 +01:00
Alejandro Celaya
d2c06dd0ab Initialized typed nullable props as null in all entities 2020-03-21 14:38:24 +01:00
Alejandro Celaya
75b8ed813f Enforced Swoole 4.4.15 to be installed during travis build, to match production one 2020-03-21 14:25:19 +01:00
Alejandro Celaya
f811002c2b Small adjustments on docker build 2020-03-21 09:37:31 +01:00
Alejandro Celaya
ca1b17863c Split Dockerfile into multiple stages, making the build be independent and then the released image just copy files from it 2020-03-21 09:10:15 +01:00
Alejandro Celaya
644f5be6fe Added scripts and configs to build docker image on travis 2020-03-21 08:42:13 +01:00
Alejandro Celaya
65fbb1dfb3 Merge pull request #680 from acelaya-forks/feature/non-interactive-db-commands
Feature/non interactive db commands
2020-03-15 17:32:57 +01:00
Alejandro Celaya
8597966187 Updated changelog 2020-03-15 17:26:34 +01:00
Alejandro Celaya
6ddd70d21d Added --no-interaction to commands run internally from shlink DB commands 2020-03-15 17:25:39 +01:00
Alejandro Celaya
d32112fe7e Updated shlink packages and installed shlink-config 2020-03-15 17:14:37 +01:00
Alejandro Celaya
da858f0353 Merge branch 'develop' of github.com:shlinkio/shlink into develop 2020-03-10 21:45:57 +01:00
Alejandro Celaya
ba8b041698 Updated API docs links 2020-03-10 21:45:20 +01:00
Alejandro Celaya
d9fee5582a Added docker pulls badge to main readme file 2020-03-07 08:44:12 +01:00
Alejandro Celaya
c9f17d54ee Merge pull request #676 from acelaya-forks/feature/bar-slug
Feature/bar slug
2020-03-06 20:09:59 +01:00
Alejandro Celaya
f5c1e12db4 Added more tests covering invalid custom slugs 2020-03-06 20:01:41 +01:00
Alejandro Celaya
18ceafeb60 Ensured only empty strings are checked while verifying empty value on custom slug 2020-03-06 19:25:05 +01:00
Alejandro Celaya
67e93a6874 Ensured empty values cannot be provided as the custom slug 2020-03-06 19:20:33 +01:00
Alejandro Celaya
f6a83a3062 Merge pull request #670 from acelaya-forks/feature/base-url-redirect
Feature/base url redirect
2020-02-25 18:06:47 +01:00
Alejandro Celaya
8a0e902bdd Updated changelog 2020-02-25 18:02:38 +01:00
Alejandro Celaya
590fc3fc92 Added tests covering redirect simplified config parsing 2020-02-25 18:01:06 +01:00
Alejandro Celaya
0d54b7696f Merge pull request #669 from jpatters/fix/base_url_redirect_to
fixed incorrect configuration option for base_url_redirect_to
2020-02-25 17:58:58 +01:00
Jordan Patterson
6b1dadc35c fixed incorrect configuration option for base_url_redirect_to 2020-02-25 06:47:02 -08:00
Alejandro Celaya
86009543ed Merge pull request #662 from acelaya-forks/feature/rest-request-id
Feature/rest request
2020-02-21 19:54:30 +01:00
Alejandro Celaya
b728a78673 Updated changelog 2020-02-21 19:47:30 +01:00
Alejandro Celaya
fb89cb80ac Added formatting to swoole logs, to avoid duplicating the date 2020-02-19 19:48:48 +01:00
Alejandro Celaya
d0a986dd5a Added request ID to logs with monolog 2020-02-19 19:37:47 +01:00
Alejandro Celaya
bb231e668b Registered middleware generating request ID 2020-02-19 18:58:25 +01:00
Alejandro Celaya
f53fa5c90f Merge pull request #660 from acelaya-forks/feature/short-codes-length
Feature/short codes length
2020-02-18 20:42:37 +01:00
Alejandro Celaya
1f3e0d1f73 Updated changelog 2020-02-18 20:35:41 +01:00
Alejandro Celaya
33a404f051 Updated CLI command to create short URLs so that it respects configs for short code length 2020-02-18 20:34:48 +01:00
Alejandro Celaya
51e130c7a0 Added env var that can be used to define default short codes length on docker image 2020-02-18 19:34:01 +01:00
Alejandro Celaya
343ee04acb Created middleware which injects default short code length from config when a value was not explicitly provided 2020-02-18 19:21:34 +01:00
Alejandro Celaya
9372d1739a Enforced short URLs length to be 4 at least 2020-02-18 18:57:24 +01:00
Alejandro Celaya
13555366e3 Short code lengths can now be customized 2020-02-18 18:54:40 +01:00
Alejandro Celaya
8162dafe16 Added separator in readme before MaxMind reference 2020-02-16 12:18:23 +01:00
Alejandro Celaya
0b6602b275 Merge pull request #659 from acelaya-forks/feature/phpunit9
Feature/phpunit9
2020-02-15 21:21:00 +01:00
Alejandro Celaya
2cf9f64e8e Updated changelog 2020-02-15 21:14:55 +01:00
Alejandro Celaya
37c0a813db Updated to PHPUnit 9 2020-02-15 21:14:14 +01:00
Alejandro Celaya
a9269811dc Added command to run api tests with code coverage 2020-02-15 20:55:04 +01:00
Alejandro Celaya
0b353737ea Merge pull request #658 from acelaya-forks/feature/mssql
Feature/mssql
2020-02-15 20:39:20 +01:00
Alejandro Celaya
a3fc1513e1 Updated Installer to include the one supporting MsSQL 2020-02-15 20:28:32 +01:00
Alejandro Celaya
5886d73093 Documented support for Microsoft SQL Server 2020-02-14 19:55:24 +01:00
Alejandro Celaya
12adce9ac2 Updated changelog 2020-02-14 19:51:58 +01:00
Alejandro Celaya
d8cbf0512b Added missing env var which fixes issues with mssql driver on alpine 2020-02-14 19:44:11 +01:00
Alejandro Celaya
2bb2c2cde3 Documented how to use mssql with the docker image 2020-02-14 19:27:21 +01:00
Alejandro Celaya
27fd9c5988 Added MSSQL driver to prod docker image 2020-02-14 19:20:54 +01:00
Alejandro Celaya
542673fcb0 Updated development docker images 2020-02-14 19:11:29 +01:00
Alejandro Celaya
e60d80bb16 Merge pull request #655 from shlinkio/develop
Release v2.0.5
2020-02-09 18:07:55 +01:00
Alejandro Celaya
bb9e57fa8b Added support for mssql on dev env 2020-02-09 18:01:11 +01:00
Alejandro Celaya
1d4bea68af Updated changelog 2020-02-09 17:59:01 +01:00
Alejandro Celaya
d2f9f5fd5e Merge pull request #654 from acelaya-forks/feature/domain-docs
Feature/domain docs
2020-02-08 19:02:39 +01:00
Alejandro Celaya
f13c3364eb Updated changelog 2020-02-08 18:52:48 +01:00
Alejandro Celaya
ac04bedead Documented how Shlink behaves when using multiple domains 2020-02-08 18:52:02 +01:00
Alejandro Celaya
67a66cefa6 Merge pull request #653 from acelaya-forks/feature/remove-default-domain-from-body
Feature/remove default domain from body
2020-02-08 12:38:20 +01:00
Alejandro Celaya
43db066cb4 Updated changelog 2020-02-08 12:31:25 +01:00
Alejandro Celaya
faec758fba Added test to ensure default domain is ignored if provided when creatin a short URL 2020-02-08 12:30:47 +01:00
Alejandro Celaya
ccec6e03aa Updated middleware which drops default domain so that it is capable of doing it from parsed body too 2020-02-08 12:22:07 +01:00
Alejandro Celaya
3f08b38558 Merge pull request #652 from acelaya-forks/feature/fix-logs-permissions
Feature/fix logs permissions
2020-02-08 11:58:09 +01:00
Alejandro Celaya
1ee5f64738 Updated changelog 2020-02-08 11:51:39 +01:00
Alejandro Celaya
d22169803f Ensured any user can write in generated log files 2020-02-08 11:50:25 +01:00
99 changed files with 1712 additions and 697 deletions

View File

@@ -8,17 +8,16 @@ data/migrations_template.txt
data/GeoLite2-City.*
data/database.sqlite
data/shlink-tests.db
**/.gitignore
CHANGELOG.md
UPGRADE.md
composer.lock
vendor
docs
indocker
docker-*
php*
infection.json
phpstan.neon
php*xml*
infection.json
**/test*
build*
.github
hooks
**/.*

View File

@@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
* Shlink 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
* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z)
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
#### Summary

View File

@@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
* Shlink 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
* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z)
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
#### Summary

2
.gitignore vendored
View File

@@ -1,6 +1,6 @@
.idea
build
!hooks/build
!docker/build
composer.lock
composer.phar
vendor/

View File

@@ -6,6 +6,9 @@ checks:
code_rating: true
duplication: true
build:
dependencies:
override:
- composer install --no-interaction --no-scripts --ignore-platform-reqs
nodes:
analysis:
tests:

View File

@@ -18,7 +18,7 @@ cache:
before_install:
- 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
install:
@@ -37,15 +37,15 @@ script:
after_success:
- rm -f build/clover.xml
- wget https://phar.phpunit.de/phpcov-6.0.1.phar
- phpdbg -qrr phpcov-6.0.1.phar merge build --clover build/clover.xml
- wget https://phar.phpunit.de/phpcov-7.0.2.phar
- phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
# Before deploying, build dist file for current travis tag
before_deploy:
- rm -f ocular.phar
- ./build.sh ${TRAVIS_TAG#?}
- if [[ ! -z $TRAVIS_TAG && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi
deploy:
- provider: releases
@@ -56,3 +56,9 @@ deploy:
on:
tags: true
php: '7.4'
- provider: script
script: bash ./docker/build
on:
all_branches: true
condition: $TRAVIS_PULL_REQUEST == 'false'
php: '7.4'

View File

@@ -4,6 +4,137 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.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

View File

@@ -1,15 +1,14 @@
FROM php:7.4.1-alpine3.10
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
FROM php:7.4.2-alpine3.11 as base
ARG SHLINK_VERSION=2.0.0
ARG SHLINK_VERSION=2.0.5
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.4.12
ENV COMPOSER_VERSION 1.9.1
ENV SWOOLE_VERSION 4.4.15
ENV LC_ALL "C"
WORKDIR /etc/shlink
RUN \
# Install mysl and calendar
# Install mysql and calendar
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
# Install sqlite
apk add --no-cache sqlite-libs sqlite-dev && \
@@ -24,24 +23,36 @@ RUN \
apk add --no-cache libzip-dev zlib-dev libpng-dev && \
docker-php-ext-install -j"$(nproc)" zip gd
# Install swoole
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
pecl install swoole-${SWOOLE_VERSION} && \
docker-php-ext-enable swoole && \
apk del .phpize-deps
# Install swoole and sqlsrv driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
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 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
FROM base as builder
COPY . .
RUN rm -rf ./docker && \
wget https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar && \
COPY --from=composer:1.10.1 /usr/bin/composer ./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 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 sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
# Expose swoole port
EXPOSE 8080

View File

@@ -4,8 +4,9 @@
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/master/LICENSE)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://acel.me/donate)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own 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 shlink](#using-shlink)
- [Shlink CLI Help](#shlink-cli-help)
- [Multiple domains](#multiple-domains)
- [Management](#management)
- [Visits](#visits)
- [Special redirects](#special-redirects)
## 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:
* PHP 7.4 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
* MySQL, MariaDB, PostgreSQL or SQLite.
* PHP 7.4 or greater with JSON, curl, PDO and gd extensions enabled.
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
* The web server of your choice with PHP integration (Apache or Nginx recommended).
### 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:
* 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.
* 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.
@@ -92,7 +97,7 @@ Once Shlink is configured, you need to expose it to the web, either by using a t
location ~ \.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;
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.
* **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.
@@ -280,4 +285,66 @@ Available commands:
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)

View File

@@ -9,7 +9,7 @@ echo 'Starting server...'
vendor/bin/mezzio-swoole start -d
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=$?
vendor/bin/mezzio-swoole stop

View File

@@ -19,13 +19,14 @@ mkdir -p "${builtcontent}"
rsync -av * "${builtcontent}" \
--exclude=*docker* \
--exclude=Dockerfile \
--include=.htaccess \
--exclude-from=./.dockerignore
cd "${builtcontent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
${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
echo 'Deleting dev files...'

View File

@@ -40,17 +40,20 @@
"mezzio/mezzio-helpers": "^5.3",
"mezzio/mezzio-platesrenderer": "^2.1",
"mezzio/mezzio-problem-details": "^1.1",
"mezzio/mezzio-swoole": "^2.4",
"mezzio/mezzio-swoole": "^2.6",
"monolog/monolog": "^2.0",
"nikolaposa/monolog-factory": "^3.0",
"ocramius/proxy-manager": "^2.6.0",
"ocramius/proxy-manager": "^2.7.0",
"phly/phly-event-dispatcher": "^1.0",
"php-middleware/request-id": "^4.0",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.7.0",
"shlinkio/shlink-event-dispatcher": "^1.3",
"shlinkio/shlink-installer": "^4.0.1",
"shlinkio/shlink-ip-geolocation": "^1.3.1",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.0",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-installer": "^4.3.2",
"shlinkio/shlink-ip-geolocation": "^1.4",
"symfony/console": "^5.0",
"symfony/filesystem": "^5.0",
"symfony/lock": "^5.0",
@@ -58,14 +61,14 @@
},
"require-dev": {
"devster/ubench": "^2.0",
"dms/phpunit-arraysubset-asserts": "^0.1.0",
"dms/phpunit-arraysubset-asserts": "^0.2.0",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.15.0",
"phpstan/phpstan": "^0.12.3",
"phpunit/phpunit": "^8.3",
"infection/infection": "^0.16.1",
"phpstan/phpstan": "^0.12.18",
"phpunit/phpunit": "~9.0.1",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.0",
"shlinkio/shlink-test-utils": "^1.3",
"shlinkio/shlink-test-utils": "^1.4",
"symfony/var-dumper": "^5.0"
},
"autoload": {
@@ -107,7 +110,7 @@
"test:ci": [
"@test:unit: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:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
@@ -115,7 +118,8 @@
"@test:db:sqlite",
"@test:db:mysql",
"@test:db:maria",
"@test:db:postgres"
"@test:db:postgres",
"@test:db:ms"
],
"test:db:ci": [
"@test:db:sqlite",
@@ -126,10 +130,12 @@
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test: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",
"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:test": [
"@test:unit:ci",

View File

@@ -30,6 +30,7 @@ return [
Option\TaskWorkerNumConfigOption::class,
Option\WebWorkerNumConfigOption::class,
Option\RedisServersConfigOption::class,
Option\ShortCodeLengthOption::class,
],
'installation_commands' => [

View File

@@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Symfony\Component\Lock;
$localLockFactory = 'Shlinkio\Shlink\LocalLockFactory';
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
return [
@@ -21,7 +21,7 @@ return [
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
Lock\LockFactory::class => ConfigAbstractFactory::class,
$localLockFactory => ConfigAbstractFactory::class,
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
],
'aliases' => [
// 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\RedisStore::class => [RedisFactory::SERVICE_NAME],
Lock\LockFactory::class => ['lock_store'],
$localLockFactory => ['local_lock_store'],
LOCAL_LOCK_FACTORY => ['local_lock_store'],
],
];

View File

@@ -9,6 +9,7 @@ use Monolog\Handler;
use Monolog\Logger;
use Monolog\Processor;
use MonologFactory\DiContainerLoggerFactory;
use PhpMiddleware\RequestId;
use Psr\Log\LoggerInterface;
use const PHP_EOL;
@@ -20,11 +21,12 @@ $processors = [
'psr3' => [
'name' => Processor\PsrLogMessageProcessor::class,
],
'request_id' => RequestId\MonologProcessor::class,
];
$formatter = [
'name' => Formatter\LineFormatter::class,
'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,
],
];
@@ -41,6 +43,7 @@ return [
'level' => Logger::INFO,
'filename' => 'data/log/shlink_log.log',
'max_files' => 30,
'file_permission' => 0666,
],
'formatter' => $formatter,
],
@@ -79,6 +82,7 @@ return [
'swoole-http-server' => [
'logger' => [
'logger-name' => 'Logger_Access',
'format' => '%h %l %u "%r" %>s %b',
],
],
],

View File

@@ -5,15 +5,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio;
use Mezzio\Helper;
use Mezzio\ProblemDetails;
use Mezzio\Router;
use PhpMiddleware\RequestId\RequestIdMiddleware;
return [
'middleware_pipeline' => [
'error-handler' => [
'middleware' => [
Mezzio\Helper\ContentLengthMiddleware::class,
Helper\ContentLengthMiddleware::class,
ErrorHandler::class,
],
],
@@ -21,6 +23,7 @@ return [
'path' => '/rest',
'middleware' => [
Rest\Middleware\CrossDomainMiddleware::class,
RequestIdMiddleware::class,
ProblemDetails\ProblemDetailsMiddleware::class,
],
],
@@ -33,14 +36,15 @@ return [
'routing' => [
'middleware' => [
Mezzio\Router\Middleware\RouteMiddleware::class,
Router\Middleware\RouteMiddleware::class,
Router\Middleware\ImplicitHeadMiddleware::class,
],
],
'rest' => [
'path' => '/rest',
'middleware' => [
Mezzio\Router\Middleware\ImplicitOptionsMiddleware::class,
Router\Middleware\ImplicitOptionsMiddleware::class,
Rest\Middleware\BodyParserMiddleware::class,
Rest\Middleware\AuthenticationMiddleware::class,
],
@@ -48,7 +52,7 @@ return [
'dispatch' => [
'middleware' => [
Mezzio\Router\Middleware\DispatchMiddleware::class,
Router\Middleware\DispatchMiddleware::class,
],
],
@@ -65,4 +69,5 @@ return [
],
],
],
];

View 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],
],
];

View File

@@ -2,6 +2,8 @@
declare(strict_types=1);
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
return [
'url_shortener' => [
@@ -11,6 +13,7 @@ return [
],
'validate_url' => false,
'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
],
];

View File

@@ -19,11 +19,12 @@ return (new ConfigAggregator\ConfigAggregator([
Mezzio\Swoole\ConfigProvider::class,
ProblemDetails\ConfigProvider::class,
Common\ConfigProvider::class,
Config\ConfigProvider::class,
IpGeolocation\ConfigProvider::class,
EventDispatcher\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
EventDispatcher\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
env('APP_ENV') === 'test'
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')

View File

@@ -5,13 +5,16 @@ declare(strict_types=1);
use Laminas\ServiceManager\ServiceManager;
use Symfony\Component\Lock;
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) {
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
// It needs to be placed here as individual config files will not be loaded once config is cached
if (! class_exists(LOCAL_LOCK_FACTORY)) {
class_alias(Lock\LockFactory::class, LOCAL_LOCK_FACTORY);
}
// Build container

View File

@@ -45,6 +45,13 @@ $buildDbConnection = function (): array {
'dbname' => 'shlink_test',
'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'];

View File

@@ -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>
ENV APCU_VERSION 5.1.18
@@ -65,6 +65,18 @@ RUN docker-php-ext-configure xdebug\
# cleanup
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
RUN php -r "readfile('https://getcomposer.org/installer');" | php
RUN chmod +x composer.phar

View File

@@ -1,10 +1,10 @@
FROM php:7.4.1-alpine3.10
FROM php:7.4.2-alpine3.11
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5
ENV INOTIFY_VERSION 2.0.0
ENV SWOOLE_VERSION 4.4.12
ENV SWOOLE_VERSION 4.4.15
RUN apk update
@@ -66,12 +66,17 @@ RUN docker-php-ext-configure inotify\
# cleanup
RUN rm /tmp/inotify.tar.gz
# Install swoole
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
pecl install swoole-${SWOOLE_VERSION} && \
docker-php-ext-enable swoole && \
apk del .phpize-deps
# Install swoole and mssql driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
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 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
RUN php -r "readfile('https://getcomposer.org/installer');" | php

View 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');
}
}

View File

@@ -25,7 +25,10 @@ services:
- shlink_db
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
environment:
LC_ALL: C
shlink_swoole:
container_name: shlink_swoole
@@ -42,7 +45,10 @@ services:
- shlink_db
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
environment:
LC_ALL: C
shlink_db:
container_name: shlink_db
@@ -82,6 +88,15 @@ services:
MYSQL_DATABASE: shlink
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:
container_name: shlink_redis
image: redis:5.0-alpine

View File

@@ -1,6 +1,5 @@
# Shlink Docker image
[![Docker build status](https://img.shields.io/docker/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](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.
@@ -38,10 +37,10 @@ Or you can list all tags with:
docker exec -it shlink_container shlink tag:list
```
Or process remaining visits with:
Or locate remaining visits with:
```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.
@@ -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.
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_USER`: **[Mandatory]**. The username 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`:
* **mysql** or **maria** -> `3306`
* **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:
@@ -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_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_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.
@@ -101,6 +101,7 @@ This is the complete list of supported env vars:
* Default value is based on the value provided for `DB_DRIVER`:
* **mysql** or **maria** -> `3306`
* **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.
* `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`.
@@ -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.
* `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.
* `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).
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.
@@ -144,6 +146,7 @@ docker run \
-e WEB_WORKER_NUM=64 \
-e TASK_WORKER_NUM=32 \
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
-e DEFAULT_SHORT_CODES_LENGTH=6 \
shlinkio/shlink:stable
```
@@ -168,6 +171,7 @@ The whole configuration should have this format, but it can be split into multip
"base_path": "/my-campaign",
"web_worker_num": 64,
"task_worker_num": 32,
"default_short_codes_length": 6,
"redis_servers": [
"tcp://172.20.0.1:6379",
"tcp://172.20.0.2:6379"

15
docker/build Executable file
View 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

View File

@@ -11,16 +11,21 @@ use function explode;
use function Functional\contains;
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 {
private const DB_DRIVERS_MAP = [
'mysql' => 'pdo_mysql',
'maria' => 'pdo_mysql',
'postgres' => 'pdo_pgsql',
'mssql' => 'pdo_sqlsrv',
];
private const DB_PORTS_MAP = [
'mysql' => '3306',
'maria' => '3306',
'postgres' => '5432',
'mssql' => '1433',
];
public function getDbConfig(): array
@@ -68,6 +73,12 @@ $helper = new class {
$redisServers = env('REDIS_SERVERS');
return $redisServers === null ? null : ['servers' => $redisServers];
}
public function getDefaultShortCodesLength(): int
{
$value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value;
}
};
return [
@@ -94,6 +105,7 @@ return [
],
'validate_url' => (bool) env('VALIDATE_URLS', false),
'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
],
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),

View File

@@ -12,6 +12,9 @@ php bin/cli db:migrate -n -q
echo "Generating proxies..."
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
# 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

View File

@@ -243,6 +243,10 @@
"domain": {
"description": "The domain to which the short URL will be attached",
"type": "string"
},
"shortCodeLength": {
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
"type": "number"
}
}
}

View File

@@ -112,6 +112,10 @@
"schema": {
"type": "object",
"properties": {
"longUrl": {
"description": "The long URL this short URL will redirect to",
"type": "string"
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
@@ -157,6 +161,7 @@
"items": {
"type": "string",
"enum": [
"longUrl",
"validSince",
"validUntil",
"maxVisits"

View File

@@ -7,7 +7,7 @@
},
"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"
},

View File

@@ -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} .

View File

@@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
@@ -19,6 +20,8 @@ use Symfony\Component\Console as SymfonyCli;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
return [
'dependencies' => [
@@ -52,16 +55,20 @@ return [
],
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 => [
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\GetVisitsCommand::class => [Service\VisitsTracker::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\LocateVisitsCommand::class => [
Service\VisitService::class,
Visit\VisitLocator::class,
IpLocationResolverInterface::class,
LockFactory::class,
GeolocationDbUpdater::class,

View File

@@ -11,8 +11,6 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use function array_unshift;
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
{
private ProcessHelper $processHelper;
@@ -27,7 +25,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
protected function runPhpCommand(OutputInterface $output, array $command): void
{
array_unshift($command, $this->phpBinary);
$command = [$this->phpBinary, ...$command, '--no-interaction'];
$this->processHelper->mustRun($output, $command);
}

View File

@@ -30,12 +30,14 @@ class GenerateShortUrlCommand extends Command
private UrlShortenerInterface $urlShortener;
private array $domainConfig;
private int $defaultShortCodeLength;
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength)
{
parent::__construct();
$this->urlShortener = $urlShortener;
$this->domainConfig = $domainConfig;
$this->defaultShortCodeLength = $defaultShortCodeLength;
}
protected function configure(): void
@@ -87,6 +89,12 @@ class GenerateShortUrlCommand extends Command
'd',
InputOption::VALUE_REQUIRED,
'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).',
);
}
@@ -117,6 +125,7 @@ class GenerateShortUrlCommand extends Command
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('customSlug');
$maxVisits = $input->getOption('maxVisits');
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
try {
$shortUrl = $this->urlShortener->urlToShortCode(
@@ -129,6 +138,7 @@ class GenerateShortUrlCommand extends Command
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
]),
);

View File

@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -76,7 +77,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
$rowData = $visit->jsonSerialize();
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
});
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Exception;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
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\VisitLocation;
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\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
@@ -27,11 +29,11 @@ use Throwable;
use function sprintf;
class LocateVisitsCommand extends AbstractLockedCommand
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
{
public const NAME = 'visit:locate';
private VisitServiceInterface $visitService;
private VisitLocatorInterface $visitLocator;
private IpLocationResolverInterface $ipLocationResolver;
private GeolocationDbUpdaterInterface $dbUpdater;
@@ -39,13 +41,13 @@ class LocateVisitsCommand extends AbstractLockedCommand
private ?ProgressBar $progressBar = null;
public function __construct(
VisitServiceInterface $visitService,
VisitLocatorInterface $visitLocator,
IpLocationResolverInterface $ipLocationResolver,
LockFactory $locker,
GeolocationDbUpdaterInterface $dbUpdater
) {
parent::__construct($locker);
$this->visitService = $visitService;
$this->visitLocator = $visitLocator;
$this->ipLocationResolver = $ipLocationResolver;
$this->dbUpdater = $dbUpdater;
}
@@ -54,32 +56,79 @@ class LocateVisitsCommand extends AbstractLockedCommand
{
$this
->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
{
$this->io = new SymfonyStyle($input, $output);
$retry = $input->getOption('retry');
$all = $retry && $input->getOption('all');
try {
$this->checkDbUpdate();
$this->visitService->locateUnlocatedVisits(
[$this, 'getGeolocationDataForVisit'],
static function (VisitLocation $location) use ($output): void {
if (!$location->isEmpty()) {
$output->writeln(
sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName()),
);
}
},
);
if ($all) {
$this->visitLocator->locateAllVisits($this);
} else {
$this->visitLocator->locateUnlocatedVisits($this);
if ($retry) {
$this->visitLocator->locateVisitsWithEmptyLocation($this);
}
}
$this->io->success('Finished processing all IPs');
$this->io->success('Finished locating visits');
return ExitCodes::EXIT_SUCCESS;
} catch (Throwable $e) {
$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);
}
@@ -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()) {
$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
{
try {

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
class ConfigProvider
{

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
class CreateDatabaseCommandTest extends TestCase
{
@@ -114,7 +115,8 @@ class CreateDatabaseCommandTest extends TestCase
'/usr/local/bin/php',
CreateDatabaseCommand::DOCTRINE_SCRIPT,
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
], Argument::cetera());
'--no-interaction',
], Argument::cetera())->willReturn(new Process([]));
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
class MigrateDatabaseCommandTest extends TestCase
{
@@ -53,7 +54,8 @@ class MigrateDatabaseCommandTest extends TestCase
'/usr/local/bin/php',
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
], Argument::cetera());
'--no-interaction',
], Argument::cetera())->willReturn(new Process([]));
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();

View File

@@ -31,7 +31,7 @@ class GenerateShortUrlCommandTest extends TestCase
public function setUp(): void
{
$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->add($command);
$this->commandTester = new CommandTester($command);

View File

@@ -15,18 +15,21 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
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\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock;
use function array_shift;
use function sprintf;
use const PHP_EOL;
class LocateVisitsCommandTest extends TestCase
{
private CommandTester $commandTester;
@@ -38,7 +41,7 @@ class LocateVisitsCommandTest extends TestCase
public function setUp(): void
{
$this->visitService = $this->prophesize(VisitService::class);
$this->visitService = $this->prophesize(VisitLocator::class);
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
@@ -61,31 +64,53 @@ class LocateVisitsCommandTest extends TestCase
$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'));
$location = new VisitLocation(Location::emptyInstance());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location): void {
$firstCallback = array_shift($args);
$firstCallback($visit);
$secondCallback = array_shift($args);
$secondCallback($location, $visit);
},
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
$locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will(
$mockMethodBehavior,
);
$locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
Location::emptyInstance(),
);
$this->commandTester->execute([]);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute($args);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
if ($expectWarningPrint) {
$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());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location): void {
$firstCallback = array_shift($args);
$firstCallback($visit);
$secondCallback = array_shift($args);
$secondCallback($location, $visit);
},
$this->invokeHelperMethods($visit, $location),
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
Location::emptyInstance(),
@@ -137,13 +156,7 @@ class LocateVisitsCommandTest extends TestCase
$location = new VisitLocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location): void {
$firstCallback = array_shift($args);
$firstCallback($visit);
$secondCallback = array_shift($args);
$secondCallback($location, $visit);
},
$this->invokeHelperMethods($visit, $location),
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
@@ -156,6 +169,17 @@ class LocateVisitsCommandTest extends TestCase
$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 */
public function noActionIsPerformedIfLockIsAcquired(): void
{
@@ -212,4 +236,33 @@ class LocateVisitsCommandTest extends TestCase
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
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]];
}
}

View File

@@ -4,11 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Doctrine\Common\Cache\Cache;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Mezzio\Router\RouterInterface;
use Mezzio\Template\TemplateRendererInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\Domain\Resolver;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
@@ -27,7 +26,7 @@ return [
Service\UrlShortener::class => ConfigAbstractFactory::class,
Service\VisitsTracker::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\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
@@ -38,7 +37,7 @@ return [
Action\PixelAction::class => ConfigAbstractFactory::class,
Action\QrCodeAction::class => ConfigAbstractFactory::class,
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
],
],
@@ -51,10 +50,10 @@ return [
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
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\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class],
Service\VisitService::class => ['em'],
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
Visit\VisitLocator::class => ['em'],
Service\Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => [
'em',
@@ -63,7 +62,7 @@ return [
],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
Util\UrlValidator::class => ['httpClient'],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
@@ -78,12 +77,12 @@ return [
'Logger_Shlink',
],
Action\QrCodeAction::class => [
RouterInterface::class,
Service\ShortUrl\ShortUrlResolver::class,
'config.url_shortener.domain',
'Logger_Shlink',
],
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
Resolver\PersistenceDomainResolver::class => ['em'],
],
];

View File

@@ -44,4 +44,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->columnName('lon')
->nullable(false)
->build();
$builder->createField('isEmpty', Types::BOOLEAN)
->columnName('is_empty')
->option('default', false)
->nullable(false)
->build();
};

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Core\Action;
use Shlinkio\Shlink\Core\Middleware;
return [
@@ -32,7 +31,6 @@ return [
'name' => Action\QrCodeAction::class,
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
'middleware' => [
Middleware\QrCodeCacheMiddleware::class,
Action\QrCodeAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],

View File

@@ -10,7 +10,11 @@ use PUGX\Shortid\Factory as ShortIdFactory;
use function sprintf;
function generateRandomShortCode(int $length = 5): string
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;
if ($shortIdFactory === null) {

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Fig\Http\Message\RequestMethodInterface;
use Laminas\Diactoros\Uri;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
@@ -24,7 +26,7 @@ use function array_merge;
use function GuzzleHttp\Psr7\build_query;
use function GuzzleHttp\Psr7\parse_query;
abstract class AbstractTrackingAction implements MiddlewareInterface
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
{
private ShortUrlResolverInterface $urlResolver;
private VisitsTrackerInterface $visitTracker;
@@ -50,14 +52,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
$disableTrackParam = $this->appOptions->getDisableTrackParam();
try {
$url = $this->urlResolver->resolveEnabledShortUrl($identifier);
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
// Track visit to this short code
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {
$this->visitTracker->track($url, Visitor::fromRequest($request));
if ($this->shouldTrackRequest($request, $query, $disableTrackParam)) {
$this->visitTracker->track($shortUrl, Visitor::fromRequest($request));
}
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
return $this->createSuccessResp($this->buildUrlToRedirectTo($shortUrl, $query, $disableTrackParam));
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
return $this->createErrorResp($request, $handler);
@@ -76,6 +77,16 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
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 createErrorResp(

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\QrCode;
use Mezzio\Router\RouterInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
@@ -23,17 +22,17 @@ class QrCodeAction implements MiddlewareInterface
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
private RouterInterface $router;
private ShortUrlResolverInterface $urlResolver;
private array $domainConfig;
private LoggerInterface $logger;
public function __construct(
RouterInterface $router,
ShortUrlResolverInterface $urlResolver,
array $domainConfig,
?LoggerInterface $logger = null
) {
$this->router = $router;
$this->urlResolver = $urlResolver;
$this->domainConfig = $domainConfig;
$this->logger = $logger ?: new NullLogger();
}
@@ -42,23 +41,19 @@ class QrCodeAction implements MiddlewareInterface
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
try {
$this->urlResolver->resolveEnabledShortUrl($identifier);
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
return $handler->handle($request);
}
$path = $this->router->generateUri(RedirectAction::class, ['shortCode' => $identifier->shortCode()]);
$size = $this->getSizeParam($request);
$qrCode = new QrCode((string) $request->getUri()->withPath($path)->withQuery(''));
$qrCode->setSize($size);
$qrCode = new QrCode($shortUrl->toString($this->domainConfig));
$qrCode->setSize($this->getSizeParam($request));
$qrCode->setMargin(0);
return new QrCodeResponse($qrCode);
}
/**
*/
private function getSizeParam(Request $request): int
{
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use Laminas\Stdlib\ArrayUtils;
use Shlinkio\Shlink\Installer\Util\PathCollection;
use Shlinkio\Shlink\Config\Collection\PathCollection;
use function array_flip;
use function array_intersect_key;
@@ -24,7 +24,7 @@ class SimplifiedConfigParser
'validate_url' => ['url_shortener', 'validate_url'],
'invalid_short_url_redirect_to' => ['not_found_redirects', 'invalid_short_url'],
'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'],
'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
'redis_servers' => ['cache', 'redis', 'servers'],
@@ -32,6 +32,7 @@ class SimplifiedConfigParser
'web_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'worker_num'],
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
];
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
'delete_short_url_threshold' => [

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
class ConfigProvider
{

View File

@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use function array_reduce;
@@ -32,8 +33,9 @@ class ShortUrl extends AbstractEntity
private ?Chronos $validSince = null;
private ?Chronos $validUntil = null;
private ?int $maxVisits = null;
private ?Domain $domain;
private ?Domain $domain = null;
private bool $customSlugWasProvided;
private int $shortCodeLength;
public function __construct(
string $longUrl,
@@ -50,7 +52,8 @@ class ShortUrl extends AbstractEntity
$this->validUntil = $meta->getValidUntil();
$this->maxVisits = $meta->getMaxVisits();
$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());
}
@@ -91,16 +94,19 @@ class ShortUrl extends AbstractEntity
return $this;
}
public function updateMeta(ShortUrlMeta $shortCodeMeta): void
public function update(ShortUrlEdit $shortUrlEdit): void
{
if ($shortCodeMeta->hasValidSince()) {
$this->validSince = $shortCodeMeta->getValidSince();
if ($shortUrlEdit->hasValidSince()) {
$this->validSince = $shortUrlEdit->validSince();
}
if ($shortCodeMeta->hasValidUntil()) {
$this->validUntil = $shortCodeMeta->getValidUntil();
if ($shortUrlEdit->hasValidUntil()) {
$this->validUntil = $shortUrlEdit->validUntil();
}
if ($shortCodeMeta->hasMaxVisits()) {
$this->maxVisits = $shortCodeMeta->getMaxVisits();
if ($shortUrlEdit->hasMaxVisits()) {
$this->maxVisits = $shortUrlEdit->maxVisits();
}
if ($shortUrlEdit->hasLongUrl()) {
$this->longUrl = $shortUrlEdit->longUrl();
}
}
@@ -119,7 +125,7 @@ class ShortUrl extends AbstractEntity
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
}
$this->shortCode = generateRandomShortCode();
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
return $this;
}

View File

@@ -10,7 +10,6 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
class Visit extends AbstractEntity implements JsonSerializable
@@ -60,9 +59,9 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->shortUrl;
}
public function getVisitLocation(): VisitLocationInterface
public function getVisitLocation(): ?VisitLocationInterface
{
return $this->visitLocation ?? new UnknownVisitLocation();
return $this->visitLocation;
}
public function isLocatable(): bool

View File

@@ -17,6 +17,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
private float $latitude;
private float $longitude;
private string $timezone;
private bool $isEmpty;
public function __construct(Location $location)
{
@@ -43,6 +44,11 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
return $this->cityName;
}
public function isEmpty(): bool
{
return $this->isEmpty;
}
private function exchangeLocationInfo(Location $info): void
{
$this->countryCode = $info->countryCode();
@@ -52,6 +58,15 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
$this->latitude = $info->latitude();
$this->longitude = $info->longitude();
$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
@@ -64,18 +79,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
'latitude' => $this->latitude,
'longitude' => $this->longitude,
'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 === '';
}
}

View File

@@ -9,6 +9,7 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManager;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
@@ -41,22 +42,35 @@ class LocateShortUrlVisit
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();
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
try {
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
$this->locateVisit($visitId, $visit);
}
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
$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
@@ -80,12 +94,13 @@ class LocateShortUrlVisit
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 {
$location = $visit->isLocatable()
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
: Location::emptyInstance();
$location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance();
$visit->locate(new VisitLocation($location));
$this->em->flush();

View File

@@ -9,10 +9,12 @@ use JsonSerializable;
final class ShortUrlVisited implements JsonSerializable
{
private string $visitId;
private ?string $originalIpAddress;
public function __construct(string $visitId)
public function __construct(string $visitId, ?string $originalIpAddress = null)
{
$this->visitId = $visitId;
$this->originalIpAddress = $originalIpAddress;
}
public function visitId(): string
@@ -20,8 +22,13 @@ final class ShortUrlVisited implements JsonSerializable
return $this->visitId;
}
public function originalIpAddress(): ?string
{
return $this->originalIpAddress;
}
public function jsonSerialize(): array
{
return ['visitId' => $this->visitId];
return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress];
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -8,22 +8,21 @@ 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;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
final class ShortUrlMeta
{
private bool $validSincePropWasProvided = false;
private ?Chronos $validSince = null;
private bool $validUntilPropWasProvided = false;
private ?Chronos $validUntil = null;
private ?string $customSlug = null;
private bool $maxVisitsPropWasProvided = false;
private ?int $maxVisits = null;
private ?bool $findIfExists = null;
private ?string $domain = null;
private int $shortCodeLength = 5;
// Force named constructors
// Enforce named constructors
private function __construct()
{
}
@@ -54,15 +53,21 @@ final class ShortUrlMeta
}
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null;
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
$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
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (int) $value : null;
}
public function getValidSince(): ?Chronos
@@ -72,7 +77,7 @@ final class ShortUrlMeta
public function hasValidSince(): bool
{
return $this->validSincePropWasProvided;
return $this->validSince !== null;
}
public function getValidUntil(): ?Chronos
@@ -82,7 +87,7 @@ final class ShortUrlMeta
public function hasValidUntil(): bool
{
return $this->validUntilPropWasProvided;
return $this->validUntil !== null;
}
public function getCustomSlug(): ?string
@@ -102,7 +107,7 @@ final class ShortUrlMeta
public function hasMaxVisits(): bool
{
return $this->maxVisitsPropWasProvided;
return $this->maxVisits !== null;
}
public function findIfExists(): bool
@@ -119,4 +124,9 @@ final class ShortUrlMeta
{
return $this->domain;
}
public function getShortCodeLength(): int
{
return $this->shortCodeLength;
}
}

View File

@@ -5,14 +5,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use function sprintf;
class AppOptions extends AbstractOptions
{
use StringUtilsTrait;
private string $name = '';
private string $version = '1.0';
private ?string $disableTrackParam = null;

View File

@@ -12,33 +12,63 @@ use Shlinkio\Shlink\Core\Entity\Visit;
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[]
*/
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
SELECT v FROM Shlinkio\Shlink\Core\Entity\Visit AS v WHERE v.visitLocation IS NULL
DQL;
$query = $this->getEntityManager()->createQuery($dql)
->setMaxResults($blockSize);
$remainingVisitsToProcess = $this->count(['visitLocation' => null]);
$offset = 0;
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('v')
->from(Visit::class, 'v')
->where($qb->expr()->isNull('v.visitLocation'));
while ($remainingVisitsToProcess > 0) {
$iterator = $query->setFirstResult($applyOffset ? $offset : null)->iterate();
foreach ($iterator as $key => [$value]) {
yield $key => $value;
return $this->findVisitsForQuery($qb, $blockSize);
}
/**
* @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;
$offset += $blockSize;
}
// As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
$lastId = isset($visit) ? $visit->getId() : $lastId;
} while ($resultsFound);
}
/**

View File

@@ -13,15 +13,19 @@ interface VisitRepositoryInterface extends ObjectRepository
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[]
*/
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[]

View File

@@ -7,14 +7,16 @@ namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM;
use Laminas\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
class ShortUrlService implements ShortUrlServiceInterface
{
@@ -22,11 +24,16 @@ class ShortUrlService implements ShortUrlServiceInterface
private ORM\EntityManagerInterface $em;
private ShortUrlResolverInterface $urlResolver;
private UrlValidatorInterface $urlValidator;
public function __construct(ORM\EntityManagerInterface $em, ShortUrlResolverInterface $urlResolver)
{
public function __construct(
ORM\EntityManagerInterface $em,
ShortUrlResolverInterface $urlResolver,
UrlValidatorInterface $urlValidator
) {
$this->em = $em;
$this->urlResolver = $urlResolver;
$this->urlValidator = $urlValidator;
}
/**
@@ -59,11 +66,16 @@ class ShortUrlService implements ShortUrlServiceInterface
/**
* @throws ShortUrlNotFoundException
* @throws InvalidUrlException
*/
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl
{
if ($shortUrlEdit->hasLongUrl()) {
$this->urlValidator->validateUrl($shortUrlEdit->longUrl());
}
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
$shortUrl->updateMeta($shortUrlMeta);
$shortUrl->update($shortUrlEdit);
$this->em->flush();

View File

@@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\Core\Service;
use Laminas\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
interface ShortUrlServiceInterface
@@ -26,6 +27,7 @@ interface ShortUrlServiceInterface
/**
* @throws ShortUrlNotFoundException
* @throws InvalidUrlException
*/
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl;
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl;
}

View File

@@ -6,12 +6,11 @@ namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
@@ -24,17 +23,17 @@ class UrlShortener implements UrlShortenerInterface
use TagManagerTrait;
private EntityManagerInterface $em;
private UrlShortenerOptions $options;
private UrlValidatorInterface $urlValidator;
private DomainResolverInterface $domainResolver;
public function __construct(
UrlValidatorInterface $urlValidator,
EntityManagerInterface $em,
UrlShortenerOptions $options
DomainResolverInterface $domainResolver
) {
$this->urlValidator = $urlValidator;
$this->em = $em;
$this->options = $options;
$this->domainResolver = $domainResolver;
}
/**
@@ -53,13 +52,9 @@ class UrlShortener implements UrlShortenerInterface
return $existingShortUrl;
}
// If the URL validation is enabled, check that the URL actually exists
if ($this->options->isUrlValidationEnabled()) {
$this->urlValidator->validateUrl($url);
}
$this->urlValidator->validateUrl($url);
$this->em->beginTransaction();
$shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em));
$shortUrl = new ShortUrl($url, $meta, $this->domainResolver);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
try {

View File

@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
class VisitService implements VisitServiceInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function locateUnlocatedVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void
{
/** @var VisitRepository $repo */
$repo = $this->em->getRepository(Visit::class);
$results = $repo->findUnlocatedVisits(false);
$count = 0;
$persistBlock = 200;
foreach ($results as $visit) {
$count++;
try {
/** @var Location $location */
$location = $geolocateVisit($visit);
} catch (IpCannotBeLocatedException $e) {
if (! $e->isNonLocatableAddress()) {
// Skip if the visit's IP could not be located because of an error
continue;
}
// If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again
$location = Location::emptyInstance();
}
$location = new VisitLocation($location);
$this->locateVisit($visit, $location, $notifyVisitWithLocation);
// Flush and clear after X iterations
if ($count % $persistBlock === 0) {
$this->em->flush();
$this->em->clear();
}
}
$this->em->flush();
$this->em->clear();
}
private function locateVisit(Visit $visit, VisitLocation $location, ?callable $notifyVisitWithLocation): void
{
$visit->locate($location);
$this->em->persist($visit);
if ($notifyVisitWithLocation !== null) {
$notifyVisitWithLocation($location, $visit);
}
}
}

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
interface VisitServiceInterface
{
public function locateUnlocatedVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void;
}

View File

@@ -39,7 +39,7 @@ class VisitsTracker implements VisitsTrackerInterface
$this->em->persist($visit);
$this->em->flush();
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId()));
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress()));
}
/**

View File

@@ -9,16 +9,19 @@ use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
{
private const MAX_REDIRECTS = 15;
private ClientInterface $httpClient;
private UrlShortenerOptions $options;
public function __construct(ClientInterface $httpClient)
public function __construct(ClientInterface $httpClient, UrlShortenerOptions $options)
{
$this->httpClient = $httpClient;
$this->options = $options;
}
/**
@@ -26,6 +29,11 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
*/
public function validateUrl(string $url): void
{
// If the URL validation is not enabled, skip check
if (! $this->options->isUrlValidationEnabled()) {
return;
}
try {
$this->httpClient->request(self::METHOD_GET, $url, [
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],

View File

@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use DateTime;
use Laminas\InputFilter\Input;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation;
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
class ShortUrlMetaInputFilter extends InputFilter
{
use Validation\InputFactoryTrait;
@@ -19,6 +22,8 @@ class ShortUrlMetaInputFilter extends InputFilter
public const MAX_VISITS = 'maxVisits';
public const FIND_IF_EXISTS = 'findIfExists';
public const DOMAIN = 'domain';
public const SHORT_CODE_LENGTH = 'shortCodeLength';
public const LONG_URL = 'longUrl';
public function __construct(array $data)
{
@@ -28,6 +33,8 @@ class ShortUrlMetaInputFilter extends InputFilter
private function initialize(): void
{
$this->add($this->createInput(self::LONG_URL, false));
$validSince = $this->createInput(self::VALID_SINCE, false);
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
$this->add($validSince);
@@ -36,14 +43,18 @@ class ShortUrlMetaInputFilter extends InputFilter
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
$this->add($validUntil);
$customSlug = $this->createInput(self::CUSTOM_SLUG, false);
// FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's
// empty, is by using the deprecated setContinueIfEmpty
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter());
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
Validator\NotEmpty::STRING,
Validator\NotEmpty::SPACE,
]));
$this->add($customSlug);
$maxVisits = $this->createInput(self::MAX_VISITS, false);
$maxVisits->getValidatorChain()->attach(new Validator\Digits())
->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true]));
$this->add($maxVisits);
$this->add($this->createPositiveNumberInput(self::MAX_VISITS));
$this->add($this->createPositiveNumberInput(self::SHORT_CODE_LENGTH, MIN_SHORT_CODES_LENGTH));
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
@@ -51,4 +62,13 @@ class ShortUrlMetaInputFilter extends InputFilter
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$this->add($domain);
}
private function createPositiveNumberInput(string $name, int $min = 1): Input
{
$input = $this->createInput($name, false);
$input->getValidatorChain()->attach(new Validator\Digits())
->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true]));
return $input;
}
}

View File

@@ -39,7 +39,7 @@ class ShortUrlsParamsInputFilter extends InputFilter
$tags = $this->createArrayInput(self::TAGS, false);
$tags->getFilterChain()->attach(new Filter\StringToLower())
->attach(new Validation\SluggerFilter());
->attach(new Filter\PregReplace(['pattern' => '/ /', 'replacement' => '-']));
$this->add($tags);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
interface VisitGeolocationHelperInterface
{
/**
* @throws IpCannotBeLocatedException
*/
public function geolocateVisit(Visit $visit): Location;
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void;
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
class VisitLocator implements VisitLocatorInterface
{
private EntityManagerInterface $em;
private VisitRepositoryInterface $repo;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
/** @var VisitRepositoryInterface $repo */
$repo = $em->getRepository(Visit::class);
$this->repo = $repo;
}
public function locateUnlocatedVisits(VisitGeolocationHelperInterface $helper): void
{
$this->locateVisits($this->repo->findUnlocatedVisits(), $helper);
}
public function locateVisitsWithEmptyLocation(VisitGeolocationHelperInterface $helper): void
{
$this->locateVisits($this->repo->findVisitsWithEmptyLocation(), $helper);
}
public function locateAllVisits(VisitGeolocationHelperInterface $helper): void
{
$this->locateVisits($this->repo->findAllVisits(), $helper);
}
/**
* @param iterable|Visit[] $results
*/
private function locateVisits(iterable $results, VisitGeolocationHelperInterface $helper): void
{
$count = 0;
$persistBlock = 200;
foreach ($results as $visit) {
$count++;
try {
$location = $helper->geolocateVisit($visit);
} catch (IpCannotBeLocatedException $e) {
if (! $e->isNonLocatableAddress()) {
// Skip if the visit's IP could not be located because of an error
continue;
}
// If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again
$location = Location::emptyInstance();
}
$location = new VisitLocation($location);
$this->locateVisit($visit, $location, $helper);
// Flush and clear after X iterations
if ($count % $persistBlock === 0) {
$this->em->flush();
$this->em->clear();
}
}
$this->em->flush();
$this->em->clear();
}
private function locateVisit(Visit $visit, VisitLocation $location, VisitGeolocationHelperInterface $helper): void
{
$prevLocation = $visit->getVisitLocation();
$visit->locate($location);
$this->em->persist($visit);
// In order to avoid leaving orphan locations, remove the previous one
if ($prevLocation !== null) {
$this->em->remove($prevLocation);
}
$helper->onVisitLocated($location, $visit);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
interface VisitLocatorInterface
{
public function locateUnlocatedVisits(VisitGeolocationHelperInterface $helper): void;
public function locateVisitsWithEmptyLocation(VisitGeolocationHelperInterface $helper): void;
public function locateAllVisits(VisitGeolocationHelperInterface $helper): void;
}

View File

@@ -40,15 +40,23 @@ class VisitRepositoryTest extends DatabaseTestCase
* @test
* @dataProvider provideBlockSize
*/
public function findUnlocatedVisitsReturnsProperVisits(int $blockSize): void
public function findVisitsReturnsProperVisits(int $blockSize): void
{
$shortUrl = new ShortUrl('');
$this->getEntityManager()->persist($shortUrl);
$countIterable = function (iterable $results): int {
$resultsCount = 0;
foreach ($results as $value) {
$resultsCount++;
}
return $resultsCount;
};
for ($i = 0; $i < 6; $i++) {
$visit = new Visit($shortUrl, Visitor::emptyInstance());
if ($i % 2 === 0) {
if ($i >= 2) {
$location = new VisitLocation(Location::emptyInstance());
$this->getEntityManager()->persist($location);
$visit->locate($location);
@@ -58,18 +66,20 @@ class VisitRepositoryTest extends DatabaseTestCase
}
$this->getEntityManager()->flush();
$resultsCount = 0;
$results = $this->repo->findUnlocatedVisits(true, $blockSize);
foreach ($results as $value) {
$resultsCount++;
}
$withEmptyLocation = $this->repo->findVisitsWithEmptyLocation($blockSize);
$unlocated = $this->repo->findUnlocatedVisits($blockSize);
$all = $this->repo->findAllVisits($blockSize);
$this->assertEquals(3, $resultsCount);
// Important! assertCount will not work here, as this iterable object loads data dynamically and the count
// is 0 if not iterated
$this->assertEquals(2, $countIterable($unlocated));
$this->assertEquals(4, $countIterable($withEmptyLocation));
$this->assertEquals(6, $countIterable($all));
}
public function provideBlockSize(): iterable
{
return map(range(1, 5), fn (int $value) => [$value]);
return map(range(1, 10), fn (int $value) => [$value]);
}
/** @test */

View File

@@ -30,7 +30,7 @@ class QrCodeActionTest extends TestCase
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->action = new QrCodeAction($router->reveal(), $this->urlResolver->reveal());
$this->action = new QrCodeAction($this->urlResolver->reveal(), ['domain' => 'doma.in']);
}
/** @test */

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Action;
use Fig\Http\Message\RequestMethodInterface;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
@@ -89,4 +91,23 @@ class RedirectActionTest extends TestCase
$handle->shouldHaveBeenCalledOnce();
}
/** @test */
public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void
{
$shortCode = 'abc123';
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl);
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
});
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)
->withAttribute(
ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE,
RequestMethodInterface::METHOD_HEAD,
);
$this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
$track->shouldNotHaveBeenCalled();
}
}

View File

@@ -41,6 +41,8 @@ class SimplifiedConfigParserTest extends TestCase
'validate_url' => true,
'delete_short_url_threshold' => 50,
'invalid_short_url_redirect_to' => 'foobar.com',
'regular_404_redirect_to' => 'bar.com',
'base_url_redirect_to' => 'foo.com',
'redis_servers' => [
'tcp://1.1.1.1:1111',
'tcp://1.2.2.2:2222',
@@ -57,6 +59,7 @@ class SimplifiedConfigParserTest extends TestCase
'http://my-api.com/api/v2.3/notify',
'https://third-party.io/foo',
],
'default_short_codes_length' => 8,
];
$expected = [
'app_options' => [
@@ -84,6 +87,7 @@ class SimplifiedConfigParserTest extends TestCase
'http://my-api.com/api/v2.3/notify',
'https://third-party.io/foo',
],
'default_short_codes_length' => 8,
],
'delete_short_urls' => [
@@ -112,6 +116,8 @@ class SimplifiedConfigParserTest extends TestCase
'not_found_redirects' => [
'invalid_short_url' => 'foobar.com',
'regular_404' => 'bar.com',
'base_url' => 'foo.com',
],
'mezzio-swoole' => [

View File

@@ -8,6 +8,13 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function Functional\map;
use function range;
use function strlen;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
class ShortUrlTest extends TestCase
{
@@ -48,4 +55,23 @@ class ShortUrlTest extends TestCase
$this->assertNotEquals($firstShortCode, $secondShortCode);
}
/**
* @test
* @dataProvider provideLengths
*/
public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void
{
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(
[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length],
));
$this->assertEquals($expectedLength, strlen($shortUrl->getShortCode()));
}
public function provideLengths(): iterable
{
yield [null, DEFAULT_SHORT_CODES_LENGTH];
yield from map(range(4, 10), fn (int $value) => [$value, $value]);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -20,7 +21,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
@@ -38,6 +38,10 @@ class LocateShortUrlVisitTest extends TestCase
{
$this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class);
$conn = $this->prophesize(Connection::class);
$this->em->getConnection()->willReturn($conn->reveal());
$this->em->clear()->will(function (): void {
});
$this->logger = $this->prophesize(LoggerInterface::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
@@ -130,13 +134,16 @@ class LocateShortUrlVisitTest extends TestCase
yield 'localhost' => [new Visit($shortUrl, new Visitor('', '', IpAddress::LOCALHOST))];
}
/** @test */
public function locatableVisitsResolveToLocation(): void
/**
* @test
* @dataProvider provideIpAddresses
*/
public function locatableVisitsResolveToLocation(string $anonymizedIpAddress, ?string $originalIpAddress): void
{
$ipAddr = '1.2.3.0';
$ipAddr = $originalIpAddress ?? $anonymizedIpAddress;
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr));
$location = new Location('', '', '', '', 0.0, 0.0, '');
$event = new ShortUrlVisited('123');
$event = new ShortUrlVisited('123', $originalIpAddress);
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
$flush = $this->em->flush()->will(function (): void {
@@ -155,6 +162,12 @@ class LocateShortUrlVisitTest extends TestCase
$dispatch->shouldHaveBeenCalledOnce();
}
public function provideIpAddresses(): iterable
{
yield 'no original IP address' => ['1.2.3.0', null];
yield 'original IP address' => ['1.2.3.0', '1.2.3.4'];
}
/** @test */
public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void
{
@@ -209,7 +222,7 @@ class LocateShortUrlVisitTest extends TestCase
($this->locateVisit)($event);
$this->assertEquals($visit->getVisitLocation(), new UnknownVisitLocation());
$this->assertNull($visit->getVisitLocation());
$findVisit->shouldHaveBeenCalledOnce();
$flush->shouldNotHaveBeenCalled();
$resolveIp->shouldNotHaveBeenCalled();

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Middleware;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\Uri;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Middleware\QrCodeCacheMiddleware;
class QrCodeCacheMiddlewareTest extends TestCase
{
private QrCodeCacheMiddleware $middleware;
private Cache $cache;
public function setUp(): void
{
$this->cache = new ArrayCache();
$this->middleware = new QrCodeCacheMiddleware($this->cache);
}
/** @test */
public function noCachedPathFallsBackToNextMiddleware(): void
{
$delegate = $this->prophesize(RequestHandlerInterface::class);
$delegate->handle(Argument::any())->willReturn(new Response())->shouldBeCalledOnce();
$this->middleware->process((new ServerRequest())->withUri(new Uri('/foo/bar')), $delegate->reveal());
$this->assertTrue($this->cache->contains('/foo/bar'));
}
/** @test */
public function cachedPathReturnsCacheContent(): void
{
$isCalled = false;
$uri = (new Uri())->withPath('/foo');
$this->cache->save('/foo', ['body' => 'the body', 'content-type' => 'image/png']);
$delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->middleware->process((new ServerRequest())->withUri($uri), $delegate->reveal());
$this->assertFalse($isCalled);
$resp->getBody()->rewind();
$this->assertEquals('the body', $resp->getBody()->getContents());
$this->assertEquals('image/png', $resp->getHeaderLine('Content-Type'));
$delegate->handle(Argument::any())->shouldHaveBeenCalledTimes(0);
}
}

View File

@@ -44,6 +44,18 @@ class ShortUrlMetaTest extends TestCase
ShortUrlMetaInputFilter::VALID_UNTIL => 500,
ShortUrlMetaInputFilter::DOMAIN => 4,
]];
yield [[
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 3,
]];
yield [[
ShortUrlMetaInputFilter::CUSTOM_SLUG => '/',
]];
yield [[
ShortUrlMetaInputFilter::CUSTOM_SLUG => '',
]];
yield [[
ShortUrlMetaInputFilter::CUSTOM_SLUG => ' ',
]];
}
/** @test */

View File

@@ -5,16 +5,15 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Model;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Core\Model\Visitor;
use function random_int;
use function str_repeat;
use function strlen;
use function substr;
class VisitorTest extends TestCase
{
use StringUtilsTrait;
/**
* @test
* @dataProvider provideParams
@@ -60,4 +59,15 @@ class VisitorTest extends TestCase
],
];
}
private function generateRandomString(int $length): string
{
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[random_int(0, $charactersLength - 1)];
}
return $randomString;
}
}

View File

@@ -12,12 +12,13 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use function count;
@@ -26,6 +27,7 @@ class ShortUrlServiceTest extends TestCase
private ShortUrlService $service;
private ObjectProphecy $em;
private ObjectProphecy $urlResolver;
private ObjectProphecy $urlValidator;
public function setUp(): void
{
@@ -34,8 +36,13 @@ class ShortUrlServiceTest extends TestCase
$this->em->flush()->willReturn(null);
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
$this->service = new ShortUrlService($this->em->reveal(), $this->urlResolver->reveal());
$this->service = new ShortUrlService(
$this->em->reveal(),
$this->urlResolver->reveal(),
$this->urlValidator->reveal(),
);
}
/** @test */
@@ -74,27 +81,47 @@ class ShortUrlServiceTest extends TestCase
$this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar']);
}
/** @test */
public function updateMetadataByShortCodeUpdatesProvidedData(): void
{
$shortUrl = new ShortUrl('');
/**
* @test
* @dataProvider provideShortUrlEdits
*/
public function updateMetadataByShortCodeUpdatesProvidedData(
int $expectedValidateCalls,
ShortUrlEdit $shortUrlEdit
): void {
$originalLongUrl = 'originalLongUrl';
$shortUrl = new ShortUrl($originalLongUrl);
$findShortUrl = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier('abc123'))->willReturn($shortUrl);
$flush = $this->em->flush()->willReturn(null);
$result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), ShortUrlMeta::fromRawData(
$result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit);
$this->assertSame($shortUrl, $result);
$this->assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince());
$this->assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil());
$this->assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits());
$this->assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl());
$findShortUrl->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled();
$this->urlValidator->validateUrl($shortUrlEdit->longUrl())->shouldHaveBeenCalledTimes($expectedValidateCalls);
}
public function provideShortUrlEdits(): iterable
{
yield 'no long URL' => [0, ShortUrlEdit::fromRawData(
[
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
'maxVisits' => 5,
],
));
$this->assertSame($shortUrl, $result);
$this->assertEquals(Chronos::parse('2017-01-01 00:00:00'), $shortUrl->getValidSince());
$this->assertEquals(Chronos::parse('2017-01-05 00:00:00'), $shortUrl->getValidUntil());
$this->assertEquals(5, $shortUrl->getMaxVisits());
$findShortUrl->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled();
)];
yield 'long URL' => [1, ShortUrlEdit::fromRawData(
[
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
'maxVisits' => 10,
'longUrl' => 'modifiedLongUrl',
],
)];
}
}

View File

@@ -13,11 +13,11 @@ use Laminas\Diactoros\Uri;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
@@ -33,6 +33,10 @@ class UrlShortenerTest extends TestCase
public function setUp(): void
{
$this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
$this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will(
function (): void {
},
);
$this->em = $this->prophesize(EntityManagerInterface::class);
$conn = $this->prophesize(Connection::class);
@@ -50,15 +54,10 @@ class UrlShortenerTest extends TestCase
$repo->shortCodeIsInUse(Argument::cetera())->willReturn(false);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->setUrlShortener(false);
}
private function setUrlShortener(bool $urlValidationEnabled): void
{
$this->urlShortener = new UrlShortener(
$this->urlValidator->reveal(),
$this->em->reveal(),
new UrlShortenerOptions(['validate_url' => $urlValidationEnabled]),
new SimpleDomainResolver(),
);
}
@@ -119,24 +118,6 @@ class UrlShortenerTest extends TestCase
);
}
/** @test */
public function validatorIsCalledWhenUrlValidationIsEnabled(): void
{
$this->setUrlShortener(true);
$validateUrl = $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will(
function (): void {
},
);
$this->urlShortener->urlToShortCode(
new Uri('http://foobar.com/12345/hello?foo=bar'),
[],
ShortUrlMeta::createEmpty(),
);
$validateUrl->shouldHaveBeenCalledOnce();
}
/** @test */
public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void
{
@@ -175,6 +156,7 @@ class UrlShortenerTest extends TestCase
$findExisting->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
$this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled();
$this->urlValidator->validateUrl(Argument::cetera())->shouldNotHaveBeenCalled();
$this->assertSame($expected, $result);
}

View File

@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service;
use Doctrine\ORM\EntityManager;
use Exception;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Service\VisitService;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use function array_shift;
use function count;
use function floor;
use function func_get_args;
use function Functional\map;
use function range;
use function sprintf;
class VisitServiceTest extends TestCase
{
private VisitService $visitService;
private ObjectProphecy $em;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManager::class);
$this->visitService = new VisitService($this->em->reveal());
}
/** @test */
public function locateVisitsIteratesAndLocatesUnlocatedVisits(): void
{
$unlocatedVisits = map(
range(1, 200),
fn (int $i) => new Visit(new ShortUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()),
);
$repo = $this->prophesize(VisitRepository::class);
$findUnlocatedVisits = $repo->findUnlocatedVisits(false)->willReturn($unlocatedVisits);
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
$persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void {
});
$flush = $this->em->flush()->will(function (): void {
});
$clear = $this->em->clear()->will(function (): void {
});
$this->visitService->locateUnlocatedVisits(fn () => Location::emptyInstance(), function (): void {
$args = func_get_args();
$this->assertInstanceOf(VisitLocation::class, array_shift($args));
$this->assertInstanceOf(Visit::class, array_shift($args));
});
$findUnlocatedVisits->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
$persist->shouldHaveBeenCalledTimes(count($unlocatedVisits));
$flush->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1);
$clear->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1);
}
/**
* @test
* @dataProvider provideIsNonLocatableAddress
*/
public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty(bool $isNonLocatableAddress): void
{
$unlocatedVisits = [
new Visit(new ShortUrl('foo'), Visitor::emptyInstance()),
];
$repo = $this->prophesize(VisitRepository::class);
$findUnlocatedVisits = $repo->findUnlocatedVisits(false)->willReturn($unlocatedVisits);
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
$persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void {
});
$flush = $this->em->flush()->will(function (): void {
});
$clear = $this->em->clear()->will(function (): void {
});
$this->visitService->locateUnlocatedVisits(function () use ($isNonLocatableAddress): void {
throw $isNonLocatableAddress
? new IpCannotBeLocatedException('Cannot be located')
: IpCannotBeLocatedException::forError(new Exception(''));
});
$findUnlocatedVisits->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
$persist->shouldHaveBeenCalledTimes($isNonLocatableAddress ? 1 : 0);
$flush->shouldHaveBeenCalledOnce();
$clear->shouldHaveBeenCalledOnce();
}
public function provideIsNonLocatableAddress(): iterable
{
yield 'The address is locatable' => [false];
yield 'The address is non-locatable' => [true];
}
}

View File

@@ -13,17 +13,20 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Util\UrlValidator;
class UrlValidatorTest extends TestCase
{
private UrlValidator $urlValidator;
private ObjectProphecy $httpClient;
private UrlShortenerOptions $options;
public function setUp(): void
{
$this->httpClient = $this->prophesize(ClientInterface::class);
$this->urlValidator = new UrlValidator($this->httpClient->reveal());
$this->options = new UrlShortenerOptions(['validate_url' => true]);
$this->urlValidator = new UrlValidator($this->httpClient->reveal(), $this->options);
}
/** @test */
@@ -52,4 +55,15 @@ class UrlValidatorTest extends TestCase
$request->shouldHaveBeenCalledOnce();
}
/** @test */
public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void
{
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$this->options->validateUrl = false;
$this->urlValidator->validateUrl('');
$request->shouldNotHaveBeenCalled();
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Visit;
use Doctrine\ORM\EntityManager;
use Exception;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\VisitLocator;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use function array_shift;
use function count;
use function floor;
use function func_get_args;
use function Functional\map;
use function range;
use function sprintf;
class VisitLocatorTest extends TestCase
{
private VisitLocator $visitService;
private ObjectProphecy $em;
private ObjectProphecy $repo;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManager::class);
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
$this->em->getRepository(Visit::class)->willReturn($this->repo->reveal());
$this->visitService = new VisitLocator($this->em->reveal());
}
/**
* @test
* @dataProvider provideMethodNames
*/
public function locateVisitsIteratesAndLocatesExpectedVisits(
string $serviceMethodName,
string $expectedRepoMethodName
): void {
$unlocatedVisits = map(
range(1, 200),
fn (int $i) => new Visit(new ShortUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()),
);
$findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits);
$persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void {
});
$flush = $this->em->flush()->will(function (): void {
});
$clear = $this->em->clear()->will(function (): void {
});
$this->visitService->{$serviceMethodName}(new class implements VisitGeolocationHelperInterface {
public function geolocateVisit(Visit $visit): Location
{
return Location::emptyInstance();
}
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
{
$args = func_get_args();
Assert::assertInstanceOf(VisitLocation::class, array_shift($args));
Assert::assertInstanceOf(Visit::class, array_shift($args));
}
});
$findVisits->shouldHaveBeenCalledOnce();
$persist->shouldHaveBeenCalledTimes(count($unlocatedVisits));
$flush->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1);
$clear->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1);
}
public function provideMethodNames(): iterable
{
yield 'locateUnlocatedVisits' => ['locateUnlocatedVisits', 'findUnlocatedVisits'];
yield 'locateVisitsWithEmptyLocation' => ['locateVisitsWithEmptyLocation', 'findVisitsWithEmptyLocation'];
yield 'locateAllVisits' => ['locateAllVisits', 'findAllVisits'];
}
/**
* @test
* @dataProvider provideIsNonLocatableAddress
*/
public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty(
string $serviceMethodName,
string $expectedRepoMethodName,
bool $isNonLocatableAddress
): void {
$unlocatedVisits = [
new Visit(new ShortUrl('foo'), Visitor::emptyInstance()),
];
$findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits);
$persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void {
});
$flush = $this->em->flush()->will(function (): void {
});
$clear = $this->em->clear()->will(function (): void {
});
$this->visitService->{$serviceMethodName}(
new class ($isNonLocatableAddress) implements VisitGeolocationHelperInterface {
private bool $isNonLocatableAddress;
public function __construct(bool $isNonLocatableAddress)
{
$this->isNonLocatableAddress = $isNonLocatableAddress;
}
public function geolocateVisit(Visit $visit): Location
{
throw $this->isNonLocatableAddress
? new IpCannotBeLocatedException('Cannot be located')
: IpCannotBeLocatedException::forError(new Exception(''));
}
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
{
}
},
);
$findVisits->shouldHaveBeenCalledOnce();
$persist->shouldHaveBeenCalledTimes($isNonLocatableAddress ? 1 : 0);
$flush->shouldHaveBeenCalledOnce();
$clear->shouldHaveBeenCalledOnce();
}
public function provideIsNonLocatableAddress(): iterable
{
yield 'locateUnlocatedVisits - locatable address' => ['locateUnlocatedVisits', 'findUnlocatedVisits', false];
yield 'locateUnlocatedVisits - non-locatable address' => ['locateUnlocatedVisits', 'findUnlocatedVisits', true];
yield 'locateVisitsWithEmptyLocation - locatable address' => [
'locateVisitsWithEmptyLocation',
'findVisitsWithEmptyLocation',
false,
];
yield 'locateVisitsWithEmptyLocation - non-locatable address' => [
'locateVisitsWithEmptyLocation',
'findVisitsWithEmptyLocation',
true,
];
yield 'locateAllVisits - locatable address' => ['locateAllVisits', 'findAllVisits', false];
yield 'locateAllVisits - non-locatable address' => ['locateAllVisits', 'findAllVisits', true];
}
private function mockRepoMethod(string $methodName): MethodProphecy
{
return (new MethodProphecy($this->repo, $methodName, new Argument\ArgumentsWildcard([])));
}
}

View File

@@ -37,7 +37,8 @@ return [
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
],
],
@@ -74,7 +75,10 @@ return [
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware::class => ['config.url_shortener.domain.hostname'],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
'config.url_shortener.default_short_codes_length',
],
],
];

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
$contentNegotiationMiddleware = [Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class];
$dropDomainMiddleware = [Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware::class];
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
return [
@@ -13,16 +13,20 @@ return [
Action\HealthAction::getRouteDef(),
// Short codes
Action\ShortUrl\CreateShortUrlAction::getRouteDef($contentNegotiationMiddleware),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef($contentNegotiationMiddleware),
Action\ShortUrl\EditShortUrlAction::getRouteDef($dropDomainMiddleware),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef($dropDomainMiddleware),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef($dropDomainMiddleware),
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef($dropDomainMiddleware),
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]),
// Visits
Action\Visit\GetVisitsAction::getRouteDef($dropDomainMiddleware),
Action\Visit\GetVisitsAction::getRouteDef([$dropDomainMiddleware]),
// Tags
Action\Tag\ListTagsAction::getRouteDef(),

View File

@@ -8,8 +8,8 @@ use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@@ -28,10 +28,10 @@ class EditShortUrlAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
$postData = (array) $request->getParsedBody();
$shortUrlEdit = ShortUrlEdit::fromRawData((array) $request->getParsedBody());
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$this->shortUrlService->updateMetadataByShortCode($identifier, ShortUrlMeta::fromRawData($postData));
$this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit);
return new EmptyResponse();
}
}

View File

@@ -8,7 +8,7 @@ use Closure;
use function Functional\first;
use function Functional\map;
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
use function sprintf;
class ConfigProvider

View File

@@ -5,20 +5,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Entity;
use Cake\Chronos\Chronos;
use Ramsey\Uuid\Uuid;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
class ApiKey extends AbstractEntity
{
use StringUtilsTrait;
private string $key;
private ?Chronos $expirationDate;
private ?Chronos $expirationDate = null;
private bool $enabled;
public function __construct(?Chronos $expirationDate = null)
{
$this->key = $this->generateV4Uuid();
$this->key = Uuid::uuid4()->toString();
$this->expirationDate = $expirationDate;
$this->enabled = true;
}
@@ -30,11 +28,7 @@ class ApiKey extends AbstractEntity
public function isExpired(): bool
{
if ($this->expirationDate === null) {
return false;
}
return $this->expirationDate->lt(Chronos::now());
return $this->expirationDate !== null && $this->expirationDate->lt(Chronos::now());
}
public function isEnabled(): bool

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Middleware\ShortUrl;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
class DefaultShortCodesLengthMiddleware implements MiddlewareInterface
{
private int $defaultShortCodesLength;
public function __construct(int $defaultShortCodesLength)
{
$this->defaultShortCodesLength = $defaultShortCodesLength;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$body = $request->getParsedBody();
if (! isset($body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH])) {
$body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength;
}
return $handler->handle($request->withParsedBody($body));
}
}

View File

@@ -9,7 +9,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class DropDefaultDomainFromQueryMiddleware implements MiddlewareInterface
class DropDefaultDomainFromRequestMiddleware implements MiddlewareInterface
{
private string $defaultDomain;
@@ -20,12 +20,18 @@ class DropDefaultDomainFromQueryMiddleware implements MiddlewareInterface
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$query = $request->getQueryParams();
if (isset($query['domain']) && $query['domain'] === $this->defaultDomain) {
unset($query['domain']);
$request = $request->withQueryParams($query);
}
$request = $request->withQueryParams($this->sanitizeDomainFromPayload($request->getQueryParams()))
->withParsedBody($this->sanitizeDomainFromPayload($request->getParsedBody()));
return $handler->handle($request);
}
private function sanitizeDomainFromPayload(array $payload): array
{
if (isset($payload['domain']) && $payload['domain'] === $this->defaultDomain) {
unset($payload['domain']);
}
return $payload;
}
}

View File

@@ -228,6 +228,22 @@ class CreateShortUrlActionTest extends ApiTestCase
$this->assertEquals($url, $payload['url']);
}
/** @test */
public function defaultDomainIsDroppedIfProvided(): void
{
[$createStatusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([
'longUrl' => 'https://www.alejandrocelaya.com',
'domain' => 'doma.in',
]);
$getResp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode);
$payload = $this->getJsonResponsePayload($getResp);
$this->assertEquals(self::STATUS_OK, $createStatusCode);
$this->assertEquals(self::STATUS_OK, $getResp->getStatusCode());
$this->assertArrayHasKey('domain', $payload);
$this->assertNull($payload['domain']);
}
/**
* @return array {
* @var int $statusCode

View File

@@ -71,6 +71,32 @@ class EditShortUrlActionTest extends ApiTestCase
return $matchingShortUrl['meta'] ?? null;
}
/**
* @test
* @dataProvider provideLongUrls
*/
public function longUrlCanBeEditedIfItIsValid(string $longUrl, int $expectedStatus, ?string $expectedError): void
{
$shortCode = 'abc123';
$url = sprintf('/short-urls/%s', $shortCode);
$resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => [
'longUrl' => $longUrl,
]]);
$this->assertEquals($expectedStatus, $resp->getStatusCode());
if ($expectedError !== null) {
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals($expectedError, $payload['type']);
}
}
public function provideLongUrls(): iterable
{
yield 'valid URL' => ['https://shlink.io', self::STATUS_NO_CONTENT, null];
yield 'invalid URL' => ['htt:foo', self::STATUS_BAD_REQUEST, 'INVALID_URL'];
}
/**
* @test
* @dataProvider provideInvalidUrls

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Middleware\ShortUrl;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DefaultShortCodesLengthMiddleware;
class DefaultShortCodesLengthMiddlewareTest extends TestCase
{
private DefaultShortCodesLengthMiddleware $middleware;
private ObjectProphecy $handler;
public function setUp(): void
{
$this->handler = $this->prophesize(RequestHandlerInterface::class);
$this->middleware = new DefaultShortCodesLengthMiddleware(8);
}
/**
* @test
* @dataProvider provideBodies
*/
public function defaultValueIsInjectedInBodyWhenNotProvided(array $body, int $expectedLength): void
{
$request = ServerRequestFactory::fromGlobals()->withParsedBody($body);
$handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) use ($expectedLength) {
$parsedBody = $req->getParsedBody();
Assert::assertArrayHasKey(ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, $parsedBody);
Assert::assertEquals($expectedLength, $parsedBody[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH]);
return $req;
}))->willReturn(new Response());
$this->middleware->process($request, $this->handler->reveal());
$handle->shouldHaveBeenCalledOnce();
}
public function provideBodies(): iterable
{
yield 'value provided' => [[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 6], 6];
yield 'value not provided' => [[], 8];
}
}

View File

@@ -12,29 +12,30 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware;
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware;
class DropDefaultDomainFromQueryMiddlewareTest extends TestCase
class DropDefaultDomainFromRequestMiddlewareTest extends TestCase
{
private DropDefaultDomainFromQueryMiddleware $middleware;
private DropDefaultDomainFromRequestMiddleware $middleware;
private ObjectProphecy $next;
public function setUp(): void
{
$this->next = $this->prophesize(RequestHandlerInterface::class);
$this->middleware = new DropDefaultDomainFromQueryMiddleware('doma.in');
$this->middleware = new DropDefaultDomainFromRequestMiddleware('doma.in');
}
/**
* @test
* @dataProvider provideQueryParams
*/
public function domainIsDroppedWhenDefaultOneIsProvided(array $providedQuery, array $expectedQuery): void
public function domainIsDroppedWhenDefaultOneIsProvided(array $providedPayload, array $expectedPayload): void
{
$req = ServerRequestFactory::fromGlobals()->withQueryParams($providedQuery);
$req = ServerRequestFactory::fromGlobals()->withQueryParams($providedPayload)->withParsedBody($providedPayload);
$handle = $this->next->handle(Argument::that(function (ServerRequestInterface $request) use ($expectedQuery) {
Assert::assertEquals($expectedQuery, $request->getQueryParams());
$handle = $this->next->handle(Argument::that(function (ServerRequestInterface $request) use ($expectedPayload) {
Assert::assertEquals($expectedPayload, $request->getQueryParams());
Assert::assertEquals($expectedPayload, $request->getParsedBody());
return $request;
}))->willReturn(new Response());