Compare commits

..

140 Commits

Author SHA1 Message Date
Alejandro Celaya
6ba4d8e947 Merge pull request #299 from acelaya/feature/repository-tests
Improved repository tests
2018-12-02 19:24:19 +01:00
Alejandro Celaya
3faf6e967f Updated changelog adding v1.15 2018-12-02 19:15:58 +01:00
Alejandro Celaya
a7a5667301 Improved repository tests 2018-12-02 19:13:49 +01:00
Alejandro Celaya
d4924897b2 Merge pull request #298 from acelaya/feature/document-swoole
Feature/document swoole
2018-12-02 10:10:00 +01:00
Alejandro Celaya
f2d39ca55a Added missing comma 2018-12-02 10:05:33 +01:00
Alejandro Celaya
743d052f55 Documented how to serve shlink using swoole 2018-12-02 09:56:52 +01:00
Alejandro Celaya
17dbab5ee8 Created config file examples to serve shlink using different approaches 2018-12-02 09:41:25 +01:00
Alejandro Celaya
8cb5e07c7b Merge pull request #297 from acelaya/feature/remove-helpers
Removed non-needed services from expressive-helpers
2018-12-01 21:59:24 +01:00
Alejandro Celaya
e9972783d2 Removed non-needed services from expressive-helpers 2018-12-01 21:53:46 +01:00
Alejandro Celaya
84f6080a38 Merge pull request #296 from acelaya/feature/fix-lowercase
Feature/fix lowercase
2018-12-01 21:47:40 +01:00
Alejandro Celaya
1b5c1e4e52 Updated changelog 2018-12-01 21:40:11 +01:00
Alejandro Celaya
d7e89ebdae Ensured custom slugs are case sensitive 2018-12-01 21:38:29 +01:00
Alejandro Celaya
aa413dab6d Configured improvements introduced in expressive swoole 2.1 2018-11-29 21:14:24 +01:00
Alejandro Celaya
b876870bd8 Encapsulated in VisitsParams how the itemsPerPage param is handled 2018-11-29 08:02:22 +01:00
Alejandro Celaya
05e56cc845 Merge pull request #293 from acelaya/feature/visits-pagination
Feature/visits pagination
2018-11-28 21:00:37 +01:00
Alejandro Celaya
d6c158ce98 Updated changelog 2018-11-28 20:55:07 +01:00
Alejandro Celaya
1d4ef4e9a4 Ensured pagination params in visits list are properly parsed to integer 2018-11-28 20:53:04 +01:00
Alejandro Celaya
4d2684be52 Updated swagger docs for visits including everything related to pagination 2018-11-28 20:46:52 +01:00
Alejandro Celaya
6947805b5c Updated to zend-expressive-swoole 2.0.1 removing all workarounds 2018-11-28 20:43:44 +01:00
Alejandro Celaya
d0e0aea0f1 Updated visits to support pagination 2018-11-28 20:39:08 +01:00
Alejandro Celaya
b0f250ed8a Created factory method to build VisitParams from a raw dataset 2018-11-28 19:58:45 +01:00
Alejandro Celaya
45254606d4 Added DTO used to pass filtering params to VisitsTracker 2018-11-27 21:09:27 +01:00
Alejandro Celaya
03ee46d903 Merge pull request #290 from acelaya/feature/coding-standard
Updated project to use external coding standard
2018-11-26 20:53:51 +01:00
Alejandro Celaya
c4afc7a923 Updated project to use external coding standard 2018-11-26 20:46:43 +01:00
Alejandro Celaya
afa2a5b0f0 Merge pull request #284 from acelaya/feature/swoole
Feature/swoole
2018-11-25 22:12:50 +01:00
Alejandro Celaya
b40057d423 Fixed typo in changelog 2018-11-25 21:33:25 +01:00
Alejandro Celaya
282ffef200 Ensured different loggers are used for swoole and for the app regular logs 2018-11-25 17:14:03 +01:00
Alejandro Celaya
22b02de405 Updated swoole docker image so that it retries the start command until status code is 0 2018-11-25 12:44:49 +01:00
Alejandro Celaya
f0330e9ae3 Ensured CloseDbConnectionMiddleware clears the entity manager 2018-11-24 13:24:43 +01:00
Alejandro Celaya
0c26490e3f Added info about swoole in changelog 2018-11-24 13:18:50 +01:00
Alejandro Celaya
cfaecd93e4 Added swoole extension to travis 2018-11-24 13:11:26 +01:00
Alejandro Celaya
ccbc6c7a75 Created middleware which closes DB connection after every request 2018-11-24 12:55:00 +01:00
Alejandro Celaya
2fc2ad98aa Updated config so that shlink logger dynamically uses standard output when running with swoole 2018-11-24 09:38:00 +01:00
Alejandro Celaya
16590b2dbb Prepared project to support both swoole and regular app servers with fast cgi 2018-11-24 08:43:48 +01:00
Alejandro Celaya
f40349479e Used more strict types in UrlShortener private methods 2018-11-24 07:52:57 +01:00
Alejandro Celaya
9f60c8dffe Merge pull request #280 from acelaya/feature/oneliner-type
Feature/oneliner type
2018-11-20 19:42:23 +01:00
Alejandro Celaya
5abd9d1a40 Made test properties to be private instead of protected 2018-11-20 19:37:22 +01:00
Alejandro Celaya
0ae5a53d86 Enforced property types comments in one line 2018-11-20 19:30:27 +01:00
Alejandro Celaya
15a70d0157 Merge pull request #278 from acelaya/feature/del-translations
Feature/del translations
2018-11-18 20:30:53 +01:00
Alejandro Celaya
ededb68ef1 Added changelog for unreleased changes 2018-11-18 20:20:30 +01:00
Alejandro Celaya
09add5fbff Moved locale middleware to before the not found handler, so that it never gets executed otherwise 2018-11-18 20:15:37 +01:00
Alejandro Celaya
e30f49a791 Simplified error templates 2018-11-18 20:04:12 +01:00
Alejandro Celaya
64737b741b Removed CLI language param from installation 2018-11-18 19:55:23 +01:00
Alejandro Celaya
d4d65bdf37 Added missing X-Api-Key header to cross domain middleware 2018-11-18 17:03:50 +01:00
Alejandro Celaya
90732a4fad Removed translations from Rest module 2018-11-18 16:28:04 +01:00
Alejandro Celaya
c5015f5828 Removed translations from CLI module 2018-11-18 16:02:52 +01:00
Alejandro Celaya
aa77c944d8 Merge pull request #277 from acelaya/feature/increase-msi
Feature/increase msi
2018-11-17 19:37:11 +01:00
Alejandro Celaya
b8faa6714a Increased MSI to 65% (for sure this time) 2018-11-17 19:32:31 +01:00
Alejandro Celaya
f48f98f4d7 Updated changelog for v1.14.1 2018-11-17 19:27:00 +01:00
Alejandro Celaya
79b2a0839f Increased MSI to 65% 2018-11-17 19:23:49 +01:00
Alejandro Celaya
6094d17718 Increased MSI to 64% 2018-11-17 18:40:53 +01:00
Alejandro Celaya
d2ed7d6417 Increased MSI to 62% 2018-11-17 18:06:21 +01:00
Alejandro Celaya
a705ef21a9 Increased MSI to 61% 2018-11-17 17:36:22 +01:00
Alejandro Celaya
67e465c479 Merge pull request #276 from acelaya/feature/locking
Feature/locking
2018-11-17 14:33:58 +01:00
Alejandro Celaya
ed3883b52c Updated translations 2018-11-17 14:29:54 +01:00
Alejandro Celaya
71ea0bcb5e Updated changelog with locking capabilities 2018-11-17 14:24:38 +01:00
Alejandro Celaya
dd2cffeee9 Reused ProcessVisitsCommand name as the lock name 2018-11-17 14:16:45 +01:00
Alejandro Celaya
1ceabf3bc3 Added locking capabilities to process visits command 2018-11-17 14:11:16 +01:00
Alejandro Celaya
17fcd637f2 Merge pull request #275 from acelaya/feature/doctrine-performance
Feature/doctrine performance
2018-11-17 09:59:53 +01:00
Alejandro Celaya
d44bc4b182 Added small hint in README 2018-11-17 09:49:44 +01:00
Alejandro Celaya
4760406221 Updated changelog 2018-11-17 09:47:14 +01:00
Alejandro Celaya
0aae0d888c Moved visits iteration logic from command to service to allow lazy loading of entries in resultset 2018-11-17 09:42:15 +01:00
Alejandro Celaya
1bc01057f3 Reduced the number of arguments in private method 2018-11-17 08:02:42 +01:00
Alejandro Celaya
c1906606c6 Updated VisitService to have a method which locates visits and allows entity manager to be cleared 2018-11-17 07:47:42 +01:00
Alejandro Celaya
1363194909 Improved code in LoggerFactory 2018-11-17 07:31:51 +01:00
Alejandro Celaya
d945e0c31b Updated CLI help in README file 2018-11-16 17:17:25 +01:00
Alejandro Celaya
0af7b75af5 Merge pull request #269 from acelaya/feature/missing-resp-examples
Feature/missing resp examples
2018-11-16 17:07:38 +01:00
Alejandro Celaya
36a42cb064 Added missing entries for v1.14.0 2018-11-16 17:02:40 +01:00
Alejandro Celaya
4db0acc0e7 Updated swagger response schemas and added missing response examples 2018-11-16 16:58:21 +01:00
Alejandro Celaya
8f4800aa47 Merge pull request #268 from acelaya/feature/phpstan-fix
feature/phpstan-fix
2018-11-16 16:57:30 +01:00
Alejandro Celaya
4745a37549 Used a lower level on phpstan to avoid errors produced by Symfony 4.1.5 new phpdocs 2018-11-16 16:44:48 +01:00
Alejandro Celaya
8fc949898b Excluded GeoLite2 db from build process 2018-11-12 21:51:14 +01:00
Alejandro Celaya
d4758b0e91 Merge pull request #258 from acelaya/feature/geolocation
Feature/geolocation
2018-11-12 21:46:33 +01:00
Alejandro Celaya
a07e4b17be Updated docs 2018-11-12 21:37:04 +01:00
Alejandro Celaya
b9dd975bc6 Updated changelog with new geolocation service 2018-11-12 21:34:45 +01:00
Alejandro Celaya
9964d3e24b Added progress bar to command downloading new GeoLite2 database file 2018-11-12 21:30:30 +01:00
Alejandro Celaya
58e8c8e182 Updated spanish translations 2018-11-12 21:04:02 +01:00
Alejandro Celaya
c7339f6cfa Created an EmptyIpLocationResolver which always returns an empty resolution and can be used as a fallback while resolving IP addresses 2018-11-12 20:58:14 +01:00
Alejandro Celaya
1aa78f766a Added step to download GeoLite2 db during installation 2018-11-12 20:51:53 +01:00
Alejandro Celaya
bf56e6adaf Created UpdateDbCommandTest 2018-11-12 20:37:30 +01:00
Alejandro Celaya
e915b7e499 Updated GeoLite2 db reader service so that it is lazily created 2018-11-12 20:22:42 +01:00
Alejandro Celaya
de0470d200 Created command to update GeoLite2 database 2018-11-12 20:06:12 +01:00
Alejandro Celaya
3d7cf6992e Created service to update geolite2 database file 2018-11-11 21:28:42 +01:00
Alejandro Celaya
06db082e3f Updated translations 2018-11-11 21:28:42 +01:00
Alejandro Celaya
4a383cecaf Set chain IP resolver as the default IP resolver 2018-11-11 21:28:42 +01:00
Alejandro Celaya
9a0f9207be Fixed region resolved in GeoLite2 2018-11-11 21:28:42 +01:00
Alejandro Celaya
0e3a0a1eec Created chain IP resolver which wrapps multiple resolver to fallback until one is capable of resolving an address 2018-11-11 21:28:42 +01:00
Alejandro Celaya
fd6d180eba Created chainIpLocationResolver 2018-11-11 21:28:42 +01:00
Alejandro Celaya
d152e2ef9a Removed the concept of API limits in IP location resolvers 2018-11-11 21:28:42 +01:00
Alejandro Celaya
b530cf4461 Created new namespace for IP geolocation elements 2018-11-11 21:28:42 +01:00
Alejandro Celaya
bbe85cde31 Migrated to GeoLite2 for IP location resolution 2018-11-11 21:28:42 +01:00
Alejandro Celaya
2c3cbe7146 Installed geoip2 and added to docs 2018-11-11 21:28:42 +01:00
Alejandro Celaya
2358308f4d Merge pull request #259 from acelaya/feature/infection
Updated to infection v0.11
2018-11-11 21:28:12 +01:00
Alejandro Celaya
58bff4fa73 Updated to infection v0.11 2018-11-11 21:24:11 +01:00
Alejandro Celaya
098f7afc70 Merge pull request #255 from acelaya/feature/user-agent-length
Updated user agent column in visits table to have a length of 512
2018-11-10 19:07:22 +01:00
Alejandro Celaya
4070b1e23d Updated user agent column in visits table to have a length of 512 2018-11-10 19:01:59 +01:00
Alejandro Celaya
d9d4c8a70c Merge pull request #252 from acelaya/feature/redirect-not-found
Feature/redirect not found
2018-11-04 12:19:03 +01:00
Alejandro Celaya
05abe49d8b Updated changelog 2018-11-04 12:11:36 +01:00
Alejandro Celaya
a71245b883 Improved UrlShortenerConfigCustomizerTest covering new config options 2018-11-04 12:05:22 +01:00
Alejandro Celaya
057f88a36a Added new not found short url config to installer 2018-11-04 11:58:35 +01:00
Alejandro Celaya
32fcdd9d94 Ensured phpcov is run with phpdbg in travis pipeline 2018-11-03 12:15:25 +01:00
Alejandro Celaya
313927827d Updated RedirectAction so that it makes use of the not found short url options 2018-11-03 12:10:02 +01:00
Alejandro Celaya
358b2b661e Deprecated ci composer command, since it does the same as check, but slower 2018-11-03 11:40:57 +01:00
Alejandro Celaya
3eddacdff8 Created options to enable redirection to external page when short code is not found 2018-11-03 11:37:43 +01:00
Alejandro Celaya
95d4cde649 Merge pull request #251 from acelaya/feature/improve-infection
Feature/improve infection
2018-11-03 11:07:20 +01:00
Alejandro Celaya
d1d947bf12 Disabled xdebug in travis env 2018-11-03 11:02:52 +01:00
Alejandro Celaya
40815e5b38 Ensured phpunit is run using phpdbg, to avoid the requirement on xdebug 2018-11-03 11:02:19 +01:00
Alejandro Celaya
8fc1d23e03 Created needed commands and updated pipeline config file to run infection using an existing code coverage report 2018-11-03 10:58:46 +01:00
Alejandro Celaya
5ec8c229a1 Merge pull request #250 from acelaya/feature/functional
Feature/functional
2018-11-02 12:19:07 +01:00
Alejandro Celaya
2412ec2195 Updated changelog 2018-11-02 12:08:43 +01:00
Alejandro Celaya
bfb96b0ae8 Fixed coding style 2018-11-02 12:07:13 +01:00
Alejandro Celaya
f64920e510 Replaced some array_map by Functional\map 2018-11-02 12:05:01 +01:00
Alejandro Celaya
664dc333ac Used select_keys function in place of custom pick function 2018-11-02 11:08:20 +01:00
Alejandro Celaya
521f6f2b18 Added functional-php library 2018-11-02 10:54:42 +01:00
Alejandro Celaya
6986d03c53 Merge pull request #248 from acelaya/feature/fix-anemic-model
Feature/fix anemic model
2018-10-28 16:27:14 +01:00
Alejandro Celaya
e6e38e3ca2 Added change to changelog 2018-10-28 16:22:30 +01:00
Alejandro Celaya
951d08f914 Improved public API in Visit entity, reducing anemic model 2018-10-28 16:20:54 +01:00
Alejandro Celaya
8e1e8ba7de Improved public API in ShortUrl entity, reducing anemic model 2018-10-28 16:00:54 +01:00
Alejandro Celaya
877b098b09 Improved public API in ApiKey entity, reducing anemic model 2018-10-28 15:24:41 +01:00
Alejandro Celaya
e046eddda9 Improved public API in VisitLocation entity, reducing anemic model 2018-10-28 15:13:45 +01:00
Alejandro Celaya
084b1169d7 Improved public API in Tag entity, avoiding anemic model 2018-10-28 14:38:43 +01:00
Alejandro Celaya
f7ceeff05a Added task to changelog 2018-10-28 09:15:26 +01:00
Alejandro Celaya
e0d41a2b8a Merge pull request #246 from acelaya/feature/enforce-global-imports
Feature/enforce global imports
2018-10-28 09:12:46 +01:00
Alejandro Celaya
6b9f9f0f44 Added scrutinizer config to enforce using the new environment 2018-10-28 09:05:20 +01:00
Alejandro Celaya
025135b8c6 Added all missing use statements from global functions and constants 2018-10-28 08:34:02 +01:00
Alejandro Celaya
77d810b735 Replaced all FQ global function and constants by explicit imports 2018-10-28 08:24:06 +01:00
Alejandro Celaya
e1222de05b Explicitly imported global functions in UrlShortener 2018-10-28 08:07:33 +01:00
Alejandro Celaya
459f807e67 Added link to shlink CLI help when mentioning CLI available commands 2018-10-20 13:09:41 +02:00
Alejandro Celaya
32df1370a6 Updated changelog 2018-10-20 13:08:03 +02:00
Alejandro Celaya
f18f8c89ec Merge pull request #244 from acelaya/feature/psr-logs
Feature/psr logs
2018-10-20 13:06:20 +02:00
Alejandro Celaya
787b791651 Replaced hardcoded exceptions concatenations by PSR approach 2018-10-20 12:50:10 +02:00
Alejandro Celaya
2eca0da852 Updated logger to properly format exceptions using processors 2018-10-20 12:37:35 +02:00
Alejandro Celaya
9e49604ce2 Replaced usages of mt_rand by random_int 2018-10-20 09:21:26 +02:00
Alejandro Celaya
5f85c61d6a Merge pull request #243 from acelaya/feature/snake-case-table
Feature/snake case table
2018-10-20 09:20:48 +02:00
Alejandro Celaya
cd58855e1f Updated changelog 2018-10-20 09:10:27 +02:00
Alejandro Celaya
13c64b0db0 Fixed coding styles 2018-10-20 09:10:27 +02:00
Alejandro Celaya
55e021ba20 Added snake case column names to VisitLocation entity 2018-10-20 09:10:27 +02:00
Alejandro Celaya
26fd61a3ed Created migrations to rename camel case columns to snake case 2018-10-20 09:10:27 +02:00
Alejandro Celaya
46482522bb Merge pull request #242 from acelaya/feature/functions-as-object
Moved global functions to handle array paths to a wrapper class
2018-10-20 08:59:40 +02:00
Alejandro Celaya
98e3e22896 Moved global functions to handle array paths to a wrapper class 2018-10-20 08:00:33 +02:00
310 changed files with 4904 additions and 3658 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@@ -18,6 +18,8 @@ matrix:
before_install:
- echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- yes | pecl install swoole
- phpenv config-rm xdebug.ini || return 0
install:
- composer self-update
@@ -28,7 +30,8 @@ script:
- composer check
after_success:
- vendor/bin/phpcov merge build --clover build/clover.xml
- rm -f build/clover.xml
- phpdbg -qrr vendor/bin/phpcov merge build --clover build/clover.xml
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml

View File

@@ -4,6 +4,103 @@ 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.15.0 - 2018-12-02
#### Added
* [#208](https://github.com/shlinkio/shlink/issues/208) Added initial support to run shlink using [swoole](https://www.swoole.co.uk/), a non-blocking IO server which improves the performance of shlink from 4 to 10 times.
Run shlink with `./vendor/bin/zend-expressive-swoole start` to start-up the service, which will be exposed in port `8080`.
Adding the `-d` flag, it will be started as a background service. Then you can use the `./vendor/bin/zend-expressive-swoole stop` command in order to stop it.
* [#266](https://github.com/shlinkio/shlink/issues/266) Added pagination to `GET /short-urls/{shortCode}/visits` endpoint.
In order to make it backwards compatible, it keeps returning all visits by default, but it now allows to provide the `page` and `itemsPerPage` query parameters in order to configure the number of items to get.
#### Changed
* [#267](https://github.com/shlinkio/shlink/issues/267) API responses and the CLI interface is no longer translated and uses english always. Only not found error templates are still translated.
* [#289](https://github.com/shlinkio/shlink/issues/289) Extracted coding standard rules to a separated package.
* [#273](https://github.com/shlinkio/shlink/issues/273) Improved code coverage in repository classes.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#278](https://github.com/shlinkio/shlink/pull/278) Added missing `X-Api-Key` header to the list of valid cross domain headers.
* [#295](https://github.com/shlinkio/shlink/pull/295) Fixed custom slugs so that they are case sensitive and do not try to lowercase provided values.
## 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

165
README.md
View File

@@ -44,52 +44,132 @@ Despite how you built the project, you are going to need to install it now, by f
* If you are going to use MySQL or PostgreSQL, create an empty database with the name of your choice.
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
* Configure the web server of your choice to serve shlink using your short domain.
* Expose shlink to the web, either by using a traditional web server + fast CGI approach, or by using a [swoole](https://www.swoole.co.uk/) non-blocking server.
For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, this would be the basic configuration for Nginx and Apache.
* **Using a web server:**
*Nginx:*
For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, these would be the basic configurations for Nginx and Apache.
```nginx
server {
server_name doma.in;
listen 80;
root /path/to/shlink/public;
index index.php;
charset utf-8;
*Nginx:*
location / {
try_files $uri $uri/ /index.php$is_args$args;
```nginx
server {
server_name doma.in;
listen 80;
root /path/to/shlink/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
location ~ /\.ht {
deny all;
}
}
```
*Apache:*
```apache
<VirtualHost *:80>
ServerName doma.in
DocumentRoot "/path/to/shlink/public"
<Directory "/path/to/shlink/public">
Options FollowSymLinks Includes ExecCGI
AllowOverride all
Order allow,deny
Allow from all
</Directory>
</VirtualHost>
```
* **Using swoole:**
**Important!** Swoole support is still experimental. Use it with care, and report any found issue.
First you need to install the swoole PHP extension with [pecl](https://pecl.php.net/package/swoole), `pecl install swoole`.
Once installed, it's actually pretty easy to get shlink up and running with swoole. Just run `./vendor/bin/zend-expressive-swoole start -d` and you will get shlink running on port 8080.
However, by doing it this way, you are loosing all the access logs, and the service won't be automatically run if the server has to be restarted.
For that reason, you should create a daemon script, in `/etc/init.d/shlink_swoole`, like this one, replacing `/path/to/shlink` by the path to your shlink installation:
```bash
#!/bin/bash
### BEGIN INIT INFO
# Provides: shlink_swoole
# Required-Start: $local_fs $network $named $time $syslog
# Required-Stop: $local_fs $network $named $time $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Description: Shlink non-blocking server with swoole
### END INIT INFO
SCRIPT=/path/to/shlink/vendor/bin/zend-expressive-swoole\ start
RUNAS=root
PIDFILE=/var/run/shlink_swoole.pid
LOGDIR=/var/log/shlink
LOGFILE=${LOGDIR}/shlink_swoole.log
start() {
if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with swoole already running' >&2
return 1
fi
echo 'Starting shlink with swoole' >&2
mkdir -p "$LOGDIR"
touch "$LOGFILE"
local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!"
su -c "$CMD" $RUNAS > "$PIDFILE"
echo 'Shlink started' >&2
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
stop() {
if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with swoole not running' >&2
return 1
fi
echo 'Stopping shlink with swoole' >&2
kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE"
echo 'Shlink stopped' >&2
}
location ~ /\.ht {
deny all;
}
}
```
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
*)
echo "Usage: $0 {start|stop|restart}"
esac
```
*Apache:*
Then run these commands to enable the service and start it:
```apache
<VirtualHost *:80>
ServerName doma.in
DocumentRoot "/path/to/shlink/public"
* `sudo chmod +x /etc/init.d/shlink_swoole`
* `sudo update-rc.d shlink_swoole defaults`
* `sudo update-rc.d shlink_swoole enable`
* `/etc/init.d/shlink_swoole start`
<Directory "/path/to/shlink/public">
Options FollowSymLinks Includes ExecCGI
AllowOverride all
Order allow,deny
Allow from all
</Directory>
</VirtualHost>
```
Now again, you can access shlink on port 8080, but this time the service will be automatically run at system start-up, and all access logs will be written in `/var/log/shlink/shlink_swoole.log` (you will probably want to [rotate those logs](https://www.digitalocean.com/community/tutorials/how-to-manage-logfiles-with-logrotate-on-ubuntu-16-04). You can find an example logrotate config file [here](data/infra/examples/shlink-daemon-logrotate.conf)).
* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API.
* Finally access to [https://app.shlink.io](https://app.shlink.io) and configure your server to start creating short URLs.
@@ -104,10 +184,20 @@ Those tasks can be performed using shlink's CLI, so it should be easy to schedul
If you don't run this command regularly, the stats will say all visits come from *unknown* locations.
* Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db`
When shlink is installed it downloads a fresh [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/) db file. Running this command will update this file.
The file is updated the first Tuesday of every month, so it should be enough running this command the first Wednesday.
* Generate website previews: `/path/to/shlink/bin/cli short-url:process-previews`
Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site.
*Any of those commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.*
In future versions, it is planed that, when using **swoole** to serve shlink, some of these tasks are automatically run without blocking the request and also, without having to configure cron jobs. Probably resolving IP locations and generating previews.
## Update to new version
When a new Shlink version is available, you don't need to repeat the entire process yourself. Instead, follow these steps:
@@ -134,7 +224,7 @@ Currently the image does not expose an entry point which let's you interact with
Once shlink is installed, there are two main ways to interact with it:
* **The command line**. Try running `bin/cli` and see all the available commands.
* **The command line**. Try running `bin/cli` and see all the [available commands](#shlink-cli-help).
All of those commands can be run with the `--help`/`-h` flag in order to see how to use them and all the available options.
@@ -185,4 +275,7 @@ Available commands:
tag:rename Renames one existing tag.
visit
visit:process Processes visits where location is not set yet
visit:update-db Updates the GeoLite2 database file used to geolocate IP addresses
```
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)

View File

@@ -3,8 +3,11 @@
declare(strict_types=1);
use Interop\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Exec\ExecutionContext;
use Symfony\Component\Console\Application as CliApp;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
putenv(sprintf('CURRENT_SHLINK_CONTEXT=%s', ExecutionContext::CLI));
$container->get(CliApp::class)->run();

View File

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

View File

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

View File

@@ -4,18 +4,13 @@ declare(strict_types=1);
use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory;
use Zend\Expressive;
use Zend\Expressive\Container;
use Zend\Expressive\Helper;
use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'dependencies' => [
'factories' => [
ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class,
Helper\UrlHelper::class => Helper\UrlHelperFactory::class,
Helper\ServerUrlHelper::class => InvokableFactory::class,
],
'delegators' => [
@@ -23,6 +18,12 @@ return [
Container\ApplicationConfigInjectionDelegator::class,
],
],
'lazy_services' => [
'proxies_target_dir' => 'data/proxies',
'proxies_namespace' => 'ShlinkProxy',
'write_proxy_files' => true,
],
],
];

View File

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

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Common;
use function Shlinkio\Shlink\Common\env;
return [
@@ -10,9 +10,9 @@ return [
'proxies_dir' => 'data/proxies',
],
'connection' => [
'user' => Common\env('DB_USER'),
'password' => Common\env('DB_PASSWORD'),
'dbname' => Common\env('DB_NAME', 'shlink'),
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'dbname' => env('DB_NAME', 'shlink'),
'charset' => 'utf8',
],
],

View File

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

View File

@@ -1,8 +1,11 @@
<?php
declare(strict_types=1);
use Zend\ConfigAggregator\ConfigAggregator;
return [
'debug' => true,
'config_cache_enabled' => false,
ConfigAggregator::ENABLE_CACHE => false,
];

View File

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

View File

@@ -1,32 +1,75 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Processor;
use Zend\Expressive\Swoole\Log\AccessLogInterface;
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,
],
],
'handlers' => [
'rotating_file_handler' => [
'shlink_rotating_handler' => [
'class' => RotatingFileHandler::class,
'level' => Logger::INFO,
'filename' => 'data/log/shlink_log.log',
'max_files' => 30,
'formatter' => 'dashed',
],
'swoole_access_handler' => [
'class' => StreamHandler::class,
'level' => Logger::INFO,
'stream' => 'php://stdout',
'formatter' => 'dashed',
],
],
'processors' => [
'exception_with_new_line' => [
'class' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class,
],
'psr3' => [
'class' => Processor\PsrLogMessageProcessor::class,
],
],
'loggers' => [
'Shlink' => [
'handlers' => ['rotating_file_handler'],
'handlers' => ['shlink_rotating_handler'],
'processors' => ['exception_with_new_line', 'psr3'],
],
'Swoole' => [
'handlers' => ['swoole_access_handler'],
'processors' => ['psr3'],
],
],
],
'dependencies' => [
'factories' => [
'Logger_Shlink' => Common\Factory\LoggerFactory::class,
'Logger_Swoole' => Common\Factory\LoggerFactory::class,
AccessLogInterface::class => Common\Logger\Swoole\AccessLogFactory::class,
],
],
'zend-expressive-swoole' => [
'swoole-http-server' => [
'logger' => [
'logger_name' => 'Logger_Swoole',
],
],
],

View File

@@ -1,11 +1,13 @@
<?php
declare(strict_types=1);
use Monolog\Logger;
return [
'logger' => [
'handlers' => [
'rotating_file_handler' => [
'shlink_rotating_handler' => [
'level' => Logger::DEBUG,
],
],

View File

@@ -10,11 +10,18 @@ return [
'middleware_pipeline' => [
'pre-routing' => [
'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
Common\Middleware\LocaleMiddleware::class,
],
'middleware' => (function () {
$middleware = [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
];
if (Common\Exec\ExecutionContext::currentContextIsSwoole()) {
$middleware[] = Common\Middleware\CloseDbConnectionMiddleware::class;
}
return $middleware;
})(),
'priority' => 12,
],
'pre-routing-rest' => [
@@ -47,6 +54,9 @@ return [
'post-routing' => [
'middleware' => [
Expressive\Router\Middleware\DispatchMiddleware::class,
// Only if a not found error is triggered, set-up the locale to be used
Common\Middleware\LocaleMiddleware::class,
Core\Response\NotFoundHandler::class,
],
'priority' => 1,

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use Cocur\Slugify\Slugify;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
return [
'slugify_options' => [
'lowercase' => false,
],
'dependencies' => [
'factories' => [
Slugify::class => ConfigAbstractFactory::class,
],
],
ConfigAbstractFactory::class => [
Slugify::class => ['config.slugify_options'],
],
];

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'zend-expressive-swoole' => [
'enable_coroutine' => true,
'swoole-http-server' => [
'host' => '0.0.0.0',
'process-name' => 'shlink',
'static-files' => [
'enable' => false,
],
],
],
];

View File

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

View File

@@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
use Zend\ConfigAggregator\ConfigAggregator;
return [
'debug' => false,
'config_cache_enabled' => true,
ConfigAggregator::ENABLE_CACHE => true,
];

View File

@@ -12,10 +12,7 @@ return (new ConfigAggregator\ConfigAggregator([
Expressive\Router\ConfigProvider::class,
Expressive\Router\FastRouteRouter\ConfigProvider::class,
Expressive\Plates\ConfigProvider::class,
Expressive\Helper\ConfigProvider::class,
\class_exists(Expressive\Swoole\ConfigProvider::class)
? Expressive\Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
Expressive\Swoole\ConfigProvider::class,
ExpressiveErrorHandler\ConfigProvider::class,
Common\ConfigProvider::class,
Core\ConfigProvider::class,

View File

@@ -0,0 +1,11 @@
<VirtualHost *:80>
ServerName doma.in
DocumentRoot "/path/to/shlink/public"
<Directory "/path/to/shlink/public">
Options FollowSymLinks Includes ExecCGI
AllowOverride all
Order allow,deny
Allow from all
</Directory>
</VirtualHost>

View File

@@ -0,0 +1,22 @@
server {
server_name doma.in;
listen 80;
root /path/to/shlink/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
location ~ /\.ht {
deny all;
}
}

View File

@@ -0,0 +1,13 @@
/var/log/shlink/shlink_swoole.log {
su root root
daily
missingok
rotate 120
compress
delaycompress
notifempty
create 0640 root root
postrotate
/etc/init.d/shlink_swoole restart
endscript
}

View File

@@ -0,0 +1,54 @@
#!/bin/bash
### BEGIN INIT INFO
# Provides: shlink_swoole
# Required-Start: $local_fs $network $named $time $syslog
# Required-Stop: $local_fs $network $named $time $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Description: Shlink non-blocking server with swoole
### END INIT INFO
SCRIPT=/path/to/shlink/vendor/bin/zend-expressive-swoole\ start
RUNAS=root
PIDFILE=/var/run/shlink_swoole.pid
LOGDIR=/var/log/shlink
LOGFILE=${LOGDIR}/shlink_swoole.log
start() {
if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with swoole already running' >&2
return 1
fi
echo 'Starting shlink with swoole' >&2
mkdir -p "$LOGDIR"
touch "$LOGFILE"
local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!"
su -c "$CMD" $RUNAS > "$PIDFILE"
echo 'Shlink started' >&2
}
stop() {
if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with swoole not running' >&2
return 1
fi
echo 'Stopping shlink with swoole' >&2
kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE"
echo 'Shlink stopped' >&2
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
*)
echo "Usage: $0 {start|stop|restart}"
esac

View File

@@ -0,0 +1,98 @@
FROM php:7.1.22-cli-alpine3.7
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install iconv
RUN docker-php-ext-install mbstring
RUN docker-php-ext-install calendar
RUN apk add --no-cache --virtual sqlite-libs
RUN apk add --no-cache --virtual sqlite-dev
RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache --virtual icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache --virtual zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache --virtual libmcrypt-dev
RUN docker-php-ext-install mcrypt
RUN apk add --no-cache --virtual libpng-dev
RUN docker-php-ext-install gd
# Install redis extension
ADD https://github.com/phpredis/phpredis/archive/3.1.4.tar.gz /tmp/phpredis.tar.gz
RUN mkdir -p /usr/src/php/ext/redis\
&& tar xf /tmp/phpredis.tar.gz -C /usr/src/php/ext/redis --strip-components=1
# configure and install
RUN docker-php-ext-configure redis\
&& docker-php-ext-install redis
# cleanup
RUN rm /tmp/phpredis.tar.gz
# Install memcached extension
RUN apk add --no-cache --virtual cyrus-sasl-dev
RUN apk add --no-cache --virtual libmemcached-dev
ADD https://github.com/php-memcached-dev/php-memcached/archive/php7.tar.gz /tmp/memcached.tar.gz
RUN mkdir -p /usr/src/php/ext/memcached\
&& tar xf /tmp/memcached.tar.gz -C /usr/src/php/ext/memcached --strip-components=1
# configure and install
RUN docker-php-ext-configure memcached\
&& docker-php-ext-install memcached
# cleanup
RUN rm /tmp/memcached.tar.gz
# Install APCu extension
ADD https://pecl.php.net/get/apcu-5.1.3.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
# configure and install
RUN docker-php-ext-configure apcu\
&& docker-php-ext-install apcu
# cleanup
RUN rm /tmp/apcu.tar.gz
# Install APCu-BC extension
ADD https://pecl.php.net/get/apcu_bc-1.0.3.tgz /tmp/apcu_bc.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu-bc\
&& tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
# configure and install
RUN docker-php-ext-configure apcu-bc\
&& docker-php-ext-install apcu-bc
# cleanup
RUN rm /tmp/apcu_bc.tar.gz
# Load APCU.ini before APC.ini
RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install swoole
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
pecl install swoole && \
docker-php-ext-enable swoole && \
apk del .phpize-deps
# Install composer
RUN php -r "readfile('https://getcomposer.org/installer');" | php
RUN chmod +x composer.phar
RUN mv composer.phar /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home
VOLUME /home/shlink
WORKDIR /home/shlink
# Expose swoole port
EXPOSE 8080
CMD /usr/local/bin/composer update && \
# When restarting the container, swoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php ./vendor/bin/zend-expressive-swoole start; do sleep 1 ; done

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,3 +6,9 @@ services:
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_swoole:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro

View File

@@ -26,6 +26,18 @@ services:
links:
- shlink_db
shlink_swoole:
container_name: shlink_swoole
build:
context: .
dockerfile: ./data/infra/swoole.Dockerfile
ports:
- "8080:8080"
volumes:
- ./:/home/shlink
links:
- shlink_db
shlink_db:
container_name: shlink_db
build:

View File

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

View File

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

View File

@@ -33,6 +33,24 @@
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"description": "The page to display. Defaults to 1",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "itemsPerPage",
"in": "query",
"description": "The amount of items to return on every page. Defaults to all the items",
"required": false,
"schema": {
"type": "number"
}
}
],
"security": [
@@ -59,6 +77,9 @@
"items": {
"$ref": "../definitions/Visit.json"
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}
}
}
@@ -96,7 +117,14 @@
"userAgent": "some_web_crawler/1.4",
"visitLocation": null
}
]
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}
}

View File

@@ -1,2 +1,9 @@
#!/usr/bin/env bash
docker exec -it shlink_php /bin/sh -c "cd /home/shlink/www && $*"
# Run docker containers if they are not up yet
if [[ $(docker ps | grep shlink_swoole) ]]; then :
else
docker-compose up -d
fi
docker exec -it shlink_swoole /bin/sh -c "$*"

View File

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

View File

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

View File

@@ -3,12 +3,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use function Shlinkio\Shlink\Common\env;
return [
'cli' => [
'locale' => env('CLI_LOCALE', 'en'),
'commands' => [
Command\ShortUrl\GenerateShortUrlCommand::NAME => Command\ShortUrl\GenerateShortUrlCommand::class,
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
@@ -18,6 +15,7 @@ return [
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
Command\Visit\UpdateDbCommand::NAME => Command\Visit\UpdateDbCommand::class,
Command\Config\GenerateCharsetCommand::NAME => Command\Config\GenerateCharsetCommand::class,
Command\Config\GenerateSecretCommand::NAME => Command\Config\GenerateSecretCommand::class,

View File

@@ -3,13 +3,15 @@ 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 Zend\I18n\Translator\Translator;
use Symfony\Component\Lock;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
@@ -25,9 +27,10 @@ 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,
Command\Config\GenerateCharsetCommand::class => InvokableFactory::class,
Command\Config\GenerateSecretCommand::class => InvokableFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
@@ -41,45 +44,28 @@ return [
],
ConfigAbstractFactory::class => [
Command\ShortUrl\GenerateShortUrlCommand::class => [
Service\UrlShortener::class,
'translator',
'config.url_shortener.domain',
],
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'],
Command\ShortUrl\ListShortUrlsCommand::class => [
Service\ShortUrlService::class,
'translator',
'config.url_shortener.domain',
],
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'],
Command\ShortUrl\GeneratePreviewCommand::class => [
Service\ShortUrlService::class,
PreviewGenerator::class,
'translator',
],
Command\ShortUrl\DeleteShortUrlCommand::class => [
Service\ShortUrl\DeleteShortUrlService::class,
'translator',
],
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class],
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
Command\ShortUrl\GeneratePreviewCommand::class => [Service\ShortUrlService::class, PreviewGenerator::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpApiLocationResolver::class,
'translator',
IpLocationResolverInterface::class,
Lock\Factory::class,
],
Command\Visit\UpdateDbCommand::class => [DbUpdater::class],
Command\Config\GenerateCharsetCommand::class => ['translator'],
Command\Config\GenerateSecretCommand::class => ['translator'],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, 'translator'],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class, 'translator'],
Command\Api\ListKeysCommand::class => [ApiKeyService::class, 'translator'],
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class],
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class],
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class],
],
];

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
return [
'translator' => [
'translation_file_patterns' => [
[
'type' => 'gettext',
'base_dir' => __DIR__ . '/../lang',
'pattern' => '%s.mo',
],
],
],
];

Binary file not shown.

View File

@@ -1,379 +0,0 @@
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"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.6\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: translate;translatePlural\n"
"X-Poedit-SearchPath-0: src\n"
"X-Poedit-SearchPath-1: config\n"
msgid "Disables an API key."
msgstr "Desahbilita una clave de API."
msgid "The API key to disable"
msgstr "La clave de API a deshabilitar"
#, php-format
msgid "API key \"%s\" properly disabled"
msgstr "Clave de API \"%s\" deshabilitada correctamente"
#, php-format
msgid "API key \"%s\" does not exist."
msgstr "La clave de API \"%s\" no existe."
msgid "Generates a new valid API key."
msgstr "Genera una nueva clave de API válida."
msgid "The date in which the API key should expire. Use any valid PHP format."
msgstr ""
"La fecha en la que la clave de API debe expirar. Utiliza cualquier valor "
"válido en PHP."
#, php-format
msgid "Generated API key: \"%s\""
msgstr "Generada clave de API. \"%s\""
msgid "Lists all the available API keys."
msgstr "Lista todas las claves de API disponibles."
msgid "Tells if only enabled API keys should be returned."
msgstr "Define si sólo las claves de API habilitadas deben ser devueltas."
msgid "Key"
msgstr "Clave"
msgid "Is enabled"
msgstr "Está habilitada"
msgid "Expiration date"
msgstr "Fecha de caducidad"
#, php-format
msgid ""
"Generates a character set sample just by shuffling the default one, \"%s\". "
"Then it can be set in the SHORTCODE_CHARS environment variable"
msgstr ""
"Genera un grupo de caracteres simplemente mexclando el grupo por defecto \"%s"
"\". Después puede ser utilizado en la variable de entrono SHORTCODE_CHARS"
#, php-format
msgid "Character set: \"%s\""
msgstr "Grupo de caracteres: \"%s\""
msgid ""
"Generates a random secret string that can be used for JWT token encryption"
msgstr ""
"Genera una cadena de caracteres aleatoria que puede ser usada para cifrar "
"tokens JWT"
#, php-format
msgid "Secret key: \"%s\""
msgstr "Clave secreta: \"%s\""
msgid "Deletes a short URL"
msgstr "Elimina una URL"
msgid "The short code for the short URL to be deleted"
msgstr "El código corto de la URL corta a eliminar"
msgid ""
"Ignores the safety visits threshold check, which could make short URLs with "
"many visits to be accidentally deleted"
msgstr ""
"Ignora el límite de seguridad de visitas, pudiendo resultar en el borrado "
"accidental de URLs con muchas visitas"
#, php-format
msgid "Provided short code \"%s\" could not be found."
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
#, php-format
msgid ""
"It was not possible to delete the short URL with short code \"%s\" because "
"it has more than %s visits."
msgstr ""
"No se pudo eliminar la URL acortada con código corto \"%s\" porque tiene más "
"de %s visitas."
msgid "Do you want to delete it anyway?"
msgstr "¿Aún así quieres eliminarla?"
msgid "Short URL was not deleted."
msgstr "La URL corta no ha sido eliminada."
#, php-format
msgid "Short URL with short code \"%s\" successfully deleted."
msgstr "La URL acortada con el código corto \"%s\" eliminada correctamente."
msgid ""
"Processes and generates the previews for every URL, improving performance "
"for later web requests."
msgstr ""
"Procesa y genera las vistas previas para cada URL, mejorando el rendimiento "
"para peticiones web posteriores."
msgid "Finished processing all URLs"
msgstr "Finalizado el procesado de todas las URLs"
#, php-format
msgid "Processing URL %s..."
msgstr "Procesando URL %s..."
msgid " <info>Success!</info>"
msgstr " <info>¡Correcto!</info>"
msgid "Error"
msgstr "Error"
msgid "Generates a short URL for provided long URL and returns it"
msgstr "Genera una URL corta para la URL larga proporcionada y la devuelve"
msgid "The long URL to parse"
msgstr "La URL larga a procesar"
msgid "Tags to apply to the new short URL"
msgstr "Etiquetas a aplicar a la nueva URL acortada"
msgid ""
"The date from which this short URL will be valid. If someone tries to access "
"it before this date, it will not be found."
msgstr ""
"La fecha desde la cual será válida esta URL acortada. Si alguien intenta "
"acceder a ella antes de esta fecha, no será encontrada."
msgid ""
"The date until which this short URL will be valid. If someone tries to "
"access it after this date, it will not be found."
msgstr ""
"La fecha hasta la cual será válida está URL acortada. Si alguien intenta "
"acceder a ella después de esta fecha, no será encontrada."
msgid "If provided, this slug will be used instead of generating a short code"
msgstr ""
"Si se proporciona, este slug será usado en vez de generar un código corto"
msgid "This will limit the number of visits for this short URL."
msgstr "Esto limitará el número de visitas a esta URL acortada."
#, fuzzy
#| msgid "A long URL was not provided. Which URL do you want to shorten?:"
msgid "A long URL was not provided. Which URL do you want to be shortened?"
msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?"
msgid "A URL was not provided!"
msgstr "¡No se ha proporcionado una URL!"
msgid "Processed long URL:"
msgstr "URL larga procesada:"
msgid "Generated short URL:"
msgstr "URL corta generada:"
#, php-format
msgid "Provided URL \"%s\" is invalid. Try with a different one."
msgstr "La URL proporcionada \"%s\" e inválida. Prueba con una diferente."
#, php-format
msgid ""
"Provided slug \"%s\" is already in use by another URL. Try with a different "
"one."
msgstr ""
"El slug proporcionado \"%s\" ya está siendo usado para otra URL. Prueba con "
"uno diferente."
msgid "Returns the detailed visits information for provided short code"
msgstr ""
"Devuelve la información detallada de visitas para el código corto "
"proporcionado"
msgid "The short code which visits we want to get"
msgstr "El código corto del cual queremos obtener las visitas"
msgid "Allows to filter visits, returning only those older than start date"
msgstr ""
"Permite filtrar las visitas, devolviendo sólo aquellas más antiguas que "
"startDate"
msgid "Allows to filter visits, returning only those newer than end date"
msgstr ""
"Permite filtrar las visitas, devolviendo sólo aquellas más nuevas que endDate"
msgid "A short code was not provided. Which short code do you want to use?"
msgstr "No se proporcionó un código corto. ¿Qué código corto deseas usar?"
msgid "Referer"
msgstr "Origen"
msgid "Date"
msgstr "Fecha"
msgid "User agent"
msgstr "Agente de usuario"
msgid "Country"
msgstr "País"
msgid "List all short URLs"
msgstr "Listar todas las URLs cortas"
#, php-format
msgid "The first page to list (%s items per page)"
msgstr "La primera página a listar (%s elementos por página)"
msgid ""
"A query used to filter results by searching for it on the longUrl and "
"shortCode fields"
msgstr ""
"Una consulta usada para filtrar el resultado buscándola en los campos "
"longUrl y shortCode"
msgid "A comma-separated list of tags to filter results"
msgstr "Una lista de etiquetas separadas por coma para filtrar el resultado"
msgid ""
"The field from which we want to order by. Pass ASC or DESC separated by a "
"comma"
msgstr ""
"El campo por el cual queremos ordernar. Pasa ASC o DESC separado por una coma"
msgid "Whether to display the tags or not"
msgstr "Si se desea mostrar las etiquetas o no"
msgid "Short code"
msgstr "Código corto"
msgid "Short URL"
msgstr "URL corta"
msgid "Long URL"
msgstr "URL larga"
msgid "Date created"
msgstr "Fecha de creación"
msgid "Visits count"
msgstr "Número de visitas"
msgid "Tags"
msgstr "Etiquetas"
msgid "Short URLs properly listed"
msgstr "URLs cortas listadas correctamente"
msgid "Continue with page"
msgstr "Continuar con la página"
msgid "Returns the long URL behind a short code"
msgstr "Devuelve la URL larga detrás de un código corto"
msgid "The short code to parse"
msgstr "El código corto a convertir"
msgid "A short code was not provided. Which short code do you want to parse?"
msgstr ""
"No se proporcionó un código corto. ¿Qué código corto quieres convertir?"
msgid "Long URL:"
msgstr "URL larga:"
#, php-format
msgid "Provided short code \"%s\" has an invalid format."
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido."
msgid "Creates one or more tags."
msgstr "Crea una o más etiquetas."
msgid "The name of the tags to create"
msgstr "El nombre de las etiquetas a crear"
msgid "You have to provide at least one tag name"
msgstr "Debes proporcionar al menos un nombre de etiqueta"
msgid "Tags properly created"
msgstr "Etiquetas correctamente creadas"
msgid "Deletes one or more tags."
msgstr "Elimina una o más etiquetas."
msgid "The name of the tags to delete"
msgstr "El nombre de las etiquetas a eliminar"
msgid "Tags properly deleted"
msgstr "Etiquetas correctamente eliminadas"
msgid "Lists existing tags."
msgstr "Lista las etiquetas existentes."
#, fuzzy
msgid "Name"
msgstr "Nombre"
msgid "No tags yet"
msgstr "Aún no hay etiquetas"
msgid "Renames one existing tag."
msgstr "Renombra una etiqueta existente."
msgid "Current name of the tag."
msgstr "Nombre actual de la etiqueta."
msgid "New name of the tag."
msgstr "Nuevo nombre de la etiqueta."
msgid "Tag properly renamed."
msgstr "Etiqueta correctamente renombrada."
#, php-format
msgid "A tag with name \"%s\" was not found"
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"
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"
msgstr "Se produjo un error al localizar la IP"
#, php-format
msgid "IP location resolver limit reached. Waiting %s seconds..."
msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"
#~ msgid "Remote Address"
#~ msgstr "Dirección remota"
#~ msgid "Original URL"
#~ msgstr "URL original"
#~ msgid "You have reached last page"
#~ msgstr "Has alcanzado la última página"
#~ msgid "No URL found for short code \"%s\""
#~ msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
#~ msgid "Created tags"
#~ msgstr "Etiquetas creadas"
#~ msgid "Deleted tags"
#~ msgstr "Etiquetas eliminadas"

View File

@@ -3,40 +3,33 @@ 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;
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 DisableKeyCommand extends Command
{
public const NAME = 'api-key:disable';
/**
* @var ApiKeyServiceInterface
*/
/** @var ApiKeyServiceInterface */
private $apiKeyService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
public function __construct(ApiKeyServiceInterface $apiKeyService)
{
$this->apiKeyService = $apiKeyService;
$this->translator = $translator;
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Disables an API key.'))
->addArgument('apiKey', InputArgument::REQUIRED, $this->translator->translate('The API key to disable'));
->setDescription('Disables an API key.')
->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable');
}
protected function execute(InputInterface $input, OutputInterface $output): void
@@ -46,9 +39,9 @@ class DisableKeyCommand extends Command
try {
$this->apiKeyService->disable($apiKey);
$io->success(sprintf($this->translator->translate('API key "%s" properly disabled'), $apiKey));
} catch (\InvalidArgumentException $e) {
$io->error(sprintf($this->translator->translate('API key "%s" does not exist.'), $apiKey));
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
} catch (InvalidArgumentException $e) {
$io->error(sprintf('API key "%s" does not exist.', $apiKey));
}
}
}

View File

@@ -10,39 +10,32 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class GenerateKeyCommand extends Command
{
public const NAME = 'api-key:generate';
/**
* @var ApiKeyServiceInterface
*/
/** @var ApiKeyServiceInterface */
private $apiKeyService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
public function __construct(ApiKeyServiceInterface $apiKeyService)
{
$this->apiKeyService = $apiKeyService;
$this->translator = $translator;
parent::__construct();
}
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Generates a new valid API key.'))
->addOption(
'expirationDate',
'e',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('The date in which the API key should expire. Use any valid PHP format.')
);
$this
->setName(self::NAME)
->setDescription('Generates a new valid API key.')
->addOption(
'expirationDate',
'e',
InputOption::VALUE_OPTIONAL,
'The date in which the API key should expire. Use any valid PHP format.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
@@ -50,8 +43,6 @@ class GenerateKeyCommand extends Command
$expirationDate = $input->getOption('expirationDate');
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);
(new SymfonyStyle($input, $output))->success(
sprintf($this->translator->translate('Generated API key: "%s"'), $apiKey)
);
(new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey));
}
}

View File

@@ -10,8 +10,8 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function array_filter;
use function array_map;
use function sprintf;
class ListKeysCommand extends Command
@@ -22,61 +22,50 @@ class ListKeysCommand extends Command
public const NAME = 'api-key:list';
/**
* @var ApiKeyServiceInterface
*/
/** @var ApiKeyServiceInterface */
private $apiKeyService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
public function __construct(ApiKeyServiceInterface $apiKeyService)
{
$this->apiKeyService = $apiKeyService;
$this->translator = $translator;
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Lists all the available API keys.'))
->addOption(
'enabledOnly',
null,
InputOption::VALUE_NONE,
$this->translator->translate('Tells if only enabled API keys should be returned.')
);
$this
->setName(self::NAME)
->setDescription('Lists all the available API keys.')
->addOption(
'enabledOnly',
'e',
InputOption::VALUE_NONE,
'Tells if only enabled API keys should be returned.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$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'),
! $enabledOnly ? $this->translator->translate('Is enabled') : null,
$this->translator->translate('Expiration date'),
'Key',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
]), $rows);
}

View File

@@ -8,7 +8,6 @@ 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 Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
use function str_shuffle;
@@ -16,31 +15,20 @@ class GenerateCharsetCommand extends Command
{
public const NAME = 'config:generate-charset';
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
parent::__construct();
}
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription(sprintf($this->translator->translate(
'Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable'
), UrlShortener::DEFAULT_CHARS));
$this
->setName(self::NAME)
->setDescription(sprintf(
'Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable',
UrlShortener::DEFAULT_CHARS
));
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
(new SymfonyStyle($input, $output))->success(
sprintf($this->translator->translate('Character set: "%s"'), $charSet)
);
(new SymfonyStyle($input, $output))->success(sprintf('Character set: "%s"', $charSet));
}
}

View File

@@ -8,7 +8,6 @@ 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 Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class GenerateSecretCommand extends Command
@@ -17,30 +16,16 @@ class GenerateSecretCommand extends Command
public const NAME = 'config:generate-secret';
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
parent::__construct();
}
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate(
'Generates a random secret string that can be used for JWT token encryption'
));
$this
->setName(self::NAME)
->setDescription('[DEPRECATED] Generates a random secret string that can be used for JWT token encryption');
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$secret = $this->generateRandomString(32);
(new SymfonyStyle($input, $output))->success(
sprintf($this->translator->translate('Secret key: "%s"'), $secret)
);
(new SymfonyStyle($input, $output))->success(sprintf('Secret key: "%s"', $secret));
}
}

View File

@@ -11,27 +11,20 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class DeleteShortUrlCommand extends Command
{
public const NAME = 'short-url:delete';
private const ALIASES = ['short-code:delete'];
/**
* @var DeleteShortUrlServiceInterface
*/
/** @var DeleteShortUrlServiceInterface */
private $deleteShortUrlService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService, TranslatorInterface $translator)
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
{
$this->deleteShortUrlService = $deleteShortUrlService;
$this->translator = $translator;
parent::__construct();
$this->deleteShortUrlService = $deleteShortUrlService;
}
protected function configure(): void
@@ -39,22 +32,14 @@ class DeleteShortUrlCommand extends Command
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Deletes a short URL')
)
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code for the short URL to be deleted')
)
->setDescription('Deletes a short URL')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code for the short URL to be deleted')
->addOption(
'ignore-threshold',
'i',
InputOption::VALUE_NONE,
$this->translator->translate(
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted'
)
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted'
);
}
@@ -67,9 +52,7 @@ class DeleteShortUrlCommand extends Command
try {
$this->runDelete($io, $shortCode, $ignoreThreshold);
} catch (Exception\InvalidShortCodeException $e) {
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
);
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode));
} catch (Exception\DeleteShortUrlException $e) {
$this->retry($io, $shortCode, $e);
}
@@ -77,25 +60,24 @@ class DeleteShortUrlCommand extends Command
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): void
{
$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());
$warningMsg = sprintf(
'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 . '</>');
$forceDelete = $io->confirm($this->translator->translate('Do you want to delete it anyway?'), false);
$forceDelete = $io->confirm('Do you want to delete it anyway?', false);
if ($forceDelete) {
$this->runDelete($io, $shortCode, true);
} else {
$io->warning($this->translator->translate('Short URL was not deleted.'));
$io->warning('Short URL was not deleted.');
}
}
private function runDelete(SymfonyStyle $io, string $shortCode, bool $ignoreThreshold): void
{
$this->deleteShortUrlService->deleteByShortCode($shortCode, $ignoreThreshold);
$io->success(\sprintf(
$this->translator->translate('Short URL with short code "%s" successfully deleted.'),
$shortCode
));
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode));
}
}

View File

@@ -10,35 +10,23 @@ 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 Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class GeneratePreviewCommand extends Command
{
public const NAME = 'short-url:process-previews';
private const ALIASES = ['shortcode:process-previews', 'short-code:process-previews'];
/**
* @var PreviewGeneratorInterface
*/
/** @var PreviewGeneratorInterface */
private $previewGenerator;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* @var ShortUrlServiceInterface
*/
/** @var ShortUrlServiceInterface */
private $shortUrlService;
public function __construct(
ShortUrlServiceInterface $shortUrlService,
PreviewGeneratorInterface $previewGenerator,
TranslatorInterface $translator
) {
public function __construct(ShortUrlServiceInterface $shortUrlService, PreviewGeneratorInterface $previewGenerator)
{
parent::__construct();
$this->shortUrlService = $shortUrlService;
$this->previewGenerator = $previewGenerator;
$this->translator = $translator;
parent::__construct(null);
}
protected function configure(): void
@@ -47,9 +35,7 @@ class GeneratePreviewCommand extends Command
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate(
'Processes and generates the previews for every URL, improving performance for later web requests.'
)
'Processes and generates the previews for every URL, improving performance for later web requests.'
);
}
@@ -61,21 +47,21 @@ class GeneratePreviewCommand extends Command
$page += 1;
foreach ($shortUrls as $shortUrl) {
$this->processUrl($shortUrl->getOriginalUrl(), $output);
$this->processUrl($shortUrl->getLongUrl(), $output);
}
} while ($page <= $shortUrls->count());
(new SymfonyStyle($input, $output))->success($this->translator->translate('Finished processing all URLs'));
(new SymfonyStyle($input, $output))->success('Finished processing all URLs');
}
private function processUrl($url, OutputInterface $output): void
{
try {
$output->write(\sprintf($this->translator->translate('Processing URL %s...'), $url));
$output->write(sprintf('Processing URL %s...', $url));
$this->previewGenerator->generatePreview($url);
$output->writeln($this->translator->translate(' <info>Success!</info>'));
$output->writeln(' <info>Success!</info>');
} catch (PreviewGenerationException $e) {
$output->writeln(' <error>' . $this->translator->translate('Error') . '</error>');
$output->writeln(' <error>Error</error>');
if ($output->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}

View File

@@ -15,7 +15,9 @@ use Symfony\Component\Console\Input\InputOption;
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
{
@@ -24,28 +26,16 @@ class GenerateShortUrlCommand extends Command
public const NAME = 'short-url:generate';
private const ALIASES = ['shortcode:generate', 'short-code:generate'];
/**
* @var UrlShortenerInterface
*/
/** @var UrlShortenerInterface */
private $urlShortener;
/**
* @var array
*/
/** @var array */
private $domainConfig;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(
UrlShortenerInterface $urlShortener,
TranslatorInterface $translator,
array $domainConfig
) {
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
{
parent::__construct();
$this->urlShortener = $urlShortener;
$this->translator = $translator;
$this->domainConfig = $domainConfig;
parent::__construct(null);
}
protected function configure(): void
@@ -53,30 +43,40 @@ class GenerateShortUrlCommand extends Command
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Generates a short URL for provided long URL and returns it')
)
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
->setDescription('Generates a short URL for provided long URL and returns it')
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse')
->addOption(
'tags',
't',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
$this->translator->translate('Tags to apply to the new short URL')
'Tags to apply to the new short URL'
)
->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate(
->addOption(
'validSince',
's',
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.'
))
->addOption('validUntil', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate(
)
->addOption(
'validUntil',
'u',
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.'
))
->addOption('customSlug', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate(
)
->addOption(
'customSlug',
'c',
InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code'
))
->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate(
)
->addOption(
'maxVisits',
'm',
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.'
));
);
}
protected function interact(InputInterface $input, OutputInterface $output): void
@@ -87,9 +87,7 @@ class GenerateShortUrlCommand extends Command
return;
}
$longUrl = $io->ask(
$this->translator->translate('A long URL was not provided. Which URL do you want to be shortened?')
);
$longUrl = $io->ask('A long URL was not provided. Which URL do you want to be shortened?');
if (! empty($longUrl)) {
$input->setArgument('longUrl', $longUrl);
}
@@ -100,15 +98,15 @@ class GenerateShortUrlCommand extends Command
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl');
if (empty($longUrl)) {
$io->error($this->translator->translate('A URL was not provided!'));
$io->error('A URL was not provided!');
return;
}
$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,21 +124,15 @@ 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('Processed long URL: <info>%s</info>', $longUrl),
sprintf('Generated short URL: <info>%s</info>', $shortUrl),
]);
} catch (InvalidUrlException $e) {
$io->error(\sprintf(
$this->translator->translate('Provided URL "%s" is invalid. Try with a different one.'),
$longUrl
));
$io->error(sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl));
} catch (NonUniqueSlugException $e) {
$io->error(\sprintf(
$this->translator->translate(
'Provided slug "%s" is already in use by another URL. Try with a different one.'
),
$customSlug
));
$io->error(
sprintf('Provided slug "%s" is already in use by another URL. Try with a different one.', $customSlug)
);
}
}

View File

@@ -5,6 +5,8 @@ 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\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -12,26 +14,21 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use Zend\Stdlib\ArrayUtils;
use function array_map;
use function Functional\select_keys;
class GetVisitsCommand extends Command
{
public const NAME = 'short-url:visits';
private const ALIASES = ['shortcode:visits', 'short-code:visits'];
/**
* @var VisitsTrackerInterface
*/
/** @var VisitsTrackerInterface */
private $visitsTracker;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(VisitsTrackerInterface $visitsTracker, TranslatorInterface $translator)
public function __construct(VisitsTrackerInterface $visitsTracker)
{
$this->visitsTracker = $visitsTracker;
$this->translator = $translator;
parent::__construct();
}
@@ -40,25 +37,19 @@ class GetVisitsCommand extends Command
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Returns the detailed visits information for provided short code')
)
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code which visits we want to get')
)
->setDescription('Returns the detailed visits information for provided short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get')
->addOption(
'startDate',
's',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('Allows to filter visits, returning only those older than start date')
'Allows to filter visits, returning only those older than start date'
)
->addOption(
'endDate',
'e',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('Allows to filter visits, returning only those newer than end date')
'Allows to filter visits, returning only those newer than end date'
);
}
@@ -70,9 +61,7 @@ class GetVisitsCommand extends Command
}
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask(
$this->translator->translate('A short code was not provided. Which short code do you want to use?')
);
$shortCode = $io->ask('A short code was not provided. Which short code do you want to use?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
@@ -85,24 +74,15 @@ class GetVisitsCommand extends Command
$startDate = $this->getDateOption($input, 'startDate');
$endDate = $this->getDateOption($input, 'endDate');
$visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate));
$rows = [];
foreach ($visits as $row) {
$rowData = $row->jsonSerialize();
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange($startDate, $endDate)));
$visits = ArrayUtils::iteratorToArray($paginator->getCurrentItems());
// Unset location info and remote addr
unset($rowData['visitLocation'], $rowData['remoteAddr']);
$rowData['country'] = $row->getVisitLocation()->getCountryName();
$rows[] = \array_values($rowData);
}
$io->table([
$this->translator->translate('Referer'),
$this->translator->translate('Date'),
$this->translator->translate('User agent'),
$this->translator->translate('Country'),
], $rows);
$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(['Referer', 'Date', 'User agent', 'Country'], $rows);
}
private function getDateOption(InputInterface $input, $key)

View File

@@ -12,7 +12,11 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function array_values;
use function count;
use function explode;
use function implode;
use function sprintf;
class ListShortUrlsCommand extends Command
{
@@ -21,27 +25,15 @@ class ListShortUrlsCommand extends Command
public const NAME = 'short-url:list';
private const ALIASES = ['shortcode:list', 'short-code:list'];
/**
* @var ShortUrlServiceInterface
*/
/** @var ShortUrlServiceInterface */
private $shortUrlService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* @var array
*/
/** @var array */
private $domainConfig;
public function __construct(
ShortUrlServiceInterface $shortUrlService,
TranslatorInterface $translator,
array $domainConfig
) {
$this->shortUrlService = $shortUrlService;
$this->translator = $translator;
public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
{
parent::__construct();
$this->shortUrlService = $shortUrlService;
$this->domainConfig = $domainConfig;
}
@@ -50,45 +42,33 @@ class ListShortUrlsCommand extends Command
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription($this->translator->translate('List all short URLs'))
->setDescription('List all short URLs')
->addOption(
'page',
'p',
InputOption::VALUE_OPTIONAL,
sprintf(
$this->translator->translate('The first page to list (%s items per page)'),
PaginableRepositoryAdapter::ITEMS_PER_PAGE
),
1
sprintf('The first page to list (%s items per page)', PaginableRepositoryAdapter::ITEMS_PER_PAGE),
'1'
)
->addOption(
'searchTerm',
's',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'A query used to filter results by searching for it on the longUrl and shortCode fields'
)
'A query used to filter results by searching for it on the longUrl and shortCode fields'
)
->addOption(
'tags',
't',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('A comma-separated list of tags to filter results')
'A comma-separated list of tags to filter results'
)
->addOption(
'orderBy',
'o',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
)
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
)
->addOption(
'showTags',
null,
InputOption::VALUE_NONE,
$this->translator->translate('Whether to display the tags or not')
);
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not');
}
protected function execute(InputInterface $input, OutputInterface $output): void
@@ -97,7 +77,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);
@@ -105,39 +85,30 @@ class ListShortUrlsCommand extends Command
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$page++;
$headers = [
$this->translator->translate('Short code'),
$this->translator->translate('Short URL'),
$this->translator->translate('Long URL'),
$this->translator->translate('Date created'),
$this->translator->translate('Visits count'),
];
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {
$headers[] = $this->translator->translate('Tags');
$headers[] = 'Tags';
}
$rows = [];
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);
if ($this->isLastPage($result)) {
$continue = false;
$io->success($this->translator->translate('Short URLs properly listed'));
$io->success('Short URLs properly listed');
} else {
$continue = $io->confirm(
\sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),
false
);
$continue = $io->confirm(sprintf('Continue with page <options=bold>%s</>?', $page), false);
}
} while ($continue);
}
@@ -149,7 +120,7 @@ class ListShortUrlsCommand extends Command
return null;
}
$orderBy = \explode(',', $orderBy);
return \count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
$orderBy = explode(',', $orderBy);
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
}
}

View File

@@ -11,27 +11,20 @@ use Symfony\Component\Console\Input\InputArgument;
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
{
public const NAME = 'short-url:parse';
private const ALIASES = ['shortcode:parse', 'short-code:parse'];
/**
* @var UrlShortenerInterface
*/
/** @var UrlShortenerInterface */
private $urlShortener;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(UrlShortenerInterface $urlShortener, TranslatorInterface $translator)
public function __construct(UrlShortenerInterface $urlShortener)
{
parent::__construct();
$this->urlShortener = $urlShortener;
$this->translator = $translator;
parent::__construct(null);
}
protected function configure(): void
@@ -39,12 +32,8 @@ class ResolveUrlCommand extends Command
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to parse')
);
->setDescription('Returns the long URL behind a short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse');
}
protected function interact(InputInterface $input, OutputInterface $output): void
@@ -55,9 +44,7 @@ class ResolveUrlCommand extends Command
}
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask(
$this->translator->translate('A short code was not provided. Which short code do you want to parse?')
);
$shortCode = $io->ask('A short code was not provided. Which short code do you want to parse?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
@@ -70,17 +57,11 @@ class ResolveUrlCommand extends Command
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$output->writeln(
\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $url->getLongUrl())
);
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
} catch (InvalidShortCodeException $e) {
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode)
);
$io->error(sprintf('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)
);
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode));
}
}
}

View File

@@ -9,38 +9,30 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class CreateTagCommand extends Command
{
public const NAME = 'tag:create';
/**
* @var TagServiceInterface
*/
/** @var TagServiceInterface */
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(TagServiceInterface $tagService, TranslatorInterface $translator)
public function __construct(TagServiceInterface $tagService)
{
$this->tagService = $tagService;
$this->translator = $translator;
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription($this->translator->translate('Creates one or more tags.'))
->setDescription('Creates one or more tags.')
->addOption(
'name',
't',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
$this->translator->translate('The name of the tags to create')
'The name of the tags to create'
);
}
@@ -50,11 +42,11 @@ class CreateTagCommand extends Command
$tagNames = $input->getOption('name');
if (empty($tagNames)) {
$io->warning($this->translator->translate('You have to provide at least one tag name'));
$io->warning('You have to provide at least one tag name');
return;
}
$this->tagService->createTags($tagNames);
$io->success($this->translator->translate('Tags properly created'));
$io->success('Tags properly created');
}
}

View File

@@ -9,38 +9,30 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class DeleteTagsCommand extends Command
{
public const NAME = 'tag:delete';
/**
* @var TagServiceInterface
*/
/** @var TagServiceInterface */
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(TagServiceInterface $tagService, TranslatorInterface $translator)
public function __construct(TagServiceInterface $tagService)
{
$this->tagService = $tagService;
$this->translator = $translator;
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription($this->translator->translate('Deletes one or more tags.'))
->setDescription('Deletes one or more tags.')
->addOption(
'name',
't',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
$this->translator->translate('The name of the tags to delete')
'The name of the tags to delete'
);
}
@@ -50,11 +42,11 @@ class DeleteTagsCommand extends Command
$tagNames = $input->getOption('name');
if (empty($tagNames)) {
$io->warning($this->translator->translate('You have to provide at least one tag name'));
$io->warning('You have to provide at least one tag name');
return;
}
$this->tagService->deleteTags($tagNames);
$io->success($this->translator->translate('Tags properly deleted'));
$io->success('Tags properly deleted');
}
}

View File

@@ -9,51 +9,43 @@ 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 Zend\I18n\Translator\TranslatorInterface;
use function array_map;
use function Functional\map;
class ListTagsCommand extends Command
{
public const NAME = 'tag:list';
/**
* @var TagServiceInterface
*/
/** @var TagServiceInterface */
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(TagServiceInterface $tagService, TranslatorInterface $translator)
public function __construct(TagServiceInterface $tagService)
{
$this->tagService = $tagService;
$this->translator = $translator;
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription($this->translator->translate('Lists existing tags.'));
->setDescription('Lists existing tags.');
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$io->table([$this->translator->translate('Name')], $this->getTagsRows());
$io->table(['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 [['No tags yet']];
}
return array_map(function (Tag $tag) {
return [$tag->getName()];
}, $tags);
return map($tags, function (Tag $tag) {
return [(string) $tag];
});
}
}

View File

@@ -10,36 +10,28 @@ use Symfony\Component\Console\Input\InputArgument;
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 RenameTagCommand extends Command
{
public const NAME = 'tag:rename';
/**
* @var TagServiceInterface
*/
/** @var TagServiceInterface */
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(TagServiceInterface $tagService, TranslatorInterface $translator)
public function __construct(TagServiceInterface $tagService)
{
$this->tagService = $tagService;
$this->translator = $translator;
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription($this->translator->translate('Renames one existing tag.'))
->addArgument('oldName', InputArgument::REQUIRED, $this->translator->translate('Current name of the tag.'))
->addArgument('newName', InputArgument::REQUIRED, $this->translator->translate('New name of the tag.'));
->setDescription('Renames one existing tag.')
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the tag.')
->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.');
}
protected function execute(InputInterface $input, OutputInterface $output): void
@@ -50,9 +42,9 @@ class RenameTagCommand extends Command
try {
$this->tagService->renameTag($oldName, $newName);
$io->success($this->translator->translate('Tag properly renamed.'));
$io->success('Tag properly renamed.');
} catch (EntityDoesNotExistException $e) {
$io->error(sprintf($this->translator->translate('A tag with name "%s" was not found'), $oldName));
$io->error(sprintf('A tag with name "%s" was not found', $oldName));
}
}
}

View File

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

View File

@@ -0,0 +1,64 @@
<?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;
class UpdateDbCommand extends Command
{
public const NAME = 'visit:update-db';
/** @var DbUpdaterInterface */
private $geoLiteDbUpdater;
public function __construct(DbUpdaterInterface $geoLiteDbUpdater)
{
parent::__construct();
$this->geoLiteDbUpdater = $geoLiteDbUpdater;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Updates the GeoLite2 database file used to geolocate IP addresses')
->setHelp(
'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('GeoLite2 database properly updated');
} catch (RuntimeException $e) {
$progressBar->finish();
$io->writeln('');
$io->error('An error occurred while updating GeoLite2 database');
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
}
}
}

View File

@@ -10,7 +10,6 @@ use Psr\Container\NotFoundExceptionInterface;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Symfony\Component\Console\Application as CliApp;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
@@ -34,8 +33,6 @@ class ApplicationFactory implements FactoryInterface
{
$config = $container->get('config')['cli'];
$appOptions = $container->get(AppOptions::class);
$translator = $container->get(Translator::class);
$translator->setLocale($config['locale']);
$commands = $config['commands'] ?? [];
$app = new CliApp($appOptions->getName(), $appOptions->getVersion());

View File

@@ -10,23 +10,18 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class DisableKeyCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $apiKeyService;
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $apiKeyService;
public function setUp()
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new DisableKeyCommand($this->apiKeyService->reveal(), Translator::factory([]));
$command = new DisableKeyCommand($this->apiKeyService->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
@@ -38,11 +33,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 +50,15 @@ class DisableKeyCommandTest extends TestCase
public function errorIsReturnedIfServiceThrowsException()
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class)
->shouldBeCalledTimes(1);
$disable = $this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class);
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('API key "abcd1234" does not exist.', $output);
$disable->shouldHaveBeenCalledOnce();
}
}

View File

@@ -12,23 +12,18 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class GenerateKeyCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $apiKeyService;
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $apiKeyService;
public function setUp()
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), Translator::factory([]));
$command = new GenerateKeyCommand($this->apiKeyService->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
@@ -39,11 +34,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 +50,7 @@ class GenerateKeyCommandTest extends TestCase
*/
public function expirationDateIsDefinedIfProvided()
{
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledTimes(1)
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',

View File

@@ -10,23 +10,18 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class ListKeysCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $apiKeyService;
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $apiKeyService;
public function setUp()
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new ListKeysCommand($this->apiKeyService->reveal(), Translator::factory([]));
$command = new ListKeysCommand($this->apiKeyService->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
@@ -35,30 +30,46 @@ class ListKeysCommandTest extends TestCase
/**
* @test
*/
public function ifEnabledOnlyIsNotProvidedEverythingIsListed()
public function everythingIsListedIfEnabledOnlyIsNotProvided()
{
$this->apiKeyService->listKeys(false)->willReturn([
new ApiKey(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:list',
'command' => ListKeysCommand::NAME,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Key', $output);
$this->assertContains('Is enabled', $output);
$this->assertContains(' +++ ', $output);
$this->assertNotContains(' --- ', $output);
$this->assertContains('Expiration date', $output);
}
/**
* @test
*/
public function ifEnabledOnlyIsProvidedOnlyThoseKeysAreListed()
public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided()
{
$this->apiKeyService->listKeys(true)->willReturn([
(new ApiKey())->disable(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:list',
'command' => ListKeysCommand::NAME,
'--enabledOnly' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Key', $output);
$this->assertNotContains('Is enabled', $output);
$this->assertNotContains(' +++ ', $output);
$this->assertNotContains(' --- ', $output);
$this->assertContains('Expiration date', $output);
}
}

View File

@@ -7,18 +7,18 @@ use PHPUnit\Framework\TestCase;
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
{
/**
* @var CommandTester
*/
protected $commandTester;
/** @var CommandTester */
private $commandTester;
public function setUp()
{
$command = new GenerateCharsetCommand(Translator::factory([]));
$command = new GenerateCharsetCommand();
$app = new Application();
$app->add($command);

View File

@@ -11,24 +11,21 @@ use Shlinkio\Shlink\Core\Exception;
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
{
/**
* @var CommandTester
*/
/** @var CommandTester */
private $commandTester;
/**
* @var ObjectProphecy
*/
/** @var ObjectProphecy */
private $service;
public function setUp()
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$command = new DeleteShortUrlCommand($this->service->reveal(), Translator::factory([]));
$command = new DeleteShortUrlCommand($this->service->reveal());
$app = new Application();
$app->add($command);
@@ -47,8 +44,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 +61,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 +73,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 +85,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 +107,11 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
$this->assertContains(sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
}

View File

@@ -13,23 +13,18 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Symfony\Component\Console\Application;
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
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $previewGenerator;
/**
* @var ObjectProphecy
*/
/** @var ObjectProphecy */
private $shortUrlService;
public function setUp()
@@ -37,11 +32,7 @@ class GeneratePreviewCommandTest extends TestCase
$this->previewGenerator = $this->prophesize(PreviewGenerator::class);
$this->shortUrlService = $this->prophesize(ShortUrlService::class);
$command = new GeneratePreviewCommand(
$this->shortUrlService->reveal(),
$this->previewGenerator->reveal(),
Translator::factory([])
);
$command = new GeneratePreviewCommand($this->shortUrlService->reveal(), $this->previewGenerator->reveal());
$app = new Application();
$app->add($command);
@@ -54,19 +45,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 +75,12 @@ class GeneratePreviewCommandTest extends TestCase
public function exceptionWillOutputError()
{
$items = [
(new ShortUrl())->setOriginalUrl('http://foo.com'),
(new ShortUrl())->setOriginalUrl('https://bar.com'),
(new ShortUrl())->setOriginalUrl('http://baz.com/something'),
new ShortUrl('http://foo.com'),
new ShortUrl('https://bar.com'),
new ShortUrl('http://baz.com/something'),
];
$paginator = $this->createPaginator($items);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledOnce();
$this->previewGenerator->generatePreview(Argument::any())->willThrow(PreviewGenerationException::class)
->shouldBeCalledTimes(count($items));

View File

@@ -3,32 +3,29 @@ 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;
use Shlinkio\Shlink\Core\Service\UrlShortener;
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
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $urlShortener;
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $urlShortener;
public function setUp()
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), Translator::factory([]), [
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), [
'schema' => 'http',
'hostname' => 'foo.com',
]);
@@ -42,19 +39,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 +60,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 +72,29 @@ class GenerateShortcodeCommandTest extends TestCase
$output
);
}
/**
* @test
*/
public function properlyProcessesProvidedTags()
{
$urlToShortCode = $this->urlShortener->urlToShortCode(
Argument::type(UriInterface::class),
Argument::that(function (array $tags) {
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
return $tags;
}),
Argument::cetera()
)->willReturn((new ShortUrl(''))->setShortCode('abc123'));
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--tags' => ['foo,bar', 'baz', 'boo,zar'],
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('http://foo.com/abc123', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
}

View File

@@ -9,28 +9,28 @@ 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\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
use Zend\Paginator\Paginator;
class GetVisitsCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $visitsTracker;
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $visitsTracker;
public function setUp()
{
$this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
$command = new GetVisitsCommand($this->visitsTracker->reveal(), Translator::factory([]));
$command = new GetVisitsCommand($this->visitsTracker->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
@@ -42,8 +42,9 @@ class GetVisitsCommandTest extends TestCase
public function noDateFlagsTriesToListWithoutDateRange()
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, new DateRange(null, null))->willReturn([])
->shouldBeCalledTimes(1);
$this->visitsTracker->info($shortCode, new VisitsParams(new DateRange(null, null)))->willReturn(
new Paginator(new ArrayAdapter([]))
)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
@@ -59,9 +60,12 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$startDate = '2016-01-01';
$endDate = '2016-02-01';
$this->visitsTracker->info($shortCode, new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)))
->willReturn([])
->shouldBeCalledTimes(1);
$this->visitsTracker->info(
$shortCode,
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)))
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
@@ -77,19 +81,21 @@ class GetVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated()
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::any())->willReturn([
(new Visit())->setReferer('foo')
->setVisitLocation((new VisitLocation())->setCountryName('Spain'))
->setUserAgent('bar'),
])->shouldBeCalledTimes(1);
$this->visitsTracker->info($shortCode, Argument::any())->willReturn(
new Paginator(new ArrayAdapter([
(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->assertContains('foo', $output);
$this->assertContains('Spain', $output);
$this->assertContains('bar', $output);
}
}

View File

@@ -11,26 +11,21 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
use Zend\Paginator\Paginator;
class ListShortcodesCommandTest extends TestCase
class ListShortUrlsCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $shortUrlService;
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $shortUrlService;
public function setUp()
{
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$app = new Application();
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), Translator::factory([]), []);
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), []);
$app->add($command);
$this->commandTester = new CommandTester($command);
}
@@ -41,7 +36,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 +47,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 +59,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 +74,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 +100,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 +115,7 @@ class ListShortcodesCommandTest extends TestCase
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([

View File

@@ -12,23 +12,19 @@ use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
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
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $urlShortener;
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $urlShortener;
public function setUp()
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new ResolveUrlCommand($this->urlShortener->reveal(), Translator::factory([]));
$command = new ResolveUrlCommand($this->urlShortener->reveal());
$app = new Application();
$app->add($command);
@@ -42,9 +38,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 +57,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 +74,7 @@ class ResolveUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',

View File

@@ -11,24 +11,19 @@ use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class CreateTagCommandTest extends TestCase
{
/**
* @var CommandTester
*/
/** @var CommandTester */
private $commandTester;
/**
* @var ObjectProphecy
*/
/** @var ObjectProphecy */
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$command = new CreateTagCommand($this->tagService->reveal(), Translator::factory([]));
$command = new CreateTagCommand($this->tagService->reveal());
$app = new Application();
$app->add($command);

View File

@@ -10,28 +10,21 @@ use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class DeleteTagsCommandTest extends TestCase
{
/**
* @var DeleteTagsCommand
*/
/** @var DeleteTagsCommand */
private $command;
/**
* @var CommandTester
*/
/** @var CommandTester */
private $commandTester;
/**
* @var ObjectProphecy
*/
/** @var ObjectProphecy */
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$command = new DeleteTagsCommand($this->tagService->reveal(), Translator::factory([]));
$command = new DeleteTagsCommand($this->tagService->reveal());
$app = new Application();
$app->add($command);

View File

@@ -11,28 +11,21 @@ use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class ListTagsCommandTest extends TestCase
{
/**
* @var ListTagsCommand
*/
/** @var ListTagsCommand */
private $command;
/**
* @var CommandTester
*/
/** @var CommandTester */
private $commandTester;
/**
* @var ObjectProphecy
*/
/** @var ObjectProphecy */
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$command = new ListTagsCommand($this->tagService->reveal(), Translator::factory([]));
$command = new ListTagsCommand($this->tagService->reveal());
$app = new Application();
$app->add($command);
@@ -61,8 +54,8 @@ class ListTagsCommandTest extends TestCase
{
/** @var MethodProphecy $listTags */
$listTags = $this->tagService->listTags()->willReturn([
(new Tag())->setName('foo'),
(new Tag())->setName('bar'),
new Tag('foo'),
new Tag('bar'),
]);
$this->commandTester->execute([]);

View File

@@ -12,28 +12,21 @@ use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class RenameTagCommandTest extends TestCase
{
/**
* @var RenameTagCommand
*/
/** @var RenameTagCommand */
private $command;
/**
* @var CommandTester
*/
/** @var CommandTester */
private $commandTester;
/**
* @var ObjectProphecy
*/
/** @var ObjectProphecy */
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$command = new RenameTagCommand($this->tagService->reveal(), Translator::factory([]));
$command = new RenameTagCommand($this->tagService->reveal());
$app = new Application();
$app->add($command);
@@ -68,7 +61,7 @@ class RenameTagCommandTest extends TestCase
$oldName = 'foo';
$newName = 'bar';
/** @var MethodProphecy $renameTag */
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag());
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag($newName));
$this->commandTester->execute([
'oldName' => $oldName,

View File

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

View File

@@ -0,0 +1,61 @@
<?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;
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());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function successMessageIsPrintedIfEverythingWorks()
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->will(function () {
});
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('GeoLite2 database properly updated', $output);
$download->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function errorMessageIsPrintedIfAnExceptionIsThrown()
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->willThrow(RuntimeException::class);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('An error occurred while updating GeoLite2 database', $output);
$download->shouldHaveBeenCalledOnce();
}
}

View File

@@ -8,10 +8,8 @@ use Shlinkio\Shlink\CLI\ConfigProvider;
class ConfigProviderTest extends TestCase
{
/**
* @var ConfigProvider
*/
protected $configProvider;
/** @var ConfigProvider */
private $configProvider;
public function setUp()
{
@@ -23,10 +21,9 @@ class ConfigProviderTest extends TestCase
*/
public function confiIsProperlyReturned()
{
$config = $this->configProvider->__invoke();
$config = ($this->configProvider)();
$this->assertArrayHasKey('cli', $config);
$this->assertArrayHasKey('dependencies', $config);
$this->assertArrayHasKey('translator', $config);
}
}

View File

@@ -4,19 +4,19 @@ 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
{
/**
* @var ApplicationFactory
*/
protected $factory;
/** @var ApplicationFactory */
private $factory;
public function setUp()
{
@@ -28,7 +28,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,28 +39,43 @@ 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' => [
'cli' => array_merge($config, ['locale' => 'en']),
],
AppOptions::class => new AppOptions(),
Translator::class => Translator::factory([]),
]]);
}
private function createCommandMock(string $name): ObjectProphecy
{
$command = $this->prophesize(Command::class);
$command->getName()->willReturn($name);
$command->getDefinition()->willReturn($name);
$command->isEnabled()->willReturn(true);
$command->getAliases()->willReturn([]);
$command->setApplication(Argument::type(Application::class))->willReturn(function () {
});
return $command;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Exec;
use const PHP_SAPI;
use function Shlinkio\Shlink\Common\env;
abstract class ExecutionContext
{
public const WEB = 'shlink_web';
public const CLI = 'shlink_cli';
public static function currentContextIsSwoole(): bool
{
return PHP_SAPI === 'cli' && env('CURRENT_SHLINK_CONTEXT', self::WEB) === self::WEB;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,6 @@ class TranslatorFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config');
return Translator::factory(isset($config['translator']) ? $config['translator'] : []);
return Translator::factory($config['translator'] ?? []);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
<?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
}
}
}

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