mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-05 06:43:12 +08:00
Compare commits
111 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 | ||
|
|
15d49e97c0 | ||
|
|
d5e7ce38ac | ||
|
|
162d0560db | ||
|
|
1de05047ca | ||
|
|
2af5de1199 | ||
|
|
e66a724d2b | ||
|
|
9f4c2ac8d7 | ||
|
|
44f0011445 | ||
|
|
545094cddf | ||
|
|
99f45d8853 | ||
|
|
c25b5f9938 | ||
|
|
db1304c11a | ||
|
|
57714b373c | ||
|
|
5be7f839f3 | ||
|
|
aa441eb58b | ||
|
|
e6b6a40fa6 | ||
|
|
f6dde6f4c1 |
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
|
||||
|
||||
14
.travis.yml
14
.travis.yml
@@ -9,10 +9,16 @@ branches:
|
||||
php:
|
||||
- 7.1
|
||||
- 7.2
|
||||
- 7.3
|
||||
|
||||
matrix:
|
||||
allow_failures:
|
||||
- php: 7.3
|
||||
|
||||
before_install:
|
||||
- phpenv config-add data/infra/travis-php/memcached.ini
|
||||
- phpenv config-add data/infra/travis-php/apcu.ini
|
||||
- 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
|
||||
@@ -23,12 +29,14 @@ 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
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- rm -f ocular.phar
|
||||
- ./build.sh ${TRAVIS_TAG#?}
|
||||
|
||||
deploy:
|
||||
|
||||
93
CHANGELOG.md
93
CHANGELOG.md
@@ -1,5 +1,98 @@
|
||||
# CHANGELOG
|
||||
|
||||
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
|
||||
|
||||
#### Added
|
||||
|
||||
* [#233](https://github.com/shlinkio/shlink/issues/233) Added PHP 7.3 to build matrix allowing its failure.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#235](https://github.com/shlinkio/shlink/issues/235) Improved update instructions (thanks to [tivyhosting](https://github.com/tivyhosting)).
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#237](https://github.com/shlinkio/shlink/issues/233) Solved errors when trying to geo-locate `null` IP addresses.
|
||||
|
||||
Also improved how visitor IP addresses are discovered, thanks to [akrabat/ip-address-middleware](https://github.com/akrabat/ip-address-middleware) package.
|
||||
|
||||
|
||||
## 1.13.1 - 2018-10-16
|
||||
|
||||
#### Added
|
||||
|
||||
68
README.md
68
README.md
@@ -104,21 +104,29 @@ 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.
|
||||
|
||||
* Generate website previews: `/path/to/shlink/bin/cli shortcode:process-previews`
|
||||
* 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 whole process yourself.
|
||||
When a new Shlink version is available, you don't need to repeat the entire process yourself. Instead, follow these steps:
|
||||
|
||||
Instead, get the latest version as explained in previous step, and then, run the script `bin/update`.
|
||||
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`)
|
||||
2. Download and extract the new version of Shlink, and set the directories name to that of the old version. (ie. `shlink`)
|
||||
3. Run the `bin/update` script in the new version's directory to migrate your configuration over.
|
||||
|
||||
The script will ask you for the location from previous shlink version, and use it in order to import the configuration.
|
||||
The script will ask you for the location from previous shlink version, and use it in order to import the configuration. It will then update the database and generate some the assets neccessary for Shlink to function.
|
||||
|
||||
It will then update the database and generate some assets.
|
||||
|
||||
Right now, it does not import cached info (like website previews), but it will. By now you will need to regenerate them again.
|
||||
Right now, it does not import cached info (like website previews), but it will. For now you will need to regenerate them again.
|
||||
|
||||
**Important!** It is recommended that you don't skip any version when using this process. The update gets better on every version, but older versions might make assumptions.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -145,3 +153,47 @@ Once shlink is installed, there are two main ways to interact with it:
|
||||
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too.
|
||||
|
||||
Both the API and CLI allow you to do the same operations, except for API key management, which can be done from the command line interface only.
|
||||
|
||||
### Shlink CLI Help
|
||||
|
||||
```
|
||||
Usage:
|
||||
command [options] [arguments]
|
||||
|
||||
Options:
|
||||
-h, --help Display this help message
|
||||
-q, --quiet Do not output any message
|
||||
-V, --version Display this application version
|
||||
--ansi Force ANSI output
|
||||
--no-ansi Disable ANSI output
|
||||
-n, --no-interaction Do not ask any interactive question
|
||||
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
|
||||
|
||||
Available commands:
|
||||
help Displays help for a command
|
||||
list Lists commands
|
||||
api-key
|
||||
api-key:disable Disables an API key.
|
||||
api-key:generate Generates a new valid API key.
|
||||
api-key:list Lists all the available API keys.
|
||||
config
|
||||
config:generate-charset Generates a character set sample just by shuffling the default one, "123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ". Then it can be set in the SHORTCODE_CHARS environment variable
|
||||
config:generate-secret Generates a random secret string that can be used for JWT token encryption
|
||||
short-url
|
||||
short-url:delete [short-code:delete] Deletes a short URL
|
||||
short-url:generate [shortcode:generate|short-code:generate] Generates a short URL for provided long URL and returns it
|
||||
short-url:list [shortcode:list|short-code:list] List all short URLs
|
||||
short-url:parse [shortcode:parse|short-code:parse] Returns the long URL behind a short code
|
||||
short-url:process-previews [shortcode:process-previews|short-code:process-previews] Processes and generates the previews for every URL, improving performance for later web requests.
|
||||
short-url:visits [shortcode:visits|short-code:visits] Returns the detailed visits information for provided short code
|
||||
tag
|
||||
tag:create Creates one or more tags.
|
||||
tag:delete Deletes one or more tags.
|
||||
tag:list Lists existing tags.
|
||||
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 \
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"acelaya/ze-content-based-error-handler": "^2.2",
|
||||
"akrabat/ip-address-middleware": "^1.0",
|
||||
"cakephp/chronos": "^1.2",
|
||||
"cocur/slugify": "^3.0",
|
||||
"doctrine/cache": "^1.6",
|
||||
@@ -23,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",
|
||||
@@ -45,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",
|
||||
@@ -88,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,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
extension="apcu.so"
|
||||
@@ -1 +0,0 @@
|
||||
extension="memcached.so"
|
||||
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,75 +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) {
|
||||
$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,37 +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 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();
|
||||
@@ -49,89 +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->assertEquals(0, \strpos($output, 'Processing IP 1.2.3.0'));
|
||||
$this->assertGreaterThan(0, \strpos($output, 'Processing IP 4.3.2.0'));
|
||||
$this->assertGreaterThan(0, \strpos($output, 'Processing IP 12.34.56.0'));
|
||||
|
||||
$this->assertContains('Processing IP 1.2.3.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 localhostAddressIsIgnored()
|
||||
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'),
|
||||
];
|
||||
$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) - 2);
|
||||
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
|
||||
->shouldBeCalledTimes(\count($visits) - 2);
|
||||
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
|
||||
function (array $args) use ($visit, $location) {
|
||||
$firstCallback = array_shift($args);
|
||||
$firstCallback($visit);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'visit:process',
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertGreaterThan(0, \strpos($output, 'Ignored localhost address'));
|
||||
$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 sleepsEveryTimeTheApiLimitIsReached()
|
||||
public function noActionIsPerformedIfLockIsAcquired()
|
||||
{
|
||||
$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->lock->acquire()->willReturn(false);
|
||||
|
||||
$this->visitService->getUnlocatedVisits()->willReturn($visits);
|
||||
$this->visitService->saveVisit(Argument::any())->will(function () {
|
||||
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(function () {
|
||||
});
|
||||
|
||||
$getApiLimit = $this->ipResolver->getApiLimit()->willReturn($apiLimit);
|
||||
$getApiInterval = $this->ipResolver->getApiInterval()->willReturn(0);
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
|
||||
->shouldBeCalledTimes(count($visits));
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'visit:process',
|
||||
]);
|
||||
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
use Shlinkio\Shlink\Common\Factory;
|
||||
use Shlinkio\Shlink\Common\Image;
|
||||
use Shlinkio\Shlink\Common\Image\ImageBuilder;
|
||||
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
|
||||
use Shlinkio\Shlink\Common\Service;
|
||||
use Shlinkio\Shlink\Common\Template\Extension\TranslatorExtension;
|
||||
use RKA\Middleware\IpAddress;
|
||||
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 [
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
EntityManager::class => Factory\EntityManagerFactory::class,
|
||||
GuzzleHttp\Client::class => InvokableFactory::class,
|
||||
GuzzleClient::class => InvokableFactory::class,
|
||||
Cache::class => Factory\CacheFactory::class,
|
||||
'Logger_Shlink' => Factory\LoggerFactory::class,
|
||||
Filesystem::class => InvokableFactory::class,
|
||||
Reader::class => ConfigAbstractFactory::class,
|
||||
|
||||
Translator::class => Factory\TranslatorFactory::class,
|
||||
TranslatorExtension::class => ConfigAbstractFactory::class,
|
||||
LocaleMiddleware::class => ConfigAbstractFactory::class,
|
||||
Template\Extension\TranslatorExtension::class => ConfigAbstractFactory::class,
|
||||
|
||||
Middleware\LocaleMiddleware::class => ConfigAbstractFactory::class,
|
||||
IpAddress::class => Middleware\IpAddressMiddlewareFactory::class,
|
||||
|
||||
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' => GuzzleHttp\Client::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 => [
|
||||
TranslatorExtension::class => ['translator'],
|
||||
LocaleMiddleware::class => ['translator'],
|
||||
Service\IpApiLocationResolver::class => ['httpClient'],
|
||||
Reader::class => ['config.geolite2.db_location'],
|
||||
|
||||
Template\Extension\TranslatorExtension::class => ['translator'],
|
||||
Middleware\LocaleMiddleware::class => ['translator'],
|
||||
|
||||
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 => [
|
||||
ImageBuilder::class,
|
||||
Image\ImageBuilder::class,
|
||||
Filesystem::class,
|
||||
'config.preview_generation.files_location',
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Options;
|
||||
namespace Shlinkio\Shlink\Common\Middleware;
|
||||
|
||||
use Interop\Container\ContainerInterface;
|
||||
use Interop\Container\Exception\ContainerException;
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
|
||||
use Zend\ServiceManager\Exception\ServiceNotFoundException;
|
||||
use Zend\ServiceManager\Factory\FactoryInterface;
|
||||
|
||||
class AppOptionsFactory implements FactoryInterface
|
||||
class IpAddressMiddlewareFactory implements FactoryInterface
|
||||
{
|
||||
/**
|
||||
* Create an object
|
||||
@@ -17,15 +18,11 @@ class AppOptionsFactory implements FactoryInterface
|
||||
* @param ContainerInterface $container
|
||||
* @param string $requestedName
|
||||
* @param null|array $options
|
||||
* @return object
|
||||
* @throws ServiceNotFoundException if unable to resolve the service.
|
||||
* @throws ServiceNotCreatedException if an exception is raised when
|
||||
* creating a service.
|
||||
* @throws ContainerException if any other error occurs
|
||||
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
|
||||
*/
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): IpAddress
|
||||
{
|
||||
$config = $container->has('config') ? $container->get('config') : [];
|
||||
return new AppOptions($config['app_options'] ?? []);
|
||||
return new IpAddress(true, [], Visitor::REMOTE_ADDRESS_ATTR);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Common\Util;
|
||||
|
||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||
use function count;
|
||||
use function explode;
|
||||
use function implode;
|
||||
use function trim;
|
||||
|
||||
final class IpAddress
|
||||
{
|
||||
@@ -43,9 +47,9 @@ final class IpAddress
|
||||
*/
|
||||
public static function fromString(string $address): self
|
||||
{
|
||||
$address = \trim($address);
|
||||
$parts = \explode('.', $address);
|
||||
if (\count($parts) !== self::IPV4_PARTS_COUNT) {
|
||||
$address = trim($address);
|
||||
$parts = explode('.', $address);
|
||||
if (count($parts) !== self::IPV4_PARTS_COUNT) {
|
||||
throw WrongIpException::fromIpAddress($address);
|
||||
}
|
||||
|
||||
@@ -64,7 +68,7 @@ final class IpAddress
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return \implode('.', [
|
||||
return implode('.', [
|
||||
$this->firstOctet,
|
||||
$this->secondOctet,
|
||||
$this->thirdOctet,
|
||||
|
||||
@@ -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)];
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user