Compare commits

..

94 Commits

Author SHA1 Message Date
Alejandro Celaya
aa77c944d8 Merge pull request #277 from acelaya/feature/increase-msi
Feature/increase msi
2018-11-17 19:37:11 +01:00
Alejandro Celaya
b8faa6714a Increased MSI to 65% (for sure this time) 2018-11-17 19:32:31 +01:00
Alejandro Celaya
f48f98f4d7 Updated changelog for v1.14.1 2018-11-17 19:27:00 +01:00
Alejandro Celaya
79b2a0839f Increased MSI to 65% 2018-11-17 19:23:49 +01:00
Alejandro Celaya
6094d17718 Increased MSI to 64% 2018-11-17 18:40:53 +01:00
Alejandro Celaya
d2ed7d6417 Increased MSI to 62% 2018-11-17 18:06:21 +01:00
Alejandro Celaya
a705ef21a9 Increased MSI to 61% 2018-11-17 17:36:22 +01:00
Alejandro Celaya
67e465c479 Merge pull request #276 from acelaya/feature/locking
Feature/locking
2018-11-17 14:33:58 +01:00
Alejandro Celaya
ed3883b52c Updated translations 2018-11-17 14:29:54 +01:00
Alejandro Celaya
71ea0bcb5e Updated changelog with locking capabilities 2018-11-17 14:24:38 +01:00
Alejandro Celaya
dd2cffeee9 Reused ProcessVisitsCommand name as the lock name 2018-11-17 14:16:45 +01:00
Alejandro Celaya
1ceabf3bc3 Added locking capabilities to process visits command 2018-11-17 14:11:16 +01:00
Alejandro Celaya
17fcd637f2 Merge pull request #275 from acelaya/feature/doctrine-performance
Feature/doctrine performance
2018-11-17 09:59:53 +01:00
Alejandro Celaya
d44bc4b182 Added small hint in README 2018-11-17 09:49:44 +01:00
Alejandro Celaya
4760406221 Updated changelog 2018-11-17 09:47:14 +01:00
Alejandro Celaya
0aae0d888c Moved visits iteration logic from command to service to allow lazy loading of entries in resultset 2018-11-17 09:42:15 +01:00
Alejandro Celaya
1bc01057f3 Reduced the number of arguments in private method 2018-11-17 08:02:42 +01:00
Alejandro Celaya
c1906606c6 Updated VisitService to have a method which locates visits and allows entity manager to be cleared 2018-11-17 07:47:42 +01:00
Alejandro Celaya
1363194909 Improved code in LoggerFactory 2018-11-17 07:31:51 +01:00
Alejandro Celaya
d945e0c31b Updated CLI help in README file 2018-11-16 17:17:25 +01:00
Alejandro Celaya
0af7b75af5 Merge pull request #269 from acelaya/feature/missing-resp-examples
Feature/missing resp examples
2018-11-16 17:07:38 +01:00
Alejandro Celaya
36a42cb064 Added missing entries for v1.14.0 2018-11-16 17:02:40 +01:00
Alejandro Celaya
4db0acc0e7 Updated swagger response schemas and added missing response examples 2018-11-16 16:58:21 +01:00
Alejandro Celaya
8f4800aa47 Merge pull request #268 from acelaya/feature/phpstan-fix
feature/phpstan-fix
2018-11-16 16:57:30 +01:00
Alejandro Celaya
4745a37549 Used a lower level on phpstan to avoid errors produced by Symfony 4.1.5 new phpdocs 2018-11-16 16:44:48 +01:00
Alejandro Celaya
8fc949898b Excluded GeoLite2 db from build process 2018-11-12 21:51:14 +01:00
Alejandro Celaya
d4758b0e91 Merge pull request #258 from acelaya/feature/geolocation
Feature/geolocation
2018-11-12 21:46:33 +01:00
Alejandro Celaya
a07e4b17be Updated docs 2018-11-12 21:37:04 +01:00
Alejandro Celaya
b9dd975bc6 Updated changelog with new geolocation service 2018-11-12 21:34:45 +01:00
Alejandro Celaya
9964d3e24b Added progress bar to command downloading new GeoLite2 database file 2018-11-12 21:30:30 +01:00
Alejandro Celaya
58e8c8e182 Updated spanish translations 2018-11-12 21:04:02 +01:00
Alejandro Celaya
c7339f6cfa Created an EmptyIpLocationResolver which always returns an empty resolution and can be used as a fallback while resolving IP addresses 2018-11-12 20:58:14 +01:00
Alejandro Celaya
1aa78f766a Added step to download GeoLite2 db during installation 2018-11-12 20:51:53 +01:00
Alejandro Celaya
bf56e6adaf Created UpdateDbCommandTest 2018-11-12 20:37:30 +01:00
Alejandro Celaya
e915b7e499 Updated GeoLite2 db reader service so that it is lazily created 2018-11-12 20:22:42 +01:00
Alejandro Celaya
de0470d200 Created command to update GeoLite2 database 2018-11-12 20:06:12 +01:00
Alejandro Celaya
3d7cf6992e Created service to update geolite2 database file 2018-11-11 21:28:42 +01:00
Alejandro Celaya
06db082e3f Updated translations 2018-11-11 21:28:42 +01:00
Alejandro Celaya
4a383cecaf Set chain IP resolver as the default IP resolver 2018-11-11 21:28:42 +01:00
Alejandro Celaya
9a0f9207be Fixed region resolved in GeoLite2 2018-11-11 21:28:42 +01:00
Alejandro Celaya
0e3a0a1eec Created chain IP resolver which wrapps multiple resolver to fallback until one is capable of resolving an address 2018-11-11 21:28:42 +01:00
Alejandro Celaya
fd6d180eba Created chainIpLocationResolver 2018-11-11 21:28:42 +01:00
Alejandro Celaya
d152e2ef9a Removed the concept of API limits in IP location resolvers 2018-11-11 21:28:42 +01:00
Alejandro Celaya
b530cf4461 Created new namespace for IP geolocation elements 2018-11-11 21:28:42 +01:00
Alejandro Celaya
bbe85cde31 Migrated to GeoLite2 for IP location resolution 2018-11-11 21:28:42 +01:00
Alejandro Celaya
2c3cbe7146 Installed geoip2 and added to docs 2018-11-11 21:28:42 +01:00
Alejandro Celaya
2358308f4d Merge pull request #259 from acelaya/feature/infection
Updated to infection v0.11
2018-11-11 21:28:12 +01:00
Alejandro Celaya
58bff4fa73 Updated to infection v0.11 2018-11-11 21:24:11 +01:00
Alejandro Celaya
098f7afc70 Merge pull request #255 from acelaya/feature/user-agent-length
Updated user agent column in visits table to have a length of 512
2018-11-10 19:07:22 +01:00
Alejandro Celaya
4070b1e23d Updated user agent column in visits table to have a length of 512 2018-11-10 19:01:59 +01:00
Alejandro Celaya
d9d4c8a70c Merge pull request #252 from acelaya/feature/redirect-not-found
Feature/redirect not found
2018-11-04 12:19:03 +01:00
Alejandro Celaya
05abe49d8b Updated changelog 2018-11-04 12:11:36 +01:00
Alejandro Celaya
a71245b883 Improved UrlShortenerConfigCustomizerTest covering new config options 2018-11-04 12:05:22 +01:00
Alejandro Celaya
057f88a36a Added new not found short url config to installer 2018-11-04 11:58:35 +01:00
Alejandro Celaya
32fcdd9d94 Ensured phpcov is run with phpdbg in travis pipeline 2018-11-03 12:15:25 +01:00
Alejandro Celaya
313927827d Updated RedirectAction so that it makes use of the not found short url options 2018-11-03 12:10:02 +01:00
Alejandro Celaya
358b2b661e Deprecated ci composer command, since it does the same as check, but slower 2018-11-03 11:40:57 +01:00
Alejandro Celaya
3eddacdff8 Created options to enable redirection to external page when short code is not found 2018-11-03 11:37:43 +01:00
Alejandro Celaya
95d4cde649 Merge pull request #251 from acelaya/feature/improve-infection
Feature/improve infection
2018-11-03 11:07:20 +01:00
Alejandro Celaya
d1d947bf12 Disabled xdebug in travis env 2018-11-03 11:02:52 +01:00
Alejandro Celaya
40815e5b38 Ensured phpunit is run using phpdbg, to avoid the requirement on xdebug 2018-11-03 11:02:19 +01:00
Alejandro Celaya
8fc1d23e03 Created needed commands and updated pipeline config file to run infection using an existing code coverage report 2018-11-03 10:58:46 +01:00
Alejandro Celaya
5ec8c229a1 Merge pull request #250 from acelaya/feature/functional
Feature/functional
2018-11-02 12:19:07 +01:00
Alejandro Celaya
2412ec2195 Updated changelog 2018-11-02 12:08:43 +01:00
Alejandro Celaya
bfb96b0ae8 Fixed coding style 2018-11-02 12:07:13 +01:00
Alejandro Celaya
f64920e510 Replaced some array_map by Functional\map 2018-11-02 12:05:01 +01:00
Alejandro Celaya
664dc333ac Used select_keys function in place of custom pick function 2018-11-02 11:08:20 +01:00
Alejandro Celaya
521f6f2b18 Added functional-php library 2018-11-02 10:54:42 +01:00
Alejandro Celaya
6986d03c53 Merge pull request #248 from acelaya/feature/fix-anemic-model
Feature/fix anemic model
2018-10-28 16:27:14 +01:00
Alejandro Celaya
e6e38e3ca2 Added change to changelog 2018-10-28 16:22:30 +01:00
Alejandro Celaya
951d08f914 Improved public API in Visit entity, reducing anemic model 2018-10-28 16:20:54 +01:00
Alejandro Celaya
8e1e8ba7de Improved public API in ShortUrl entity, reducing anemic model 2018-10-28 16:00:54 +01:00
Alejandro Celaya
877b098b09 Improved public API in ApiKey entity, reducing anemic model 2018-10-28 15:24:41 +01:00
Alejandro Celaya
e046eddda9 Improved public API in VisitLocation entity, reducing anemic model 2018-10-28 15:13:45 +01:00
Alejandro Celaya
084b1169d7 Improved public API in Tag entity, avoiding anemic model 2018-10-28 14:38:43 +01:00
Alejandro Celaya
f7ceeff05a Added task to changelog 2018-10-28 09:15:26 +01:00
Alejandro Celaya
e0d41a2b8a Merge pull request #246 from acelaya/feature/enforce-global-imports
Feature/enforce global imports
2018-10-28 09:12:46 +01:00
Alejandro Celaya
6b9f9f0f44 Added scrutinizer config to enforce using the new environment 2018-10-28 09:05:20 +01:00
Alejandro Celaya
025135b8c6 Added all missing use statements from global functions and constants 2018-10-28 08:34:02 +01:00
Alejandro Celaya
77d810b735 Replaced all FQ global function and constants by explicit imports 2018-10-28 08:24:06 +01:00
Alejandro Celaya
e1222de05b Explicitly imported global functions in UrlShortener 2018-10-28 08:07:33 +01:00
Alejandro Celaya
459f807e67 Added link to shlink CLI help when mentioning CLI available commands 2018-10-20 13:09:41 +02:00
Alejandro Celaya
32df1370a6 Updated changelog 2018-10-20 13:08:03 +02:00
Alejandro Celaya
f18f8c89ec Merge pull request #244 from acelaya/feature/psr-logs
Feature/psr logs
2018-10-20 13:06:20 +02:00
Alejandro Celaya
787b791651 Replaced hardcoded exceptions concatenations by PSR approach 2018-10-20 12:50:10 +02:00
Alejandro Celaya
2eca0da852 Updated logger to properly format exceptions using processors 2018-10-20 12:37:35 +02:00
Alejandro Celaya
9e49604ce2 Replaced usages of mt_rand by random_int 2018-10-20 09:21:26 +02:00
Alejandro Celaya
5f85c61d6a Merge pull request #243 from acelaya/feature/snake-case-table
Feature/snake case table
2018-10-20 09:20:48 +02:00
Alejandro Celaya
cd58855e1f Updated changelog 2018-10-20 09:10:27 +02:00
Alejandro Celaya
13c64b0db0 Fixed coding styles 2018-10-20 09:10:27 +02:00
Alejandro Celaya
55e021ba20 Added snake case column names to VisitLocation entity 2018-10-20 09:10:27 +02:00
Alejandro Celaya
26fd61a3ed Created migrations to rename camel case columns to snake case 2018-10-20 09:10:27 +02:00
Alejandro Celaya
46482522bb Merge pull request #242 from acelaya/feature/functions-as-object
Moved global functions to handle array paths to a wrapper class
2018-10-20 08:59:40 +02:00
Alejandro Celaya
98e3e22896 Moved global functions to handle array paths to a wrapper class 2018-10-20 08:00:33 +02:00
221 changed files with 3199 additions and 1297 deletions

1
.gitignore vendored
View File

@@ -5,5 +5,6 @@ composer.phar
vendor/
.env
data/database.sqlite
data/GeoLite2-City.mmdb
docs/swagger-ui
docker-compose.override.yml

View File

@@ -1,6 +1,12 @@
tools:
external_code_coverage: true
checks:
php:
code_rating: true
duplication: true
php:
code_rating: true
duplication: true
build:
nodes:
analysis:
tests:
override:
- php-scrutinizer-run

View File

@@ -18,6 +18,7 @@ matrix:
before_install:
- echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- phpenv config-rm xdebug.ini || return 0
install:
- composer self-update
@@ -28,7 +29,8 @@ script:
- composer check
after_success:
- vendor/bin/phpcov merge build --clover build/clover.xml
- rm -f build/clover.xml
- phpdbg -qrr vendor/bin/phpcov 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

View File

@@ -4,6 +4,69 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## 1.14.1 - 2018-11-17
#### Added
* *Nothing*
#### Changed
* [#260](https://github.com/shlinkio/shlink/issues/260) Increased mutation score to 65%.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#271](https://github.com/shlinkio/shlink/issues/271) Fixed memory leak produced when processing high amounts of visits at the same time.
* [#272](https://github.com/shlinkio/shlink/issues/272) Fixed errors produced when trying to process visits multiple times in parallel, by using a lock which ensures only one instance is run at a time.
## 1.14.0 - 2018-11-16
#### Added
* [#236](https://github.com/shlinkio/shlink/issues/236) Added option to define a redirection to a custom URL when a user hits an invalid short URL.
It only affects URLs matched as "short URL" where the short code is invalid, not any 404 that happens in the app. For example, a request to the path `/foo/bar` will keep returning a 404.
This new option will be asked by the installer both for new shlink installations and for any previous shlink version which is updated.
* [#189](https://github.com/shlinkio/shlink/issues/189) and [#240](https://github.com/shlinkio/shlink/issues/240) Added new [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/)-based geolocation service which is faster and more reliable than previous one.
It does not have API limit problems, since it uses a local database file.
Previous service is still used as a fallback in case GeoLite DB does not contain any IP address.
#### Changed
* [#241](https://github.com/shlinkio/shlink/issues/241) Fixed columns in `visit_locations` table, to be snake_case instead of camelCase.
* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monlog's `PsrLogMessageProcessor`.
* [#225](https://github.com/shlinkio/shlink/issues/225) Performance and maintainability slightly improved by enforcing via code sniffer that all global namespace classes, functions and constants are explicitly imported.
* [#196](https://github.com/shlinkio/shlink/issues/196) Reduced anemic model in entities, defining more expressive public APIs instead.
* [#249](https://github.com/shlinkio/shlink/issues/249) Added [functional-php](https://github.com/lstrojny/functional-php) to ease collections handling.
* [#253](https://github.com/shlinkio/shlink/issues/253) Increased `user_agent` column length in `visits` table to 512.
* [#256](https://github.com/shlinkio/shlink/issues/256) Updated to Infection v0.11.
* [#202](https://github.com/shlinkio/shlink/issues/202) Added missing response examples to OpenAPI docs.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#223](https://github.com/shlinkio/shlink/issues/223) Fixed PHPStan errors produced with symfony/console 4.1.5
## 1.13.2 - 2018-10-18

View File

@@ -104,10 +104,18 @@ Those tasks can be performed using shlink's CLI, so it should be easy to schedul
If you don't run this command regularly, the stats will say all visits come from *unknown* locations.
* Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db`
When shlink is installed it downloads a fresh [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/) db file. Running this command will update this file.
The file is updated the first Tuesday of every month, so it should be enough running this command the first Wednesday.
* Generate website previews: `/path/to/shlink/bin/cli short-url:process-previews`
Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site.
*Any of those commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.*
## Update to new version
When a new Shlink version is available, you don't need to repeat the entire process yourself. Instead, follow these steps:
@@ -134,7 +142,7 @@ Currently the image does not expose an entry point which let's you interact with
Once shlink is installed, there are two main ways to interact with it:
* **The command line**. Try running `bin/cli` and see all the available commands.
* **The command line**. Try running `bin/cli` and see all the [available commands](#shlink-cli-help).
All of those commands can be run with the `--help`/`-h` flag in order to see how to use them and all the available options.
@@ -185,4 +193,7 @@ Available commands:
tag:rename Renames one existing tag.
visit
visit:process Processes visits where location is not set yet
visit:update-db Updates the GeoLite2 database file used to geolocate IP addresses
```
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)

View File

@@ -18,6 +18,8 @@ rm -rf "${builtcontent}"
mkdir -p "${builtcontent}"
rsync -av * "${builtcontent}" \
--exclude=data/infra \
--exclude=data/migrations_template.txt \
--exclude=data/GeoLite2-City.mmdb \
--exclude=**/.gitignore \
--exclude=CHANGELOG.md \
--exclude=composer.lock \

View File

@@ -24,13 +24,16 @@
"doctrine/orm": "^2.5",
"endroid/qr-code": "^1.7",
"firebase/php-jwt": "^4.0",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^6.2",
"lstrojny/functional-php": "^1.8",
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master",
"symfony/console": "^4.0 <4.1.5",
"symfony/filesystem": "^4.0",
"symfony/process": "^4.0",
"symfony/console": "^4.1",
"symfony/filesystem": "^4.1",
"symfony/lock": "^4.1",
"symfony/process": "^4.1",
"theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0",
@@ -46,11 +49,12 @@
"zendframework/zend-stdlib": "^3.0"
},
"require-dev": {
"devster/ubench": "^2.0",
"filp/whoops": "^2.0",
"infection/infection": "^0.9.0",
"infection/infection": "^0.11.0",
"phpstan/phpstan": "^0.10.0",
"phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.0",
"phpunit/phpunit": "^7.3",
"slevomat/coding-standard": "^4.0",
"squizlabs/php_codesniffer": "^3.2.3",
"symfony/dotenv": "^4.0",
@@ -89,42 +93,59 @@
"check": [
"@cs",
"@stan",
"@test",
"@infect"
"@test:ci",
"@infect:ci"
],
"ci": [
"echo \"This command is DEPRECATED. Use check instead\"",
"@check"
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"stan": "phpstan analyse module/*/src/ --level=5 -c phpstan.neon",
"test": [
"@test:unit",
"@test:func"
],
"test:unit": "phpunit --coverage-php build/coverage-unit.cov",
"test:func": "phpunit -c phpunit-func.xml --coverage-php build/coverage-func.cov",
"test:ci": [
"@test:unit:ci",
"@test:func"
],
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov",
"test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/phpunit.junit.xml",
"test:func": "phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-func.xml --coverage-php build/coverage-func.cov",
"test:pretty": [
"@test:unit",
"@test:func",
"@test",
"phpcov merge build --html build/html"
],
"test:unit:pretty": "phpunit --coverage-html build/coverage",
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --coverage-html build/coverage --order-by=random",
"infect": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2",
"infect:show": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2 --show-mutations"
"infect": "infection --threads=4 --min-msi=65 --log-verbosity=2 --only-covered",
"infect:ci": "infection --threads=4 --min-msi=65 --log-verbosity=2 --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=65 --log-verbosity=2 --only-covered --show-mutations",
"infect:test": [
"@test:unit:ci",
"@infect:ci"
]
},
"scripts-descriptions": {
"check": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test\" and \"infect\"</>",
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test:ci\" and \"infect:ci\"</>",
"cs": "<fg=blue;options=bold>Checks coding styles</>",
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
"test": "<fg=blue;options=bold>Runs all test suites</>",
"test:ci": "<fg=blue;options=bold>Runs all test suites, generating all needed reports and logs for CI envs</>",
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:func": "<fg=blue;options=bold>Runs functional test suites (covering entity repositories)</>",
"test:pretty": "<fg=blue;options=bold>Runs all test suites and generates an HTML code coverage report</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
"infect:ci": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>"
},
"config": {

View File

@@ -23,6 +23,12 @@ return [
Container\ApplicationConfigInjectionDelegator::class,
],
],
'lazy_services' => [
'proxies_target_dir' => 'data/proxies',
'proxies_namespace' => 'ShlinkProxy',
'write_proxy_files' => true,
],
],
];

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
return [
'dependencies' => [
'lazy_services' => [
'write_proxy_files' => false,
],
],
];

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => sys_get_temp_dir(),
'download_from' => 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz',
],
];

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Symfony\Component\Lock;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
return [
'locks' => [
'locks_dir' => __DIR__ . '/../../data/locks',
],
'dependencies' => [
'factories' => [
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
Lock\Factory::class => ConfigAbstractFactory::class,
],
],
ConfigAbstractFactory::class => [
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
Lock\Factory::class => [Lock\Store\FlockStore::class],
],
];

View File

@@ -1,15 +1,19 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Logger;
use Monolog\Processor;
use const PHP_EOL;
return [
'logger' => [
'formatters' => [
'dashed' => [
'format' => '[%datetime%] %channel%.%level_name% - %message% %context%' . PHP_EOL,
'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL,
'include_stacktraces' => true,
],
],
@@ -24,9 +28,19 @@ return [
],
],
'processors' => [
'exception_with_new_line' => [
'class' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class,
],
'psr3' => [
'class' => Processor\PsrLogMessageProcessor::class,
],
],
'loggers' => [
'Shlink' => [
'handlers' => ['rotating_file_handler'],
'processors' => ['exception_with_new_line', 'psr3'],
],
],
],

View File

@@ -13,6 +13,10 @@ return [
],
'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS),
'validate_url' => true,
'not_found_short_url' => [
'enable_redirection' => false,
'redirect_to' => null,
],
],
];

View File

@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink;
use Acelaya\ExpressiveErrorHandler;
use Zend\ConfigAggregator;
use Zend\Expressive;
use function class_exists;
return (new ConfigAggregator\ConfigAggregator([
Expressive\ConfigProvider::class,
@@ -13,7 +14,7 @@ return (new ConfigAggregator\ConfigAggregator([
Expressive\Router\FastRouteRouter\ConfigProvider::class,
Expressive\Plates\ConfigProvider::class,
Expressive\Helper\ConfigProvider::class,
\class_exists(Expressive\Swoole\ConfigProvider::class)
class_exists(Expressive\Swoole\ConfigProvider::class)
? Expressive\Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ExpressiveErrorHandler\ConfigProvider::class,

2
data/locks/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -6,6 +6,7 @@ namespace ShlinkMigrations;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use PDO;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Util\IpAddress;
@@ -38,7 +39,7 @@ final class Version20180913205455 extends AbstractMigration
->set('v.remote_addr', ':obfuscatedAddr')
->where('v.id=:id');
while ($row = $st->fetch(\PDO::FETCH_ASSOC)) {
while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
$addr = $row['remote_addr'] ?? null;
if ($addr === null) {
continue;

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Type;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20181020060559 extends AbstractMigration
{
private const COLUMNS = [
'countryCode' => 'country_code',
'countryName' => 'country_name',
'regionName' => 'region_name',
'cityName' => 'city_name',
];
/**
* @param Schema $schema
* @throws SchemaException
*/
public function up(Schema $schema): void
{
$this->createColumns($schema->getTable('visit_locations'), self::COLUMNS);
}
private function createColumns(Table $visitLocations, array $columnNames): void
{
foreach ($columnNames as $name) {
if (! $visitLocations->hasColumn($name)) {
$visitLocations->addColumn($name, Type::STRING, ['notnull' => false]);
}
}
}
/**
* @throws SchemaException
* @throws DBALException
*/
public function postUp(Schema $schema): void
{
$visitLocations = $schema->getTable('visit_locations');
// If the camel case columns do not exist, do nothing
if (! $visitLocations->hasColumn('countryCode')) {
return;
}
$qb = $this->connection->createQueryBuilder();
$qb->update('visit_locations');
foreach (self::COLUMNS as $camelCaseName => $snakeCaseName) {
$qb->set($snakeCaseName, $camelCaseName);
}
$qb->execute();
}
public function down(Schema $schema): void
{
// No down
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20181020065148 extends AbstractMigration
{
private const CAMEL_CASE_COLUMNS = [
'countryCode',
'countryName',
'regionName',
'cityName',
];
/**
* @throws SchemaException
*/
public function up(Schema $schema): void
{
$visitLocations = $schema->getTable('visit_locations');
foreach (self::CAMEL_CASE_COLUMNS as $name) {
if ($visitLocations->hasColumn($name)) {
$visitLocations->dropColumn($name);
}
}
}
public function down(Schema $schema): void
{
// No down
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Column;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
final class Version20181110175521 extends AbstractMigration
{
/**
* @throws SchemaException
*/
public function up(Schema $schema): void
{
$this->getUserAgentColumn($schema)->setLength(512);
}
/**
* @throws SchemaException
*/
public function down(Schema $schema): void
{
$this->getUserAgentColumn($schema)->setLength(256);
}
/**
* @throws SchemaException
*/
private function getUserAgentColumn(Schema $schema): Column
{
return $schema->getTable('visits')->getColumn('user_agent');
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace <namespace>;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version<version> extends AbstractMigration
{
public function up(Schema $schema): void
{
<up>
}
public function down(Schema $schema): void
{
<down>
}
}

View File

@@ -209,23 +209,22 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL that has been parsed"
},
"shortUrl": {
"type": "string",
"description": "The generated short URL"
},
"shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
}
}
"$ref": "../definitions/ShortUrl.json"
}
}
},
"examples": {
"application/json": {
"shortCode": "12C18",
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 0,
"tags": [
"games",
"tech"
]
}
}
},
"400": {

View File

@@ -45,21 +45,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL that has been shortened"
},
"shortUrl": {
"type": "string",
"description": "The generated short URL"
},
"shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
}
}
"$ref": "../definitions/ShortUrl.json"
}
},
"text/plain": {
@@ -71,10 +57,16 @@
"examples": {
"application/json": {
"longUrl": "https://github.com/shlinkio/shlink",
"shortUrl": "https://dom.ain/abc123",
"shortCode": "abc123"
"shortUrl": "https://doma.in/abc123",
"shortCode": "abc123",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 0,
"tags": [
"games",
"tech"
]
},
"text/plain": "https://dom.ain/abc123"
"text/plain": "https://doma.in/abc123"
}
},
"400": {

View File

@@ -2,10 +2,9 @@
"source": {
"directories": [
"module/*/src"
],
"excludes": []
]
},
"timeout": 10,
"timeout": 5,
"logs": {
"text": "build/infection/infection-log.txt",
"summary": "build/infection/summary-log.txt",
@@ -17,6 +16,7 @@
},
"mutators": {
"@default": true,
"IdenticalEqual": false
"IdenticalEqual": false,
"NotIdenticalNotEqual": false
}
}

View File

@@ -2,3 +2,4 @@ name: ShlinkMigrations
migrations_namespace: ShlinkMigrations
table_name: migrations
migrations_directory: data/migrations
custom_template: data/migrations_template.txt

View File

@@ -18,6 +18,7 @@ return [
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
Command\Visit\UpdateDbCommand::NAME => Command\Visit\UpdateDbCommand::class,
Command\Config\GenerateCharsetCommand::NAME => Command\Config\GenerateCharsetCommand::class,
Command\Config\GenerateSecretCommand::NAME => Command\Config\GenerateSecretCommand::class,

View File

@@ -3,11 +3,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Lock;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
@@ -25,6 +27,7 @@ return [
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\UpdateDbCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateCharsetCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateSecretCommand::class => ConfigAbstractFactory::class,
@@ -65,9 +68,11 @@ return [
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpApiLocationResolver::class,
IpLocationResolverInterface::class,
Lock\Factory::class,
'translator',
],
Command\Visit\UpdateDbCommand::class => [DbUpdater::class, 'translator'],
Command\Config\GenerateCharsetCommand::class => ['translator'],
Command\Config\GenerateSecretCommand::class => ['translator'],

Binary file not shown.

View File

@@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-09-16 18:36+0200\n"
"PO-Revision-Date: 2018-09-16 18:37+0200\n"
"POT-Creation-Date: 2018-11-17 14:29+0100\n"
"PO-Revision-Date: 2018-11-17 14:29+0100\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@@ -340,25 +340,49 @@ msgstr "Una etiqueta con nombre \"%s\" no ha sido encontrada"
msgid "Processes visits where location is not set yet"
msgstr "Procesa las visitas donde la localización no ha sido establecida aún"
#, php-format
msgid "There is already an instance of the \"%s\" command in execution"
msgstr "Ya existe una instancia del comando \"%s\" en ejecución"
#, php-format
msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%s\""
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"
msgid "Ignored visit with no IP address"
msgstr "Ignorada visita sin dirección IP"
msgid "Processing IP"
msgstr "Procesando IP"
msgid "Ignored localhost address"
msgstr "Ignorada IP de localhost"
#, php-format
msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%s\""
msgid "An error occurred while locating IP. Skipped"
msgstr "Se produjo un error al localizar la IP. Ignorado"
msgid "An error occurred while locating IP"
msgstr "Se produjo un error al localizar la IP"
msgid "Updates the GeoLite2 database file used to geolocate IP addresses"
msgstr ""
"Actualiza el fichero de base de datos de GeoLite2 usado para geolocalizar "
"direcciones IP"
#, php-format
msgid "IP location resolver limit reached. Waiting %s seconds..."
msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
msgid ""
"The GeoLite2 database is updated first Tuesday every month, so this command "
"should be ideally run every first Wednesday"
msgstr ""
"La base de datos de GeoLite2 se actualiza el primer Martes de cada mes, por "
"lo que la opción ideal es ejecutar este comando cada primer miércoles de mes"
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"
msgid "GeoLite2 database properly updated"
msgstr "Base de datos de GeoLite2 correctamente actualizada"
msgid "An error occurred while updating GeoLite2 database"
msgstr "Se produjo un error al actualizar la base de datos de GeoLite2"
#~ msgid "IP location resolver limit reached. Waiting %s seconds..."
#~ msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
#~ msgid "Remote Address"
#~ msgstr "Dirección remota"

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use InvalidArgumentException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -47,7 +48,7 @@ class DisableKeyCommand extends Command
try {
$this->apiKeyService->disable($apiKey);
$io->success(sprintf($this->translator->translate('API key "%s" properly disabled'), $apiKey));
} catch (\InvalidArgumentException $e) {
} catch (InvalidArgumentException $e) {
$io->error(sprintf($this->translator->translate('API key "%s" does not exist.'), $apiKey));
}
}

View File

@@ -12,6 +12,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function array_filter;
use function array_map;
use function sprintf;
class ListKeysCommand extends Command
@@ -54,24 +55,19 @@ class ListKeysCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$enabledOnly = $input->getOption('enabledOnly');
$list = $this->apiKeyService->listKeys($enabledOnly);
$rows = [];
/** @var ApiKey $row */
foreach ($list as $row) {
$key = $row->getKey();
$expiration = $row->getExpirationDate();
$messagePattern = $this->determineMessagePattern($row);
$rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
$expiration = $apiKey->getExpirationDate();
$messagePattern = $this->determineMessagePattern($apiKey);
// Set columns for this row
$rowData = [sprintf($messagePattern, $key)];
$rowData = [sprintf($messagePattern, $apiKey)];
if (! $enabledOnly) {
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($row));
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
$rows[] = $rowData;
}
return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly));
$io->table(array_filter([
$this->translator->translate('Key'),

View File

@@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class DeleteShortUrlCommand extends Command
{
@@ -68,7 +69,7 @@ class DeleteShortUrlCommand extends Command
$this->runDelete($io, $shortCode, $ignoreThreshold);
} catch (Exception\InvalidShortCodeException $e) {
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
);
} catch (Exception\DeleteShortUrlException $e) {
$this->retry($io, $shortCode, $e);
@@ -77,7 +78,7 @@ class DeleteShortUrlCommand extends Command
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): void
{
$warningMsg = \sprintf($this->translator->translate(
$warningMsg = sprintf($this->translator->translate(
'It was not possible to delete the short URL with short code "%s" because it has more than %s visits.'
), $shortCode, $e->getVisitsThreshold());
$io->writeln('<bg=yellow>' . $warningMsg . '</>');
@@ -93,7 +94,7 @@ class DeleteShortUrlCommand extends Command
private function runDelete(SymfonyStyle $io, string $shortCode, bool $ignoreThreshold): void
{
$this->deleteShortUrlService->deleteByShortCode($shortCode, $ignoreThreshold);
$io->success(\sprintf(
$io->success(sprintf(
$this->translator->translate('Short URL with short code "%s" successfully deleted.'),
$shortCode
));

View File

@@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class GeneratePreviewCommand extends Command
{
@@ -61,7 +62,7 @@ class GeneratePreviewCommand extends Command
$page += 1;
foreach ($shortUrls as $shortUrl) {
$this->processUrl($shortUrl->getOriginalUrl(), $output);
$this->processUrl($shortUrl->getLongUrl(), $output);
}
} while ($page <= $shortUrls->count());
@@ -71,7 +72,7 @@ class GeneratePreviewCommand extends Command
private function processUrl($url, OutputInterface $output): void
{
try {
$output->write(\sprintf($this->translator->translate('Processing URL %s...'), $url));
$output->write(sprintf($this->translator->translate('Processing URL %s...'), $url));
$this->previewGenerator->generatePreview($url);
$output->writeln($this->translator->translate(' <info>Success!</info>'));
} catch (PreviewGenerationException $e) {

View File

@@ -16,6 +16,9 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Diactoros\Uri;
use Zend\I18n\Translator\TranslatorInterface;
use function array_merge;
use function explode;
use function sprintf;
class GenerateShortUrlCommand extends Command
{
@@ -107,8 +110,8 @@ class GenerateShortUrlCommand extends Command
$tags = $input->getOption('tags');
$processedTags = [];
foreach ($tags as $key => $tag) {
$explodedTags = \explode(',', $tag);
$processedTags = \array_merge($processedTags, $explodedTags);
$explodedTags = explode(',', $tag);
$processedTags = array_merge($processedTags, $explodedTags);
}
$tags = $processedTags;
$customSlug = $input->getOption('customSlug');
@@ -126,16 +129,16 @@ class GenerateShortUrlCommand extends Command
$shortUrl = $this->buildShortUrl($this->domainConfig, $shortCode);
$io->writeln([
\sprintf('%s <info>%s</info>', $this->translator->translate('Processed long URL:'), $longUrl),
\sprintf('%s <info>%s</info>', $this->translator->translate('Generated short URL:'), $shortUrl),
sprintf('%s <info>%s</info>', $this->translator->translate('Processed long URL:'), $longUrl),
sprintf('%s <info>%s</info>', $this->translator->translate('Generated short URL:'), $shortUrl),
]);
} catch (InvalidUrlException $e) {
$io->error(\sprintf(
$io->error(sprintf(
$this->translator->translate('Provided URL "%s" is invalid. Try with a different one.'),
$longUrl
));
} catch (NonUniqueSlugException $e) {
$io->error(\sprintf(
$io->error(sprintf(
$this->translator->translate(
'Provided slug "%s" is already in use by another URL. Try with a different one.'
),

View File

@@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -13,6 +14,8 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function array_map;
use function Functional\select_keys;
class GetVisitsCommand extends Command
{
@@ -86,17 +89,11 @@ class GetVisitsCommand extends Command
$endDate = $this->getDateOption($input, 'endDate');
$visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate));
$rows = [];
foreach ($visits as $row) {
$rowData = $row->jsonSerialize();
// Unset location info and remote addr
unset($rowData['visitLocation'], $rowData['remoteAddr']);
$rowData['country'] = $row->getVisitLocation()->getCountryName();
$rows[] = \array_values($rowData);
}
$rows = array_map(function (Visit $visit) {
$rowData = $visit->jsonSerialize();
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
}, $visits);
$io->table([
$this->translator->translate('Referer'),
$this->translator->translate('Date'),

View File

@@ -13,6 +13,11 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function array_values;
use function count;
use function explode;
use function implode;
use function sprintf;
class ListShortUrlsCommand extends Command
{
@@ -59,7 +64,7 @@ class ListShortUrlsCommand extends Command
$this->translator->translate('The first page to list (%s items per page)'),
PaginableRepositoryAdapter::ITEMS_PER_PAGE
),
1
'1'
)
->addOption(
'searchTerm',
@@ -97,7 +102,7 @@ class ListShortUrlsCommand extends Command
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('searchTerm');
$tags = $input->getOption('tags');
$tags = ! empty($tags) ? \explode(',', $tags) : [];
$tags = ! empty($tags) ? explode(',', $tags) : [];
$showTags = $input->getOption('showTags');
$transformer = new ShortUrlDataTransformer($this->domainConfig);
@@ -120,13 +125,13 @@ class ListShortUrlsCommand extends Command
foreach ($result as $row) {
$shortUrl = $transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = \implode(', ', $shortUrl['tags']);
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = \array_values($shortUrl);
$rows[] = array_values($shortUrl);
}
$io->table($headers, $rows);
@@ -135,7 +140,7 @@ class ListShortUrlsCommand extends Command
$io->success($this->translator->translate('Short URLs properly listed'));
} else {
$continue = $io->confirm(
\sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),
sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),
false
);
}
@@ -149,7 +154,7 @@ class ListShortUrlsCommand extends Command
return null;
}
$orderBy = \explode(',', $orderBy);
return \count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
$orderBy = explode(',', $orderBy);
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
}
}

View File

@@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class ResolveUrlCommand extends Command
{
@@ -71,15 +72,15 @@ class ResolveUrlCommand extends Command
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$output->writeln(
\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $url->getLongUrl())
sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $url->getLongUrl())
);
} catch (InvalidShortCodeException $e) {
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode)
sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode)
);
} catch (EntityDoesNotExistException $e) {
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
);
}
}

View File

@@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function array_map;
use function Functional\map;
class ListTagsCommand extends Command
{
@@ -45,15 +45,15 @@ class ListTagsCommand extends Command
$io->table([$this->translator->translate('Name')], $this->getTagsRows());
}
private function getTagsRows()
private function getTagsRows(): array
{
$tags = $this->tagService->listTags();
if (empty($tags)) {
return [[$this->translator->translate('No tags yet')]];
}
return array_map(function (Tag $tag) {
return [$tag->getName()];
}, $tags);
return map($tags, function (Tag $tag) {
return [(string) $tag];
});
}
}

View File

@@ -4,16 +4,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
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 Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\Factory as Locker;
use Zend\I18n\Translator\TranslatorInterface;
use function sleep;
use function sprintf;
class ProcessVisitsCommand extends Command
@@ -32,83 +34,99 @@ class ProcessVisitsCommand extends Command
* @var TranslatorInterface
*/
private $translator;
/**
* @var Locker
*/
private $locker;
/**
* @var OutputInterface
*/
private $output;
public function __construct(
VisitServiceInterface $visitService,
IpLocationResolverInterface $ipLocationResolver,
Locker $locker,
TranslatorInterface $translator
) {
$this->visitService = $visitService;
$this->ipLocationResolver = $ipLocationResolver;
$this->translator = $translator;
parent::__construct(null);
$this->locker = $locker;
parent::__construct();
}
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription(
$this->translator->translate('Processes visits where location is not set yet')
);
$this
->setName(self::NAME)
->setDescription($this->translator->translate('Processes visits where location is not set yet'));
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$this->output = $output;
$io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits();
$count = 0;
foreach ($visits as $visit) {
if (! $visit->hasRemoteAddr()) {
$io->writeln(
sprintf('<comment>%s</comment>', $this->translator->translate('Ignored visit with no IP address')),
OutputInterface::VERBOSITY_VERBOSE
);
continue;
}
$ipAddr = $visit->getRemoteAddr();
$io->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$io->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
);
continue;
}
$count++;
try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
$location = new VisitLocation();
$location->exchangeArray($result);
$visit->setVisitLocation($location);
$this->visitService->saveVisit($visit);
$io->writeln(sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName()
));
} catch (WrongIpException $e) {
$io->writeln(
sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
);
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
}
if ($count === $this->ipLocationResolver->getApiLimit()) {
$count = 0;
$seconds = $this->ipLocationResolver->getApiInterval();
$io->note(sprintf(
$this->translator->translate('IP location resolver limit reached. Waiting %s seconds...'),
$seconds
));
sleep($seconds);
}
$lock = $this->locker->createLock(self::NAME);
if (! $lock->acquire()) {
$io->warning(sprintf(
$this->translator->translate('There is already an instance of the "%s" command in execution'),
self::NAME
));
return;
}
$io->success($this->translator->translate('Finished processing all IPs'));
try {
$this->visitService->locateVisits(
[$this, 'getGeolocationDataForVisit'],
function (VisitLocation $location) use ($output) {
$output->writeln(sprintf(
' [<info>' . $this->translator->translate('Address located at "%s"') . '</info>]',
$location->getCountryName()
));
}
);
$io->success($this->translator->translate('Finished processing all IPs'));
} finally {
$lock->release();
}
}
public function getGeolocationDataForVisit(Visit $visit): array
{
if (! $visit->hasRemoteAddr()) {
$this->output->writeln(sprintf(
'<comment>%s</comment>',
$this->translator->translate('Ignored visit with no IP address')
), OutputInterface::VERBOSITY_VERBOSE);
throw new IpCannotBeLocatedException('Ignored visit with no IP address');
}
$ipAddr = $visit->getRemoteAddr();
$this->output->write(sprintf('%s <fg=blue>%s</>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$this->output->writeln(
sprintf(' [<comment>%s</comment>]', $this->translator->translate('Ignored localhost address'))
);
throw new IpCannotBeLocatedException('Ignored localhost address');
}
try {
return $this->ipLocationResolver->resolveIpLocation($ipAddr);
} catch (WrongIpException $e) {
$this->output->writeln(
sprintf(
' [<fg=red>%s</>]',
$this->translator->translate('An error occurred while locating IP. Skipped')
)
);
if ($this->output->isVerbose()) {
$this->getApplication()->renderException($e, $this->output);
}
throw new IpCannotBeLocatedException('An error occurred while locating IP', $e->getCode(), $e);
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class UpdateDbCommand extends Command
{
public const NAME = 'visit:update-db';
/**
* @var DbUpdaterInterface
*/
private $geoLiteDbUpdater;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(DbUpdaterInterface $geoLiteDbUpdater, TranslatorInterface $translator)
{
$this->geoLiteDbUpdater = $geoLiteDbUpdater;
$this->translator = $translator;
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription(
$this->translator->translate('Updates the GeoLite2 database file used to geolocate IP addresses')
)
->setHelp($this->translator->translate(
'The GeoLite2 database is updated first Tuesday every month, so this command should be ideally run '
. 'every first Wednesday'
));
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$progressBar = new ProgressBar($output);
$progressBar->start();
try {
$this->geoLiteDbUpdater->downloadFreshCopy(function (int $total, int $downloaded) use ($progressBar) {
$progressBar->setMaxSteps($total);
$progressBar->setProgress($downloaded);
});
$progressBar->finish();
$io->writeln('');
$io->success($this->translator->translate('GeoLite2 database properly updated'));
} catch (RuntimeException $e) {
$progressBar->finish();
$io->writeln('');
$io->error($this->translator->translate('An error occurred while updating GeoLite2 database'));
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
}
}
}

View File

@@ -38,11 +38,15 @@ class DisableKeyCommandTest extends TestCase
public function providedApiKeyIsDisabled()
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->shouldBeCalledTimes(1);
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('API key "abcd1234" properly disabled', $output);
}
/**
@@ -51,14 +55,15 @@ class DisableKeyCommandTest extends TestCase
public function errorIsReturnedIfServiceThrowsException()
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class)
->shouldBeCalledTimes(1);
$disable = $this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class);
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('API key "abcd1234" does not exist.', $output);
$disable->shouldHaveBeenCalledOnce();
}
}

View File

@@ -39,11 +39,15 @@ class GenerateKeyCommandTest extends TestCase
*/
public function noExpirationDateIsDefinedIfNotProvided()
{
$this->apiKeyService->create(null)->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$create = $this->apiKeyService->create(null)->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Generated API key: ', $output);
$create->shouldHaveBeenCalledOnce();
}
/**
@@ -51,7 +55,7 @@ class GenerateKeyCommandTest extends TestCase
*/
public function expirationDateIsDefinedIfProvided()
{
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledTimes(1)
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',

View File

@@ -35,30 +35,46 @@ class ListKeysCommandTest extends TestCase
/**
* @test
*/
public function ifEnabledOnlyIsNotProvidedEverythingIsListed()
public function everythingIsListedIfEnabledOnlyIsNotProvided()
{
$this->apiKeyService->listKeys(false)->willReturn([
new ApiKey(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:list',
'command' => ListKeysCommand::NAME,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Key', $output);
$this->assertContains('Is enabled', $output);
$this->assertContains(' +++ ', $output);
$this->assertNotContains(' --- ', $output);
$this->assertContains('Expiration date', $output);
}
/**
* @test
*/
public function ifEnabledOnlyIsProvidedOnlyThoseKeysAreListed()
public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided()
{
$this->apiKeyService->listKeys(true)->willReturn([
(new ApiKey())->disable(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:list',
'command' => ListKeysCommand::NAME,
'--enabledOnly' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Key', $output);
$this->assertNotContains('Is enabled', $output);
$this->assertNotContains(' +++ ', $output);
$this->assertNotContains(' --- ', $output);
$this->assertContains('Expiration date', $output);
}
}

View File

@@ -8,6 +8,9 @@ use Shlinkio\Shlink\CLI\Command\Config\GenerateCharsetCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use function implode;
use function sort;
use function str_split;
class GenerateCharsetCommandTest extends TestCase
{

View File

@@ -12,6 +12,8 @@ use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use function array_pop;
use function sprintf;
class DeleteShortCodeCommandTest extends TestCase
{
@@ -47,8 +49,8 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$this->assertContains(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
@@ -64,8 +66,8 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$this->assertContains(sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
@@ -76,7 +78,7 @@ class DeleteShortCodeCommandTest extends TestCase
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
function (array $args) {
$ignoreThreshold = \array_pop($args);
$ignoreThreshold = array_pop($args);
if (!$ignoreThreshold) {
throw new Exception\DeleteShortUrlException(10);
@@ -88,11 +90,11 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
$this->assertContains(sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains(\sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$this->assertContains(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(2);
}
@@ -110,11 +112,11 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
$this->assertContains(sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
}

View File

@@ -16,6 +16,8 @@ use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
use Zend\Paginator\Paginator;
use function count;
use function substr_count;
class GeneratePreviewCommandTest extends TestCase
{
@@ -54,19 +56,28 @@ class GeneratePreviewCommandTest extends TestCase
public function previewsForEveryUrlAreGenerated()
{
$paginator = $this->createPaginator([
(new ShortUrl())->setOriginalUrl('http://foo.com'),
(new ShortUrl())->setOriginalUrl('https://bar.com'),
(new ShortUrl())->setOriginalUrl('http://baz.com/something'),
new ShortUrl('http://foo.com'),
new ShortUrl('https://bar.com'),
new ShortUrl('http://baz.com/something'),
]);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledOnce();
$this->previewGenerator->generatePreview('http://foo.com')->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview('https://bar.com')->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview('http://baz.com/something')->shouldBeCalledTimes(1);
$generatePreview1 = $this->previewGenerator->generatePreview('http://foo.com')->willReturn('');
$generatePreview2 = $this->previewGenerator->generatePreview('https://bar.com')->willReturn('');
$generatePreview3 = $this->previewGenerator->generatePreview('http://baz.com/something')->willReturn('');
$this->commandTester->execute([
'command' => 'shortcode:process-previews',
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Processing URL http://foo.com', $output);
$this->assertContains('Processing URL https://bar.com', $output);
$this->assertContains('Processing URL http://baz.com/something', $output);
$this->assertContains('Finished processing all URLs', $output);
$generatePreview1->shouldHaveBeenCalledOnce();
$generatePreview2->shouldHaveBeenCalledOnce();
$generatePreview3->shouldHaveBeenCalledOnce();
}
/**
@@ -75,12 +86,12 @@ class GeneratePreviewCommandTest extends TestCase
public function exceptionWillOutputError()
{
$items = [
(new ShortUrl())->setOriginalUrl('http://foo.com'),
(new ShortUrl())->setOriginalUrl('https://bar.com'),
(new ShortUrl())->setOriginalUrl('http://baz.com/something'),
new ShortUrl('http://foo.com'),
new ShortUrl('https://bar.com'),
new ShortUrl('http://baz.com/something'),
];
$paginator = $this->createPaginator($items);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledOnce();
$this->previewGenerator->generatePreview(Argument::any())->willThrow(PreviewGenerationException::class)
->shouldBeCalledTimes(count($items));

View File

@@ -3,9 +3,11 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
@@ -14,7 +16,7 @@ use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class GenerateShortcodeCommandTest extends TestCase
class GenerateShortUrlCommandTest extends TestCase
{
/**
* @var CommandTester
@@ -42,19 +44,19 @@ class GenerateShortcodeCommandTest extends TestCase
*/
public function properShortCodeIsCreatedIfLongUrlIsCorrect()
{
$this->urlShortener->urlToShortCode(Argument::cetera())
->willReturn(
(new ShortUrl())->setShortCode('abc123')
->setLongUrl('')
)
->shouldBeCalledTimes(1);
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn(
(new ShortUrl(''))->setShortCode('abc123')
);
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--maxVisits' => '3',
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'http://foo.com/abc123') > 0);
$this->assertContains('http://foo.com/abc123', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
/**
@@ -63,7 +65,7 @@ class GenerateShortcodeCommandTest extends TestCase
public function exceptionWhileParsingLongUrlOutputsError()
{
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:generate',
@@ -75,4 +77,29 @@ class GenerateShortcodeCommandTest extends TestCase
$output
);
}
/**
* @test
*/
public function properlyProcessesProvidedTags()
{
$urlToShortCode = $this->urlShortener->urlToShortCode(
Argument::type(UriInterface::class),
Argument::that(function (array $tags) {
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
return $tags;
}),
Argument::cetera()
)->willReturn((new ShortUrl(''))->setShortCode('abc123'));
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--tags' => ['foo,bar', 'baz', 'boo,zar'],
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('http://foo.com/abc123', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
}

View File

@@ -9,12 +9,15 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\Common\Util\DateRange;
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\VisitsTrackerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use function strpos;
class GetVisitsCommandTest extends TestCase
{
@@ -43,7 +46,7 @@ class GetVisitsCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, new DateRange(null, null))->willReturn([])
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
@@ -61,7 +64,7 @@ class GetVisitsCommandTest extends TestCase
$endDate = '2016-02-01';
$this->visitsTracker->info($shortCode, new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)))
->willReturn([])
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
@@ -78,18 +81,18 @@ class GetVisitsCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::any())->willReturn([
(new Visit())->setReferer('foo')
->setVisitLocation((new VisitLocation())->setCountryName('Spain'))
->setUserAgent('bar'),
])->shouldBeCalledTimes(1);
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
new VisitLocation(['country_name' => 'Spain'])
),
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertGreaterThan(0, \strpos($output, 'foo'));
$this->assertGreaterThan(0, \strpos($output, 'Spain'));
$this->assertGreaterThan(0, \strpos($output, 'bar'));
$this->assertGreaterThan(0, strpos($output, 'foo'));
$this->assertGreaterThan(0, strpos($output, 'Spain'));
$this->assertGreaterThan(0, strpos($output, 'bar'));
}
}

View File

@@ -15,7 +15,7 @@ use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
use Zend\Paginator\Paginator;
class ListShortcodesCommandTest extends TestCase
class ListShortUrlsCommandTest extends TestCase
{
/**
* @var CommandTester
@@ -41,7 +41,7 @@ class ListShortcodesCommandTest extends TestCase
public function noInputCallsListJustOnce()
{
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
@@ -52,10 +52,10 @@ class ListShortcodesCommandTest extends TestCase
*/
public function loadingMorePagesCallsListMoreTimes()
{
// The paginator will return more than one page for the first 3 times
// The paginator will return more than one page
$data = [];
for ($i = 0; $i < 50; $i++) {
$data[] = (new ShortUrl())->setLongUrl('url_' . $i);
$data[] = new ShortUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (&$data) {
@@ -64,6 +64,11 @@ class ListShortcodesCommandTest extends TestCase
$this->commandTester->setInputs(['y', 'y', 'n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$output = $this->commandTester->getDisplay();
$this->assertContains('Continue with page 2?', $output);
$this->assertContains('Continue with page 3?', $output);
$this->assertContains('Continue with page 4?', $output);
}
/**
@@ -74,14 +79,23 @@ class ListShortcodesCommandTest extends TestCase
// The paginator will return more than one page
$data = [];
for ($i = 0; $i < 30; $i++) {
$data[] = (new ShortUrl())->setLongUrl('url_' . $i);
$data[] = new ShortUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledOnce();
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$output = $this->commandTester->getDisplay();
$this->assertContains('url_1', $output);
$this->assertContains('url_9', $output);
$this->assertNotContains('url_10', $output);
$this->assertNotContains('url_20', $output);
$this->assertNotContains('url_30', $output);
$this->assertContains('Continue with page 2?', $output);
$this->assertNotContains('Continue with page 3?', $output);
}
/**
@@ -91,7 +105,7 @@ class ListShortcodesCommandTest extends TestCase
{
$page = 5;
$this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
@@ -106,7 +120,7 @@ class ListShortcodesCommandTest extends TestCase
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([

View File

@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase
{
@@ -42,9 +43,9 @@ class ResolveUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = (new ShortUrl())->setLongUrl($expectedUrl);
$shortUrl = new ShortUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
@@ -61,7 +62,7 @@ class ResolveUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
@@ -78,7 +79,7 @@ class ResolveUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',

View File

@@ -61,8 +61,8 @@ class ListTagsCommandTest extends TestCase
{
/** @var MethodProphecy $listTags */
$listTags = $this->tagService->listTags()->willReturn([
(new Tag())->setName('foo'),
(new Tag())->setName('bar'),
new Tag('foo'),
new Tag('bar'),
]);
$this->commandTester->execute([]);

View File

@@ -68,7 +68,7 @@ class RenameTagCommandTest extends TestCase
$oldName = 'foo';
$newName = 'bar';
/** @var MethodProphecy $renameTag */
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag());
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag($newName));
$this->commandTester->execute([
'oldName' => $oldName,

View File

@@ -7,40 +7,63 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
use Shlinkio\Shlink\Common\Util\IpAddress;
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\Service\VisitService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock;
use Throwable;
use Zend\I18n\Translator\Translator;
use function count;
use function round;
use function array_shift;
use function sprintf;
class ProcessVisitsCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
private $commandTester;
/**
* @var ObjectProphecy
*/
protected $visitService;
private $visitService;
/**
* @var ObjectProphecy
*/
protected $ipResolver;
private $ipResolver;
/**
* @var ObjectProphecy
*/
private $locker;
/**
* @var ObjectProphecy
*/
private $lock;
public function setUp()
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
$this->ipResolver->getApiLimit()->willReturn(10000000000);
$this->locker = $this->prophesize(Lock\Factory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire()->willReturn(true);
$this->lock->release()->will(function () {
});
$this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$command = new ProcessVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
$this->locker->reveal(),
Translator::factory([])
);
$app = new Application();
@@ -52,92 +75,131 @@ class ProcessVisitsCommandTest extends TestCase
/**
* @test
*/
public function allReturnedVisitsIpsAreProcessed()
public function allPendingVisitsAreProcessed()
{
$visits = [
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
];
$this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1);
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
$location = new VisitLocation([]);
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits));
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits));
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location) {
$firstCallback = array_shift($args);
$firstCallback($visit);
$secondCallback = array_shift($args);
$secondCallback($location, $visit);
}
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
$this->commandTester->execute([
'command' => 'visit:process',
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Processing IP 1.2.3.0', $output);
$this->assertContains('Processing IP 4.3.2.0', $output);
$this->assertContains('Processing IP 12.34.56.0', $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideIgnoredAddresses
*/
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message)
{
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
$location = new VisitLocation([]);
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location) {
$firstCallback = array_shift($args);
$firstCallback($visit);
$secondCallback = array_shift($args);
$secondCallback($location, $visit);
}
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
try {
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
} catch (Throwable $e) {
$output = $this->commandTester->getDisplay();
$this->assertInstanceOf(IpCannotBeLocatedException::class, $e);
$this->assertContains($message, $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldNotHaveBeenCalled();
}
}
public function provideIgnoredAddresses(): array
{
return [
['', 'Ignored visit with no IP address'],
[null, 'Ignored visit with no IP address'],
[IpAddress::LOCALHOST, 'Ignored localhost address'],
];
}
/**
* @test
*/
public function localhostAndEmptyAddressIsIgnored()
public function errorWhileLocatingIpIsDisplayed()
{
$visits = [
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('127.0.0.1'),
(new Visit())->setRemoteAddr('127.0.0.1'),
(new Visit())->setRemoteAddr(''),
(new Visit())->setRemoteAddr(null),
];
$this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1);
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
$location = new VisitLocation([]);
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 4);
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits) - 4);
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location) {
$firstCallback = array_shift($args);
$firstCallback($visit);
$secondCallback = array_shift($args);
$secondCallback($location, $visit);
}
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
try {
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
} catch (Throwable $e) {
$output = $this->commandTester->getDisplay();
$this->assertInstanceOf(IpCannotBeLocatedException::class, $e);
$this->assertContains('An error occurred while locating IP. Skipped', $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
}
/**
* @test
*/
public function noActionIsPerformedIfLockIsAcquired()
{
$this->lock->acquire()->willReturn(false);
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(function () {
});
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Ignored localhost address', $output);
$this->assertContains('Ignored visit with no IP address', $output);
}
/**
* @test
*/
public function sleepsEveryTimeTheApiLimitIsReached()
{
$visits = [
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('4.3.2.1'),
];
$apiLimit = 3;
$this->visitService->getUnlocatedVisits()->willReturn($visits);
$this->visitService->saveVisit(Argument::any())->will(function () {
});
$getApiLimit = $this->ipResolver->getApiLimit()->willReturn($apiLimit);
$getApiInterval = $this->ipResolver->getApiInterval()->willReturn(0);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits));
$this->commandTester->execute([
'command' => 'visit:process',
]);
$getApiLimit->shouldHaveBeenCalledTimes(count($visits));
$getApiInterval->shouldHaveBeenCalledTimes(round(count($visits) / $apiLimit));
$resolveIpLocation->shouldHaveBeenCalledTimes(count($visits));
$this->assertContains(
sprintf('There is already an instance of the "%s" command', ProcessVisitsCommand::NAME),
$output
);
$locateVisits->shouldNotHaveBeenCalled();
$resolveIpLocation->shouldNotHaveBeenCalled();
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\UpdateDbCommand;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class UpdateDbCommandTest extends TestCase
{
/**
* @var CommandTester
*/
private $commandTester;
/**
* @var ObjectProphecy
*/
private $dbUpdater;
public function setUp()
{
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$command = new UpdateDbCommand($this->dbUpdater->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function successMessageIsPrintedIfEverythingWorks()
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->will(function () {
});
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('GeoLite2 database properly updated', $output);
$download->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function errorMessageIsPrintedIfAnExceptionIsThrown()
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->willThrow(RuntimeException::class);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('An error occurred while updating GeoLite2 database', $output);
$download->shouldHaveBeenCalledOnce();
}
}

View File

@@ -4,12 +4,15 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Factory;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\ServiceManager;
use function array_merge;
class ApplicationFactoryTest extends TestCase
{
@@ -28,7 +31,7 @@ class ApplicationFactoryTest extends TestCase
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke($this->createServiceManager(), '');
$instance = ($this->factory)($this->createServiceManager(), '');
$this->assertInstanceOf(Application::class, $instance);
}
@@ -39,21 +42,24 @@ class ApplicationFactoryTest extends TestCase
{
$sm = $this->createServiceManager([
'commands' => [
'foo',
'bar',
'baz',
'foo' => 'foo',
'bar' => 'bar',
'baz' => 'baz',
],
]);
$sm->setService('foo', $this->prophesize(Command::class)->reveal());
$sm->setService('baz', $this->prophesize(Command::class)->reveal());
$sm->setService('foo', $this->createCommandMock('foo')->reveal());
$sm->setService('bar', $this->createCommandMock('bar')->reveal());
/** @var Application $instance */
$instance = $this->factory->__invoke($sm, '');
$instance = ($this->factory)($sm, '');
$this->assertInstanceOf(Application::class, $instance);
$this->assertCount(2, $instance->all());
$this->assertTrue($instance->has('foo'));
$this->assertTrue($instance->has('bar'));
$this->assertFalse($instance->has('baz'));
}
protected function createServiceManager($config = [])
private function createServiceManager(array $config = []): ServiceManager
{
return new ServiceManager(['services' => [
'config' => [
@@ -63,4 +69,17 @@ class ApplicationFactoryTest extends TestCase
Translator::class => Translator::factory([]),
]]);
}
private function createCommandMock(string $name): ObjectProphecy
{
$command = $this->prophesize(Command::class);
$command->getName()->willReturn($name);
$command->getDefinition()->willReturn($name);
$command->isEnabled()->willReturn(true);
$command->getAliases()->willReturn([]);
$command->setApplication(Argument::type(Application::class))->willReturn(function () {
});
return $command;
}
}

View File

@@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\Common;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use GeoIp2\Database\Reader;
use GuzzleHttp\Client as GuzzleClient;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
@@ -13,6 +14,7 @@ use Symfony\Component\Filesystem\Filesystem;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\Proxy\LazyServiceFactory;
return [
@@ -23,6 +25,7 @@ return [
Cache::class => Factory\CacheFactory::class,
'Logger_Shlink' => Factory\LoggerFactory::class,
Filesystem::class => InvokableFactory::class,
Reader::class => ConfigAbstractFactory::class,
Translator::class => Factory\TranslatorFactory::class,
Template\Extension\TranslatorExtension::class => ConfigAbstractFactory::class,
@@ -32,26 +35,64 @@ return [
Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
Service\IpApiLocationResolver::class => ConfigAbstractFactory::class,
IpGeolocation\IpApiLocationResolver::class => ConfigAbstractFactory::class,
IpGeolocation\GeoLite2LocationResolver::class => ConfigAbstractFactory::class,
IpGeolocation\EmptyIpLocationResolver::class => InvokableFactory::class,
IpGeolocation\ChainIpLocationResolver::class => ConfigAbstractFactory::class,
IpGeolocation\GeoLite2\GeoLite2Options::class => ConfigAbstractFactory::class,
IpGeolocation\GeoLite2\DbUpdater::class => ConfigAbstractFactory::class,
Service\PreviewGenerator::class => ConfigAbstractFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
'httpClient' => GuzzleClient::class,
'translator' => Translator::class,
'logger' => LoggerInterface::class,
Logger::class => 'Logger_Shlink',
LoggerInterface::class => 'Logger_Shlink',
IpGeolocation\IpLocationResolverInterface::class => IpGeolocation\ChainIpLocationResolver::class,
],
'abstract_factories' => [
Factory\DottedAccessConfigAbstractFactory::class,
],
'delegators' => [
// The GeoLite2 db reader has to be lazy so that it does not try to load the DB file at app bootstrapping.
// By doing so, it would fail the first time shlink tries to download it.
Reader::class => [
LazyServiceFactory::class,
],
],
'lazy_services' => [
'class_map' => [
Reader::class => Reader::class,
],
],
],
ConfigAbstractFactory::class => [
Reader::class => ['config.geolite2.db_location'],
Template\Extension\TranslatorExtension::class => ['translator'],
Middleware\LocaleMiddleware::class => ['translator'],
Service\IpApiLocationResolver::class => ['httpClient'],
IpGeolocation\IpApiLocationResolver::class => ['httpClient'],
IpGeolocation\GeoLite2LocationResolver::class => [Reader::class],
IpGeolocation\ChainIpLocationResolver::class => [
IpGeolocation\GeoLite2LocationResolver::class,
IpGeolocation\IpApiLocationResolver::class,
IpGeolocation\EmptyIpLocationResolver::class,
],
IpGeolocation\GeoLite2\GeoLite2Options::class => ['config.geolite2'],
IpGeolocation\GeoLite2\DbUpdater::class => [
GuzzleClient::class,
Filesystem::class,
IpGeolocation\GeoLite2\GeoLite2Options::class,
],
Service\PreviewGenerator::class => [
Image\ImageBuilder::class,
Filesystem::class,

View File

@@ -4,13 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use const JSON_ERROR_NONE;
use function array_key_exists;
use function array_shift;
use function getenv;
use function in_array;
use function is_array;
use function json_decode as spl_json_decode;
use function json_last_error;
use function json_last_error_msg;
use function sprintf;
use function strtolower;
use function trim;
@@ -48,53 +46,15 @@ function env($key, $default = null)
return trim($value);
}
function contains($needle, array $haystack): bool
{
return in_array($needle, $haystack, true);
}
/**
* @throws Exception\InvalidArgumentException
*/
function json_decode(string $json, int $depth = 512, int $options = 0): array
{
$data = \json_decode($json, true, $depth, $options);
$data = spl_json_decode($json, true, $depth, $options);
if (JSON_ERROR_NONE !== json_last_error()) {
throw new Exception\InvalidArgumentException('Error decoding JSON: ' . json_last_error_msg());
throw new Exception\InvalidArgumentException(sprintf('Error decoding JSON: %s', json_last_error_msg()));
}
return $data;
}
function array_path_exists(array $path, array $array): bool
{
// As soon as a step is not found, the path does not exist
$step = array_shift($path);
if (! array_key_exists($step, $array)) {
return false;
}
// Once the path is empty, we have found all the parts in the path
if (empty($path)) {
return true;
}
// If current value is not an array, then we have not found the path
$newArray = $array[$step];
if (! is_array($newArray)) {
return false;
}
return array_path_exists($path, $newArray);
}
function array_get_path(array $path, array $array)
{
do {
$step = array_shift($path);
if (! is_array($array) || ! array_key_exists($step, $array)) {
return null;
}
$array = $array[$step];
} while (! empty($path));
return $array;
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Collection;
use function array_key_exists;
use function array_shift;
use function is_array;
final class PathCollection
{
/**
* @var array
*/
private $array;
public function __construct(array $array)
{
$this->array = $array;
}
public function pathExists(array $path): bool
{
return $this->checkPathExists($path, $this->array);
}
private function checkPathExists(array $path, array $array): bool
{
// As soon as a step is not found, the path does not exist
$step = array_shift($path);
if (! array_key_exists($step, $array)) {
return false;
}
// Once the path is empty, we have found all the parts in the path
if (empty($path)) {
return true;
}
// If current value is not an array, then we have not found the path
$newArray = $array[$step];
if (! is_array($newArray)) {
return false;
}
return $this->checkPathExists($path, $newArray);
}
/**
* @return mixed
*/
public function getValueInPath(array $path)
{
$array = $this->array;
do {
$step = array_shift($path);
if (! is_array($array) || ! array_key_exists($step, $array)) {
return null;
}
$array = $array[$step];
} while (! empty($path));
return $array;
}
}

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Exception;
interface ExceptionInterface extends \Throwable
use Throwable;
interface ExceptionInterface extends Throwable
{
}

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Exception;
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
use InvalidArgumentException as SplInvalidArgumentException;
class InvalidArgumentException extends SplInvalidArgumentException implements ExceptionInterface
{
}

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Exception;
use function sprintf;
class PreviewGenerationException extends RuntimeException
{
public static function fromImageError($error)

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Exception;
class RuntimeException extends \RuntimeException implements ExceptionInterface
use RuntimeException as SplRuntimeException;
class RuntimeException extends SplRuntimeException implements ExceptionInterface
{
}

View File

@@ -3,11 +3,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Exception;
use Throwable;
use function sprintf;
class WrongIpException extends RuntimeException
{
public static function fromIpAddress($ipAddress, \Throwable $prev = null): self
public static function fromIpAddress($ipAddress, Throwable $prev = null): self
{
return new self(sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev);
}

View File

@@ -11,7 +11,7 @@ use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use function Shlinkio\Shlink\Common\contains;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
class CacheFactory implements FactoryInterface
@@ -45,15 +45,11 @@ class CacheFactory implements FactoryInterface
return $adapter;
}
/**
* @param ContainerInterface $container
* @return Cache\CacheProvider
*/
protected function getAdapter(ContainerInterface $container)
private function getAdapter(ContainerInterface $container): Cache\CacheProvider
{
// Try to get the adapter from config
$config = $container->get('config');
if (isset($config['cache']['adapter']) && contains($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS)) {
if (isset($config['cache']['adapter']) && contains(self::VALID_CACHE_ADAPTERS, $config['cache']['adapter'])) {
return $this->resolveCacheAdapter($config['cache']);
}
@@ -61,11 +57,7 @@ class CacheFactory implements FactoryInterface
return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
}
/**
* @param array $cacheConfig
* @return Cache\CacheProvider
*/
protected function resolveCacheAdapter(array $cacheConfig)
private function resolveCacheAdapter(array $cacheConfig): Cache\CacheProvider
{
switch ($cacheConfig['adapter']) {
case Cache\ArrayCache::class:

View File

@@ -3,12 +3,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Factory;
use ArrayAccess;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\AbstractFactoryInterface;
use function array_shift;
use function explode;
use function is_array;
use function sprintf;
use function substr_count;
class DottedAccessConfigAbstractFactory implements AbstractFactoryInterface
{
@@ -72,7 +78,7 @@ class DottedAccessConfigAbstractFactory implements AbstractFactoryInterface
}
$value = $array[$key];
if (! empty($keys) && (is_array($value) || $value instanceof \ArrayAccess)) {
if (! empty($keys) && (is_array($value) || $value instanceof ArrayAccess)) {
$value = $this->readKeysFromArray($keys, $value);
}

View File

@@ -33,7 +33,9 @@ class EntityManagerFactory implements FactoryInterface
$connectionConfig = $emConfig['connection'] ?? [];
$ormConfig = $emConfig['orm'] ?? [];
Type::addType(ChronosDateTimeType::CHRONOS_DATETIME, ChronosDateTimeType::class);
if (! Type::hasType(ChronosDateTimeType::CHRONOS_DATETIME)) {
Type::addType(ChronosDateTimeType::CHRONOS_DATETIME, ChronosDateTimeType::class);
}
return EntityManager::create($connectionConfig, Setup::createAnnotationMetadataConfiguration(
$ormConfig['entities_paths'] ?? [],

View File

@@ -9,6 +9,8 @@ use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use function count;
use function explode;
class LoggerFactory implements FactoryInterface
{
@@ -27,10 +29,10 @@ class LoggerFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->has('config') ? $container->get('config') : [];
Cascade::fileConfig(isset($config['logger']) ? $config['logger'] : ['loggers' => []]);
Cascade::fileConfig($config['logger'] ?? ['loggers' => []]);
// Compose requested logger name
$loggerName = isset($options) & isset($options['logger_name']) ? $options['logger_name'] : 'Logger';
$loggerName = $options['logger_name'] ?? 'Logger';
$nameParts = explode('_', $requestedName);
if (count($nameParts) > 1) {
$loggerName = $nameParts[1];

View File

@@ -27,9 +27,9 @@ class ImageFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config')['phpwkhtmltopdf'];
$image = new Image(isset($config['images']) ? $config['images'] : null);
$image = new Image($config['images'] ?? null);
if (isset($options) && isset($options['url'])) {
if ($options['url'] ?? null) {
$image->setPage($options['url']);
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class ChainIpLocationResolver implements IpLocationResolverInterface
{
/**
* @var IpLocationResolverInterface[]
*/
private $resolvers;
public function __construct(IpLocationResolverInterface ...$resolvers)
{
$this->resolvers = $resolvers;
}
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
$error = null;
foreach ($this->resolvers as $resolver) {
try {
return $resolver->resolveIpLocation($ipAddress);
} catch (WrongIpException $e) {
$error = $e;
}
}
// If this instruction is reached, it means no resolver was capable of resolving the address
throw WrongIpException::fromIpAddress($ipAddress, $error);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class EmptyIpLocationResolver implements IpLocationResolverInterface
{
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
return [
'country_code' => '',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => '',
'longitude' => '',
'time_zone' => '',
];
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation\GeoLite2;
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use PharData;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Symfony\Component\Filesystem\Exception as FilesystemException;
use Symfony\Component\Filesystem\Filesystem;
use Throwable;
use function sprintf;
class DbUpdater implements DbUpdaterInterface
{
private const DB_COMPRESSED_FILE = 'GeoLite2-City.tar.gz';
private const DB_DECOMPRESSED_FILE = 'GeoLite2-City.mmdb';
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var Filesystem
*/
private $filesystem;
/**
* @var GeoLite2Options
*/
private $options;
public function __construct(ClientInterface $httpClient, Filesystem $filesystem, GeoLite2Options $options)
{
$this->httpClient = $httpClient;
$this->filesystem = $filesystem;
$this->options = $options;
}
/**
* @throws RuntimeException
*/
public function downloadFreshCopy(callable $handleProgress = null): void
{
$tempDir = $this->options->getTempDir();
$compressedFile = sprintf('%s/%s', $tempDir, self::DB_COMPRESSED_FILE);
$this->downloadDbFile($compressedFile, $handleProgress);
$tempFullPath = $this->extractDbFile($compressedFile, $tempDir);
$this->copyNewDbFile($tempFullPath);
$this->deleteTempFiles([$compressedFile, $tempFullPath]);
}
private function downloadDbFile(string $dest, callable $handleProgress = null): void
{
try {
$this->httpClient->request(RequestMethod::METHOD_GET, $this->options->getDownloadFrom(), [
RequestOptions::SINK => $dest,
RequestOptions::PROGRESS => $handleProgress,
]);
} catch (Throwable | GuzzleException $e) {
throw new RuntimeException(
'An error occurred while trying to download a fresh copy of the GeoLite2 database',
0,
$e
);
}
}
private function extractDbFile(string $compressedFile, string $tempDir): string
{
try {
$phar = new PharData($compressedFile);
$internalPathToDb = sprintf('%s/%s', $phar->getBasename(), self::DB_DECOMPRESSED_FILE);
$phar->extractTo($tempDir, $internalPathToDb, true);
return sprintf('%s/%s', $tempDir, $internalPathToDb);
} catch (Throwable $e) {
throw new RuntimeException(
sprintf('An error occurred while trying to extract the GeoLite2 database from %s', $compressedFile),
0,
$e
);
}
}
private function copyNewDbFile(string $from): void
{
try {
$this->filesystem->copy($from, $this->options->getDbLocation(), true);
} catch (FilesystemException\FileNotFoundException | FilesystemException\IOException $e) {
throw new RuntimeException('An error occurred while trying to copy GeoLite2 db file to destination', 0, $e);
}
}
private function deleteTempFiles(array $files): void
{
try {
$this->filesystem->remove($files);
} catch (FilesystemException\IOException $e) {
// Ignore any error produced when trying to delete temp files
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation\GeoLite2;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
interface DbUpdaterInterface
{
/**
* @throws RuntimeException
*/
public function downloadFreshCopy(callable $handleProgress = null): void;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation\GeoLite2;
use Zend\Stdlib\AbstractOptions;
class GeoLite2Options extends AbstractOptions
{
private $dbLocation = '';
private $tempDir = '';
private $downloadFrom = 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz';
public function getDbLocation(): string
{
return $this->dbLocation;
}
protected function setDbLocation(string $dbLocation): self
{
$this->dbLocation = $dbLocation;
return $this;
}
public function getTempDir(): string
{
return $this->tempDir;
}
protected function setTempDir(string $tempDir): self
{
$this->tempDir = $tempDir;
return $this;
}
public function getDownloadFrom(): string
{
return $this->downloadFrom;
}
protected function setDownloadFrom(string $downloadFrom): self
{
$this->downloadFrom = $downloadFrom;
return $this;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use GeoIp2\Model\City;
use GeoIp2\Record\Subdivision;
use MaxMind\Db\Reader\InvalidDatabaseException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use function Functional\first;
class GeoLite2LocationResolver implements IpLocationResolverInterface
{
/**
* @var Reader
*/
private $geoLiteDbReader;
public function __construct(Reader $geoLiteDbReader)
{
$this->geoLiteDbReader = $geoLiteDbReader;
}
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
try {
$city = $this->geoLiteDbReader->city($ipAddress);
return $this->mapFields($city);
} catch (AddressNotFoundException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
} catch (InvalidDatabaseException $e) {
throw new WrongIpException('Provided GeoLite2 db file is invalid', 0, $e);
}
}
private function mapFields(City $city): array
{
/** @var Subdivision $region */
$region = first($city->subdivisions);
return [
'country_code' => $city->country->isoCode ?? '',
'country_name' => $city->country->name ?? '',
'region_name' => $region->name ?? '',
'city' => $city->city->name ?? '',
'latitude' => $city->location->latitude ?? '',
'longitude' => $city->location->longitude ?? '',
'time_zone' => $city->location->timeZone ?? '',
];
}
}

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
namespace Shlinkio\Shlink\Common\IpGeolocation;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
@@ -25,8 +25,6 @@ class IpApiLocationResolver implements IpLocationResolverInterface
}
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
@@ -53,24 +51,4 @@ class IpApiLocationResolver implements IpLocationResolverInterface
'time_zone' => $entry['timezone'] ?? '',
];
}
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int
{
return 65; // ip-api interval is 1 minute. Return 5 extra seconds just in case
}
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int
{
return 145; // ip-api limit is 150 requests per minute. Leave 5 less requests just in case
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
interface IpLocationResolverInterface
{
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Logger\Processor;
use const PHP_EOL;
use function str_replace;
use function strpos;
final class ExceptionWithNewLineProcessor
{
private const EXCEPTION_PLACEHOLDER = '{e}';
public function __invoke(array $record)
{
$message = $record['message'];
$messageHasExceptionPlaceholder = strpos($message, self::EXCEPTION_PLACEHOLDER) !== false;
if ($messageHasExceptionPlaceholder) {
$record['message'] = str_replace(
self::EXCEPTION_PLACEHOLDER,
PHP_EOL . self::EXCEPTION_PLACEHOLDER,
$message
);
}
return $record;
}
}

View File

@@ -8,6 +8,8 @@ use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as DelegateInterface;
use Zend\I18n\Translator\Translator;
use function count;
use function explode;
class LocaleMiddleware implements MiddlewareInterface
{
@@ -43,11 +45,7 @@ class LocaleMiddleware implements MiddlewareInterface
return $delegate->handle($request);
}
/**
* @param string $locale
* @return string
*/
protected function normalizeLocale($locale)
private function normalizeLocale(string $locale): string
{
$parts = explode('_', $locale);
if (count($parts) > 1) {

View File

@@ -5,6 +5,8 @@ namespace Shlinkio\Shlink\Common\Paginator\Adapter;
use Shlinkio\Shlink\Common\Repository\PaginableRepositoryInterface;
use Zend\Paginator\Adapter\AdapterInterface;
use function strip_tags;
use function trim;
class PaginableRepositoryAdapter implements AdapterInterface
{
@@ -34,7 +36,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
$orderBy = null
) {
$this->paginableRepository = $paginableRepository;
$this->searchTerm = $searchTerm !== null ? \trim(\strip_tags($searchTerm)) : null;
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
$this->orderBy = $orderBy;
$this->tags = $tags;
}

View File

@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Common\Paginator\Util;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Zend\Paginator\Paginator;
use Zend\Stdlib\ArrayUtils;
use function array_map;
trait PaginatorUtilsTrait
{
@@ -25,7 +26,7 @@ trait PaginatorUtilsTrait
private function serializeItems(array $items, ?DataTransformerInterface $transformer = null): array
{
return $transformer === null ? $items : \array_map([$transformer, 'transform'], $items);
return $transformer === null ? $items : array_map([$transformer, 'transform'], $items);
}
/**

View File

@@ -3,16 +3,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Response;
use Fig\Http\Message\StatusCodeInterface as StatusCode;
use Psr\Http\Message\StreamInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
use function base64_decode;
class PixelResponse extends Response
{
private const BASE_64_IMAGE = 'R0lGODlhAQABAJAAAP8AAAAAACH5BAUQAAAALAAAAAABAAEAAAICBAEAOw==';
private const CONTENT_TYPE = 'image/gif';
public function __construct(int $status = 200, array $headers = [])
public function __construct(int $status = StatusCode::STATUS_OK, array $headers = [])
{
$headers['content-type'] = self::CONTENT_TYPE;
parent::__construct($this->createBody(), $status, $headers);
@@ -26,7 +28,7 @@ class PixelResponse extends Response
private function createBody(): StreamInterface
{
$body = new Stream('php://temp', 'wb+');
$body->write((string) \base64_decode(self::BASE_64_IMAGE));
$body->write(base64_decode(self::BASE_64_IMAGE));
$body->rewind();
return $body;
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Response;
use Endroid\QrCode\QrCode;
use Fig\Http\Message\StatusCodeInterface as StatusCode;
use Psr\Http\Message\StreamInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
@@ -12,7 +13,7 @@ class QrCodeResponse extends Response
{
use Response\InjectContentTypeTrait;
public function __construct(QrCode $qrCode, $status = 200, array $headers = [])
public function __construct(QrCode $qrCode, int $status = StatusCode::STATUS_OK, array $headers = [])
{
parent::__construct(
$this->createBody($qrCode),
@@ -21,13 +22,7 @@ class QrCodeResponse extends Response
);
}
/**
* Create the message body.
*
* @param QrCode $qrCode
* @return StreamInterface
*/
private function createBody(QrCode $qrCode)
private function createBody(QrCode $qrCode): StreamInterface
{
$body = new Stream('php://temp', 'wb+');
$body->write($qrCode->get());

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
interface IpLocationResolverInterface
{
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array;
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int;
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int;
}

View File

@@ -7,6 +7,8 @@ use mikehaertl\wkhtmlto\Image;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Image\ImageBuilderInterface;
use Symfony\Component\Filesystem\Filesystem;
use function sprintf;
use function urlencode;
class PreviewGenerator implements PreviewGeneratorInterface
{
@@ -33,11 +35,9 @@ class PreviewGenerator implements PreviewGeneratorInterface
/**
* Generates and stores preview for provided website and returns the path to the image file
*
* @param string $url
* @return string
* @throws PreviewGenerationException
*/
public function generatePreview($url)
public function generatePreview(string $url): string
{
/** @var Image $image */
$image = $this->imageBuilder->build(Image::class, ['url' => $url]);

View File

@@ -10,9 +10,7 @@ interface PreviewGeneratorInterface
/**
* Generates and stores preview for provided website and returns the path to the image file
*
* @param string $url
* @return string
* @throws PreviewGenerationException
*/
public function generatePreview($url);
public function generatePreview(string $url): string;
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Type;
use Cake\Chronos\Chronos;
use DateTimeInterface;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\DateTimeImmutableType;
@@ -39,14 +40,14 @@ class ChronosDateTimeType extends DateTimeImmutableType
return $value;
}
if ($value instanceof \DateTimeInterface) {
if ($value instanceof DateTimeInterface) {
return $value->format($platform->getDateTimeFormatString());
}
throw ConversionException::conversionFailedInvalidType(
$value,
$this->getName(),
['null', \DateTimeInterface::class]
['null', DateTimeInterface::class]
);
}
}

View File

@@ -3,35 +3,26 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Util;
use Fig\Http\Message\StatusCodeInterface as StatusCode;
use finfo;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
use Zend\Stdlib\ArrayUtils;
use const FILEINFO_MIME;
trait ResponseUtilsTrait
{
protected function generateDownloadFileResponse(string $filePath): ResponseInterface
{
return $this->generateBinaryResponse($filePath, [
'Content-Disposition' => 'attachment; filename=' . basename($filePath),
'Content-Transfer-Encoding' => 'Binary',
'Content-Description' => 'File Transfer',
'Pragma' => 'public',
'Expires' => '0',
'Cache-Control' => 'must-revalidate',
]);
}
protected function generateImageResponse(string $imagePath): ResponseInterface
private function generateImageResponse(string $imagePath): ResponseInterface
{
return $this->generateBinaryResponse($imagePath);
}
protected function generateBinaryResponse(string $path, array $extraHeaders = []): ResponseInterface
private function generateBinaryResponse(string $path, array $extraHeaders = []): ResponseInterface
{
$body = new Stream($path);
return new Response($body, 200, ArrayUtils::merge([
'Content-Type' => (new \finfo(FILEINFO_MIME))->file($path),
return new Response($body, StatusCode::STATUS_OK, ArrayUtils::merge([
'Content-Type' => (new finfo(FILEINFO_MIME))->file($path),
'Content-Length' => (string) $body->getSize(),
], $extraHeaders));
}

View File

@@ -3,40 +3,44 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Util;
use function random_int;
use function sprintf;
use function strlen;
trait StringUtilsTrait
{
protected function generateRandomString($length = 10)
private function generateRandomString(int $length = 10): string
{
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[mt_rand(0, $charactersLength - 1)];
$randomString .= $characters[random_int(0, $charactersLength - 1)];
}
return $randomString;
}
protected function generateV4Uuid()
private function generateV4Uuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
// 32 bits for "time_low"
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
random_int(0, 0xffff),
random_int(0, 0xffff),
// 16 bits for "time_mid"
mt_rand(0, 0xffff),
random_int(0, 0xffff),
// 16 bits for "time_hi_and_version",
// four most significant bits holds version number 4
mt_rand(0, 0x0fff) | 0x4000,
random_int(0, 0x0fff) | 0x4000,
// 16 bits, 8 bits for "clk_seq_hi_res",
// 8 bits for "clk_seq_low",
// two most significant bits holds zero and one for variant DCE1.1
mt_rand(0, 0x3fff) | 0x8000,
random_int(0, 0x3fff) | 0x8000,
// 48 bits for "node"
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff)
random_int(0, 0xffff),
random_int(0, 0xffff),
random_int(0, 0xffff)
);
}
}

View File

@@ -0,0 +1 @@
geolite2-testing-db

Binary file not shown.

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Collection;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Collection\PathCollection;
class PathCollectionTest extends TestCase
{
/**
* @var PathCollection
*/
private $collection;
public function setUp()
{
$this->collection = new PathCollection([
'foo' => [
'bar' => [
'baz' => 'Hello world!',
],
],
'something' => [],
'another' => [
'one' => 'Shlink',
],
]);
}
/**
* @test
* @dataProvider providePaths
*/
public function pathExistsReturnsExpectedValue(array $path, bool $expected)
{
$this->assertEquals($expected, $this->collection->pathExists($path));
}
public function providePaths(): array
{
return [
[[], false],
[['boo'], false],
[['foo', 'nop'], false],
[['another', 'one', 'nop'], false],
[['foo'], true],
[['foo', 'bar'], true],
[['foo', 'bar', 'baz'], true],
[['something'], true],
];
}
/**
* @test
* @dataProvider providePathsWithValue
*/
public function getValueInPathReturnsExpectedValue(array $path, $expected)
{
$this->assertEquals($expected, $this->collection->getValueInPath($path));
}
public function providePathsWithValue(): array
{
return [
[[], null],
[['boo'], null],
[['foo', 'nop'], null],
[['another', 'one', 'nop'], null],
[['foo'], [
'bar' => [
'baz' => 'Hello world!',
],
]],
[['foo', 'bar'], [
'baz' => 'Hello world!',
]],
[['foo', 'bar', 'baz'], 'Hello world!'],
[['something'], []],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Exception;
use Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class WrongIpExceptionTest extends TestCase
{
/**
* @test
*/
public function fromIpAddressProperlyCreatesExceptionWithoutPrev()
{
$e = WrongIpException::fromIpAddress('1.2.3.4');
$this->assertEquals('Provided IP "1.2.3.4" is invalid', $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertNull($e->getPrevious());
}
/**
* @test
*/
public function fromIpAddressProperlyCreatesExceptionWithPrev()
{
$prev = new Exception('Previous error');
$e = WrongIpException::fromIpAddress('1.2.3.4', $prev);
$this->assertEquals('Provided IP "1.2.3.4" is invalid', $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertSame($prev, $e->getPrevious());
}
}

View File

@@ -12,6 +12,10 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Factory\CacheFactory;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\ServiceManager;
use function count;
use function putenv;
use function realpath;
use function sys_get_temp_dir;
class CacheFactoryTest extends TestCase
{

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Factory;
use PHPUnit\Framework\TestCase;
use ReflectionObject;
use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
@@ -37,7 +38,7 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase
{
$instance = $this->factory->__invoke(new ServiceManager(), '');
$ref = new \ReflectionObject($instance);
$ref = new ReflectionObject($instance);
$prop = $ref->getProperty('responseFactory');
$prop->setAccessible(true);
$this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance)());

View File

@@ -5,6 +5,7 @@ namespace ShlinkioTest\Shlink\Common\Image;
use mikehaertl\wkhtmlto\Image;
use PHPUnit\Framework\TestCase;
use ReflectionObject;
use Shlinkio\Shlink\Common\Image\ImageFactory;
use Zend\ServiceManager\ServiceManager;
@@ -31,7 +32,7 @@ class ImageFactoryTest extends TestCase
]]), '');
$this->assertInstanceOf(Image::class, $image);
$ref = new \ReflectionObject($image);
$ref = new ReflectionObject($image);
$page = $ref->getProperty('_page');
$page->setAccessible(true);
$this->assertNull($page->getValue($image));
@@ -50,7 +51,7 @@ class ImageFactoryTest extends TestCase
]]), '', ['url' => $expectedPage]);
$this->assertInstanceOf(Image::class, $image);
$ref = new \ReflectionObject($image);
$ref = new ReflectionObject($image);
$page = $ref->getProperty('_page');
$page->setAccessible(true);
$this->assertEquals($expectedPage, $page->getValue($image));

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\IpGeolocation;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\ChainIpLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
class ChainIpLocationResolverTest extends TestCase
{
/**
* @var ChainIpLocationResolver
*/
private $resolver;
/**
* @var ObjectProphecy
*/
private $firstInnerResolver;
/**
* @var ObjectProphecy
*/
private $secondInnerResolver;
public function setUp()
{
$this->firstInnerResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->secondInnerResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->resolver = new ChainIpLocationResolver(
$this->firstInnerResolver->reveal(),
$this->secondInnerResolver->reveal()
);
}
/**
* @test
*/
public function throwsExceptionWhenNoInnerResolverCanHandleTheResolution()
{
$ipAddress = '1.2.3.4';
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$this->expectException(WrongIpException::class);
$firstResolve->shouldBeCalledOnce();
$secondResolve->shouldBeCalledOnce();
$this->resolver->resolveIpLocation($ipAddress);
}
/**
* @test
*/
public function returnsResultOfFirstInnerResolver()
{
$ipAddress = '1.2.3.4';
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willReturn([]);
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$this->resolver->resolveIpLocation($ipAddress);
$firstResolve->shouldHaveBeenCalledOnce();
$secondResolve->shouldNotHaveBeenCalled();
}
/**
* @test
*/
public function returnsResultOfSecondInnerResolver()
{
$ipAddress = '1.2.3.4';
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willReturn([]);
$this->resolver->resolveIpLocation($ipAddress);
$firstResolve->shouldHaveBeenCalledOnce();
$secondResolve->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\IpGeolocation;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\IpGeolocation\EmptyIpLocationResolver;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use function Functional\map;
use function range;
class EmptyIpLocationResolverTest extends TestCase
{
use StringUtilsTrait;
private const EMPTY_RESP = [
'country_code' => '',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => '',
'longitude' => '',
'time_zone' => '',
];
/**
* @var EmptyIpLocationResolver
*/
private $resolver;
public function setUp()
{
$this->resolver = new EmptyIpLocationResolver();
}
/**
* @test
* @dataProvider provideEmptyResponses
*/
public function alwaysReturnsAnEmptyResponse(array $expected, string $ipAddress)
{
$this->assertEquals($expected, $this->resolver->resolveIpLocation($ipAddress));
}
public function provideEmptyResponses(): array
{
return map(range(0, 5), function () {
return [self::EMPTY_RESP, $this->generateRandomString(10)];
});
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\IpGeolocation\GeoLite2;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\GeoLite2Options;
use Symfony\Component\Filesystem\Exception as FilesystemException;
use Symfony\Component\Filesystem\Filesystem;
use Zend\Diactoros\Response;
class DbUpdaterTest extends TestCase
{
/**
* @var DbUpdater
*/
private $dbUpdater;
/**
* @var ObjectProphecy
*/
private $httpClient;
/**
* @var ObjectProphecy
*/
private $filesystem;
/**
* @var GeoLite2Options
*/
private $options;
public function setUp()
{
$this->httpClient = $this->prophesize(ClientInterface::class);
$this->filesystem = $this->prophesize(Filesystem::class);
$this->options = new GeoLite2Options([
'temp_dir' => __DIR__ . '/../../../test-resources',
'db_location' => '',
'download_from' => '',
]);
$this->dbUpdater = new DbUpdater($this->httpClient->reveal(), $this->filesystem->reveal(), $this->options);
}
/**
* @test
*/
public function anExceptionIsThrownIfFreshDbCannotBeDownloaded()
{
$request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class);
$this->expectException(RuntimeException::class);
$this->expectExceptionCode(0);
$this->expectExceptionMessage(
'An error occurred while trying to download a fresh copy of the GeoLite2 database'
);
$request->shouldBeCalledOnce();
$this->dbUpdater->downloadFreshCopy();
}
/**
* @test
*/
public function anExceptionIsThrownIfFreshDbCannotBeExtracted()
{
$this->options->tempDir = '__invalid__';
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$this->expectException(RuntimeException::class);
$this->expectExceptionCode(0);
$this->expectExceptionMessage(
'An error occurred while trying to extract the GeoLite2 database from __invalid__/GeoLite2-City.tar.gz'
);
$request->shouldBeCalledOnce();
$this->dbUpdater->downloadFreshCopy();
}
/**
* @test
* @dataProvider provideFilesystemExceptions
*/
public function anExceptionIsThrownIfFreshDbCannotBeCopiedToDestination(string $e)
{
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$copy = $this->filesystem->copy(Argument::cetera())->willThrow($e);
$this->expectException(RuntimeException::class);
$this->expectExceptionCode(0);
$this->expectExceptionMessage('An error occurred while trying to copy GeoLite2 db file to destination');
$request->shouldBeCalledOnce();
$copy->shouldBeCalledOnce();
$this->dbUpdater->downloadFreshCopy();
}
public function provideFilesystemExceptions(): array
{
return [
[FilesystemException\FileNotFoundException::class],
[FilesystemException\IOException::class],
];
}
/**
* @test
*/
public function noExceptionsAreThrownIfEverythingWorksFine()
{
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$copy = $this->filesystem->copy(Argument::cetera())->will(function () {
});
$remove = $this->filesystem->remove(Argument::cetera())->will(function () {
});
$this->dbUpdater->downloadFreshCopy();
$request->shouldHaveBeenCalledOnce();
$copy->shouldHaveBeenCalledOnce();
$remove->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\IpGeolocation;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use GeoIp2\Model\City;
use MaxMind\Db\Reader\InvalidDatabaseException;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2LocationResolver;
class GeoLite2LocationResolverTest extends TestCase
{
/**
* @var GeoLite2LocationResolver
*/
private $resolver;
/**
* @var ObjectProphecy
*/
private $reader;
public function setUp()
{
$this->reader = $this->prophesize(Reader::class);
$this->resolver = new GeoLite2LocationResolver($this->reader->reveal());
}
/**
* @test
* @dataProvider provideReaderExceptions
*/
public function exceptionIsThrownIfReaderThrowsException(string $e, string $message)
{
$ipAddress = '1.2.3.4';
$cityMethod = $this->reader->city($ipAddress)->willThrow($e);
$this->expectException(WrongIpException::class);
$this->expectExceptionMessage($message);
$this->expectExceptionCode(0);
$cityMethod->shouldBeCalledOnce();
$this->resolver->resolveIpLocation($ipAddress);
}
public function provideReaderExceptions(): array
{
return [
[AddressNotFoundException::class, 'Provided IP "1.2.3.4" is invalid'],
[InvalidDatabaseException::class, 'Provided GeoLite2 db file is invalid'],
];
}
/**
* @test
*/
public function resolvedCityIsProperlyMapped()
{
$ipAddress = '1.2.3.4';
$city = new City([]);
$cityMethod = $this->reader->city($ipAddress)->willReturn($city);
$result = $this->resolver->resolveIpLocation($ipAddress);
$this->assertEquals([
'country_code' => '',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => '',
'longitude' => '',
'time_zone' => '',
], $result);
$cityMethod->shouldHaveBeenCalledOnce();
}
}

View File

@@ -1,14 +1,15 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Service;
namespace ShlinkioTest\Shlink\Common\IpGeolocation;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
use function json_encode;
class IpApiLocationResolverTest extends TestCase
{
@@ -47,11 +48,11 @@ class IpApiLocationResolverTest extends TestCase
'time_zone' => '',
];
$response = new Response();
$response->getBody()->write(\json_encode($actual));
$response->getBody()->write(json_encode($actual));
$response->getBody()->rewind();
$this->client->get('http://ip-api.com/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
@@ -62,23 +63,7 @@ class IpApiLocationResolverTest extends TestCase
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://ip-api.com/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
/**
* @test
*/
public function getApiIntervalReturnsExpectedValue()
{
$this->assertEquals(65, $this->ipResolver->getApiInterval());
}
/**
* @test
*/
public function getApiLimitReturnsExpectedValue()
{
$this->assertEquals(145, $this->ipResolver->getApiLimit());
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Logger\Processor;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Logger\Processor\ExceptionWithNewLineProcessor;
use const PHP_EOL;
class ExceptionWithNewLineProcessorTest extends TestCase
{
/**
* @var ExceptionWithNewLineProcessor
*/
private $processor;
public function setUp()
{
$this->processor = new ExceptionWithNewLineProcessor();
}
/**
* @test
* @dataProvider provideNoPlaceholderRecords
*/
public function keepsRecordAsIsWhenNoPlaceholderExists(array $record)
{
$this->assertSame($record, ($this->processor)($record));
}
public function provideNoPlaceholderRecords(): array
{
return [
[['message' => 'Hello World']],
[['message' => 'Shlink']],
[['message' => 'Foo bar']],
];
}
/**
* @test
* @dataProvider providePlaceholderRecords
*/
public function properlyReplacesExceptionPlaceholderAddingNewLine(array $record, array $expected)
{
$this->assertEquals($expected, ($this->processor)($record));
}
public function providePlaceholderRecords(): array
{
return [
[
['message' => 'Hello World with placeholder {e}'],
['message' => 'Hello World with placeholder ' . PHP_EOL . '{e}'],
],
[
['message' => '{e} Shlink'],
['message' => PHP_EOL . '{e} Shlink'],
],
[
['message' => 'Foo {e} bar'],
['message' => 'Foo ' . PHP_EOL . '{e} bar'],
],
];
}
}

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