mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-04 06:13:12 +08:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa77c944d8 | ||
|
|
b8faa6714a | ||
|
|
f48f98f4d7 | ||
|
|
79b2a0839f | ||
|
|
6094d17718 | ||
|
|
d2ed7d6417 | ||
|
|
a705ef21a9 | ||
|
|
67e465c479 | ||
|
|
ed3883b52c | ||
|
|
71ea0bcb5e | ||
|
|
dd2cffeee9 | ||
|
|
1ceabf3bc3 | ||
|
|
17fcd637f2 | ||
|
|
d44bc4b182 | ||
|
|
4760406221 | ||
|
|
0aae0d888c | ||
|
|
1bc01057f3 | ||
|
|
c1906606c6 | ||
|
|
1363194909 | ||
|
|
d945e0c31b | ||
|
|
0af7b75af5 | ||
|
|
36a42cb064 | ||
|
|
4db0acc0e7 | ||
|
|
8f4800aa47 | ||
|
|
4745a37549 | ||
|
|
8fc949898b | ||
|
|
d4758b0e91 | ||
|
|
a07e4b17be | ||
|
|
b9dd975bc6 | ||
|
|
9964d3e24b | ||
|
|
58e8c8e182 | ||
|
|
c7339f6cfa | ||
|
|
1aa78f766a | ||
|
|
bf56e6adaf | ||
|
|
e915b7e499 | ||
|
|
de0470d200 | ||
|
|
3d7cf6992e | ||
|
|
06db082e3f | ||
|
|
4a383cecaf | ||
|
|
9a0f9207be | ||
|
|
0e3a0a1eec | ||
|
|
fd6d180eba | ||
|
|
d152e2ef9a | ||
|
|
b530cf4461 | ||
|
|
bbe85cde31 | ||
|
|
2c3cbe7146 | ||
|
|
2358308f4d | ||
|
|
58bff4fa73 | ||
|
|
098f7afc70 | ||
|
|
4070b1e23d | ||
|
|
d9d4c8a70c | ||
|
|
05abe49d8b | ||
|
|
a71245b883 | ||
|
|
057f88a36a | ||
|
|
32fcdd9d94 | ||
|
|
313927827d | ||
|
|
358b2b661e | ||
|
|
3eddacdff8 | ||
|
|
95d4cde649 | ||
|
|
d1d947bf12 | ||
|
|
40815e5b38 | ||
|
|
8fc1d23e03 | ||
|
|
5ec8c229a1 | ||
|
|
2412ec2195 | ||
|
|
bfb96b0ae8 | ||
|
|
f64920e510 | ||
|
|
664dc333ac | ||
|
|
521f6f2b18 | ||
|
|
6986d03c53 | ||
|
|
e6e38e3ca2 | ||
|
|
951d08f914 | ||
|
|
8e1e8ba7de | ||
|
|
877b098b09 | ||
|
|
e046eddda9 | ||
|
|
084b1169d7 | ||
|
|
f7ceeff05a | ||
|
|
e0d41a2b8a | ||
|
|
6b9f9f0f44 | ||
|
|
025135b8c6 | ||
|
|
77d810b735 | ||
|
|
e1222de05b | ||
|
|
459f807e67 | ||
|
|
32df1370a6 | ||
|
|
f18f8c89ec | ||
|
|
787b791651 | ||
|
|
2eca0da852 | ||
|
|
9e49604ce2 | ||
|
|
5f85c61d6a | ||
|
|
cd58855e1f | ||
|
|
13c64b0db0 | ||
|
|
55e021ba20 | ||
|
|
26fd61a3ed | ||
|
|
46482522bb | ||
|
|
98e3e22896 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,5 +5,6 @@ composer.phar
|
||||
vendor/
|
||||
.env
|
||||
data/database.sqlite
|
||||
data/GeoLite2-City.mmdb
|
||||
docs/swagger-ui
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -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)
|
||||
|
||||
2
build.sh
2
build.sh
@@ -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 \
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -23,6 +23,12 @@ return [
|
||||
Container\ApplicationConfigInjectionDelegator::class,
|
||||
],
|
||||
],
|
||||
|
||||
'lazy_services' => [
|
||||
'proxies_target_dir' => 'data/proxies',
|
||||
'proxies_namespace' => 'ShlinkProxy',
|
||||
'write_proxy_files' => true,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
12
config/autoload/dependencies.local.php.dist
Normal file
12
config/autoload/dependencies.local.php.dist
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
'lazy_services' => [
|
||||
'write_proxy_files' => false,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
12
config/autoload/geolite2.global.php
Normal file
12
config/autoload/geolite2.global.php
Normal 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',
|
||||
],
|
||||
|
||||
];
|
||||
25
config/autoload/locks.global.php
Normal file
25
config/autoload/locks.global.php
Normal 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],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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
2
data/locks/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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;
|
||||
|
||||
68
data/migrations/Version20181020060559.php
Normal file
68
data/migrations/Version20181020060559.php
Normal 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
|
||||
}
|
||||
}
|
||||
40
data/migrations/Version20181020065148.php
Normal file
40
data/migrations/Version20181020065148.php
Normal 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
|
||||
}
|
||||
}
|
||||
36
data/migrations/Version20181110175521.php
Normal file
36
data/migrations/Version20181110175521.php
Normal 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');
|
||||
}
|
||||
}
|
||||
20
data/migrations_template.txt
Normal file
20
data/migrations_template.txt
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ name: ShlinkMigrations
|
||||
migrations_namespace: ShlinkMigrations
|
||||
table_name: migrations
|
||||
migrations_directory: data/migrations
|
||||
custom_template: data/migrations_template.txt
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.'
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
module/CLI/src/Command/Visit/UpdateDbCommand.php
Normal file
74
module/CLI/src/Command/Visit/UpdateDbCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
66
module/CLI/test/Command/Visit/UpdateDbCommandTest.php
Normal file
66
module/CLI/test/Command/Visit/UpdateDbCommandTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
67
module/Common/src/Collection/PathCollection.php
Normal file
67
module/Common/src/Collection/PathCollection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Exception;
|
||||
|
||||
interface ExceptionInterface extends \Throwable
|
||||
use Throwable;
|
||||
|
||||
interface ExceptionInterface extends Throwable
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] ?? [],
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
|
||||
38
module/Common/src/IpGeolocation/ChainIpLocationResolver.php
Normal file
38
module/Common/src/IpGeolocation/ChainIpLocationResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
module/Common/src/IpGeolocation/EmptyIpLocationResolver.php
Normal file
25
module/Common/src/IpGeolocation/EmptyIpLocationResolver.php
Normal 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' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
106
module/Common/src/IpGeolocation/GeoLite2/DbUpdater.php
Normal file
106
module/Common/src/IpGeolocation/GeoLite2/DbUpdater.php
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
46
module/Common/src/IpGeolocation/GeoLite2/GeoLite2Options.php
Normal file
46
module/Common/src/IpGeolocation/GeoLite2/GeoLite2Options.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
module/Common/src/IpGeolocation/GeoLite2LocationResolver.php
Normal file
56
module/Common/src/IpGeolocation/GeoLite2LocationResolver.php
Normal 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 ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1
module/Common/test-resources/.gitignore
vendored
Normal file
1
module/Common/test-resources/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
geolite2-testing-db
|
||||
BIN
module/Common/test-resources/GeoLite2-City.tar.gz
Normal file
BIN
module/Common/test-resources/GeoLite2-City.tar.gz
Normal file
Binary file not shown.
82
module/Common/test/Collection/PathCollectionTest.php
Normal file
82
module/Common/test/Collection/PathCollectionTest.php
Normal 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'], []],
|
||||
];
|
||||
}
|
||||
}
|
||||
35
module/Common/test/Exception/WrongIpExceptionTest.php
Normal file
35
module/Common/test/Exception/WrongIpExceptionTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
});
|
||||
}
|
||||
}
|
||||
129
module/Common/test/IpGeolocation/GeoLite2/DbUpdaterTest.php
Normal file
129
module/Common/test/IpGeolocation/GeoLite2/DbUpdaterTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user