Compare commits

...

277 Commits

Author SHA1 Message Date
Alejandro Celaya
f563e777cc Merge pull request #369 from acelaya/feature/postgres-query-error
Feature/postgres query error
2019-03-05 14:26:36 +01:00
Alejandro Celaya
a63447b12b Updated changelog 2019-03-05 14:17:47 +01:00
Alejandro Celaya
0f81c3ab92 Fixed error when using postgres in a SELECT count query where a ORDER BY was added by mistake 2019-03-05 13:50:44 +01:00
Alejandro Celaya
425f254453 Added posgres container for development 2019-03-05 13:39:45 +01:00
Alejandro Celaya
a9d9ec5bf9 Merge pull request #365 from acelaya/feature/coding-styles
Feature/coding styles
2019-02-26 23:06:08 +01:00
Alejandro Celaya
0c5c752ffe Updated changelog 2019-02-26 22:58:03 +01:00
Alejandro Celaya
4b556cd79f Updated to shlinkio coding standard 1.1.0 2019-02-26 22:56:43 +01:00
Alejandro Celaya
3d32a90f8e Merge pull request #364 from acelaya/bugfix/non-locatable-addresses
Bugfix/non locatable addresses
2019-02-26 22:53:07 +01:00
Alejandro Celaya
0b4c334163 Fixed typo 2019-02-26 22:42:33 +01:00
Alejandro Celaya
312fc0984b Fixed mutation score by provideing more tests 2019-02-26 22:41:04 +01:00
Alejandro Celaya
30bf1c2641 Added tests for new cases with non-locatable addresses 2019-02-26 22:31:07 +01:00
Alejandro Celaya
2d1d7357a3 Given more semantic cases in which a visit cannot be located 2019-02-26 21:39:45 +01:00
Alejandro Celaya
c70077c525 Merge pull request #361 from acelaya/feature/paginated-visits
Feature/paginated visits
2019-02-23 10:09:46 +01:00
Alejandro Celaya
d2fad0128f Fixed bug missing unprocessed visits while iterating and updating, while drastically improving the performance 2019-02-23 09:58:02 +01:00
Alejandro Celaya
62133c994f Tagged v1.16 in changelog 2019-02-23 08:30:35 +01:00
Alejandro Celaya
091ea974eb Simplified implementation iterating unlocated visits 2019-02-23 07:29:07 +01:00
Alejandro Celaya
955ae00036 Updated changelog 2019-02-22 19:54:23 +01:00
Alejandro Celaya
7d4de590e5 Created ImplicitLoopPaginatorTest 2019-02-22 19:53:10 +01:00
Alejandro Celaya
292937b962 Updated VisitRepository::findUnlocatedVisits methods so that it paginates the amount of elements loaded in memory 2019-02-22 19:31:03 +01:00
Alejandro Celaya
08bd4f131c Merge pull request #359 from acelaya/feature/memory-leak
Feature/memory leak
2019-02-20 18:09:00 +01:00
Alejandro Celaya
38cc83a4ee Removed uneeded inline type hints 2019-02-17 20:32:18 +01:00
Alejandro Celaya
687a1cc9c7 Reduced amount of dead lines in tests 2019-02-17 20:28:34 +01:00
Alejandro Celaya
1bcd03b150 Renamed method 2019-02-17 13:21:07 +01:00
Alejandro Celaya
e2abe23895 Defined stricter model to represent one geo location 2019-02-17 13:01:21 +01:00
Alejandro Celaya
5c5dde48de Ensured install and update script change to the project dir 2019-02-17 10:51:22 +01:00
Alejandro Celaya
d9f11e190f Merge pull request #357 from acelaya/feature/phpstan0.11
Feature/phpstan0.11
2019-02-17 10:19:14 +01:00
Alejandro Celaya
1ab2d7a240 Increased scrutinizer timeout while waiting for code coverage, from 5 min to 10 min 2019-02-17 10:12:13 +01:00
Alejandro Celaya
580050cb7d Updated to phpstan 0.11 2019-02-17 10:06:34 +01:00
Alejandro Celaya
eab5659163 Added status codes returned by CLI commands 2019-02-16 23:21:40 +01:00
Alejandro Celaya
397b350cfc Merge pull request #356 from acelaya/feature/deprecated-commands
Deprecated commands to generate secret and charset
2019-02-16 23:20:55 +01:00
Alejandro Celaya
c0130c997a Deprecated commands to generate secret and charset 2019-02-16 22:53:49 +01:00
Alejandro Celaya
fd7f1b32dd Merge pull request #354 from acelaya/feature/infection
Feature/infection
2019-02-16 22:25:13 +01:00
Alejandro Celaya
0e286d8261 Temporarely downgrading phpstan 2019-02-16 22:17:01 +01:00
Alejandro Celaya
ce7d2d1fb0 Fixed coding styles 2019-02-16 22:04:11 +01:00
Alejandro Celaya
2175b8a7bb Improved tests to increase MSI to 70% 2019-02-16 21:58:14 +01:00
Alejandro Celaya
6c0893cdf8 Improved tests to increase MSI to 69% 2019-02-16 21:24:32 +01:00
Alejandro Celaya
25927a296d Merge pull request #353 from acelaya/feature/testing-tools
Updated testing tools
2019-02-16 20:50:22 +01:00
Alejandro Celaya
ee4db44fe8 Fixed phpcov dep not properly resolved on PHP 7.1 envs 2019-02-16 20:39:46 +01:00
Alejandro Celaya
b8cb38ae5c Updated testing tools 2019-02-16 10:53:45 +01:00
Alejandro Celaya
899bfdce2b Merge pull request #352 from acelaya/feature/configure-installer
Updated required shlink-installer version and added config for instal…
2019-02-10 22:05:03 +01:00
Alejandro Celaya
456960e1f0 Updated required shlink-installer version and added config for installer plugins 2019-02-10 21:57:29 +01:00
Alejandro Celaya
04e03e9b6e Merge pull request #348 from acelaya/feature/external-installer
Feature/external installer
2019-02-06 23:56:10 +01:00
Alejandro Celaya
a7283da016 Updated changelog 2019-02-06 23:31:37 +01:00
Alejandro Celaya
672321abab Removed class which is now part of the installer package 2019-02-04 20:16:29 +01:00
Alejandro Celaya
2059b4050b Removed installer and used external package instead 2019-02-04 20:14:59 +01:00
Alejandro Celaya
171b43c517 Fixed some configs 2019-02-04 19:43:21 +01:00
Alejandro Celaya
ccb7c8f8d9 Merge pull request #345 from acelaya/bugfix/charset-installation
Bugfix/charset installation
2019-02-03 13:19:23 +01:00
Alejandro Celaya
abbc66ac07 Unified config for installer tool 2019-02-03 13:12:17 +01:00
Alejandro Celaya
2d18ef5cee Updated installer so that it no longer asks for a charset and instead just generates one 2019-02-03 13:02:12 +01:00
Alejandro Celaya
79c132219b Merge pull request #343 from acelaya/feature/allow-check-duplicates
Feature/allow check duplicates
2019-02-03 12:22:22 +01:00
Alejandro Celaya
04d4d4a8d7 Updated GenerateShortUrlCommand to accept the findIfExists flag 2019-02-03 12:11:22 +01:00
Alejandro Celaya
a918113ba0 Documented new findIfExists flag 2019-02-03 11:24:26 +01:00
Alejandro Celaya
810b25ff14 Added API tests covering creating short URLs with new findIfExists param 2019-02-03 11:01:38 +01:00
Alejandro Celaya
c4fd8d5120 Implemented feature to optionally return an existing short url when all provided params match an existing one 2019-02-03 09:40:32 +01:00
Alejandro Celaya
772494f46f Moved process of sluggifying custom slug to a filter 2019-02-03 08:17:27 +01:00
Alejandro Celaya
594e7da256 Created new findIfExists meta param 2019-02-02 11:05:28 +01:00
Alejandro Celaya
49668547d7 Fixed typo 2019-02-02 11:05:28 +01:00
Alejandro Celaya
4c46aaead8 Improved API tests and added test for short URLs creation 2019-02-02 11:05:28 +01:00
Alejandro Celaya
d61f5faf59 Refactored UrlShortener public method to receibe DTOs instead of primitive params 2019-02-02 11:05:28 +01:00
Alejandro Celaya
5756609531 Deleted deprecated constant 2019-02-02 11:05:28 +01:00
Alejandro Celaya
ea1b285d52 Little refactopring on tests config file 2019-02-02 11:05:28 +01:00
Alejandro Celaya
bc61b55b94 Merge pull request #344 from acelaya/feature/update-dev-docker
Updated docker containers used in development
2019-02-02 11:04:39 +01:00
Alejandro Celaya
48f6a96da8 Updated docker containers used in development 2019-02-02 10:53:34 +01:00
Alejandro Celaya
967f1657d2 Merge pull request #340 from acelaya/bugfix/preview-error
Bugfix/preview error
2019-01-28 11:00:23 +01:00
Alejandro Celaya
f90a323374 Updated changelog 2019-01-28 10:53:24 +01:00
Alejandro Celaya
d289c62532 Fixed config file being deleted by mistake by build script 2019-01-28 10:52:05 +01:00
Alejandro Celaya
05695e8cd6 Merge pull request #339 from acelaya/feature/api-test
Feature/api test
2019-01-27 12:49:59 +01:00
Alejandro Celaya
d6a7a6ce66 Created new API test 2019-01-27 12:36:22 +01:00
Alejandro Celaya
05c7672de3 Improved API tests by adding fixtures 2019-01-27 12:14:22 +01:00
Alejandro Celaya
ce515767ce Updated changelog 2019-01-27 10:56:14 +01:00
Alejandro Celaya
76d8fd1023 Improved how API tests are executed 2019-01-27 10:54:04 +01:00
Alejandro Celaya
558e259b84 Minor refactorings 2019-01-27 10:30:38 +01:00
Alejandro Celaya
f467bed24c Used multiple commands with && instead of composer array for API tests command 2019-01-27 10:15:48 +01:00
Alejandro Celaya
fa753ad6fb Added api test to test:ci command 2019-01-26 11:04:50 +01:00
Alejandro Celaya
22d61fead7 Prepared configs for API tests 2019-01-26 10:19:20 +01:00
Alejandro Celaya
c4af1471f0 Simplified and united configs for tests 2019-01-26 09:42:08 +01:00
Alejandro Celaya
87ba7a7179 Updated structure for tests config files 2019-01-26 09:09:57 +01:00
Alejandro Celaya
e7c5cf0846 Merge pull request #337 from acelaya/feature/db-tests
Feature/db tests
2019-01-21 21:34:30 +01:00
Alejandro Celaya
1aaedb8d90 Udated changelog 2019-01-21 21:27:16 +01:00
Alejandro Celaya
284de28f76 Removed duplicated code to define testing database connection params 2019-01-20 22:08:32 +01:00
Alejandro Celaya
687d8d91a9 Changed references to functional tests by database tests 2019-01-20 21:49:07 +01:00
Alejandro Celaya
771087c6c6 Happy 2019! 2019-01-05 08:41:48 +01:00
Alejandro Celaya
1fd3e6365e Merge pull request #331 from acelaya/feature/health
Feature/health
2018-12-29 14:51:08 +01:00
Alejandro Celaya
28989296eb Updated changelog 2018-12-29 14:45:20 +01:00
Alejandro Celaya
fd8d73af38 Documented health endpoint 2018-12-29 14:39:31 +01:00
Alejandro Celaya
144a5415da Handled connection exceptions in Health action 2018-12-29 13:50:42 +01:00
Alejandro Celaya
d58e24bce5 Created health action related tests 2018-12-29 11:54:28 +01:00
Alejandro Celaya
0f86123ccb Finished health action implementation 2018-12-29 11:54:28 +01:00
Alejandro Celaya
3f65ef998c Created HealthAction 2018-12-29 11:54:28 +01:00
Alejandro Celaya
29d49dfbf4 Merge pull request #332 from acelaya/feature/php7.3
Do not allow failures on PHP 7.3 build
2018-12-29 11:53:44 +01:00
Alejandro Celaya
701d17f6f2 Do not allow failures on PHP 7.3 build 2018-12-29 11:43:28 +01:00
Alejandro Celaya
642431c43e Reverted to diactoros v2.0.1 while a bug is fixed 2018-12-29 11:13:23 +01:00
Alejandro Celaya
3c5b47784d Merge pull request #329 from PeterDaveHello/Update-Travis-CI-Config
Drop deprecated Travis CI container-based env config
2018-12-29 09:36:02 +01:00
Peter Dave Hello
64d7fe8bbf Drop deprecated Travis CI container-based env config
Ref: https://blog.travis-ci.com/2018-10-04-combining-linux-infrastructures
2018-12-29 16:20:18 +08:00
Alejandro Celaya
32070b1fa7 Do not use ServerRequestFactory::fromGlobals in tests 2018-12-25 23:19:36 +01:00
Alejandro Celaya
8b3324e143 Merge pull request #327 from PeterDaveHello/Add-Table-of-Contents
Add Table of Contents in README.md
2018-12-25 10:52:21 +01:00
Peter Dave Hello
f40a5a029c Add Table of Contents in README.md 2018-12-25 14:12:16 +08:00
Alejandro Celaya
eac82a602c Merge pull request #325 from acelaya/feature/dql
Feature/dql
2018-12-19 17:47:10 +01:00
Alejandro Celaya
d1312e0934 Added unreleased changes to changelog 2018-12-19 14:37:47 +01:00
Alejandro Celaya
58dbee10c5 Used DQL for non-dynamic query in VisitRepository 2018-12-19 14:36:03 +01:00
Alejandro Celaya
f8207994dc Removed superfluous method docs 2018-12-19 14:31:52 +01:00
Alejandro Celaya
2030401859 Migrated non-dynamic query to DQL in ShortUrlRepository 2018-12-19 14:29:43 +01:00
Alejandro Celaya
8966cf9910 Merge pull request #323 from acelaya/feature/docker-build
Feature/docker build
2018-12-16 19:40:32 +01:00
Alejandro Celaya
4eb4df9ca2 Updated changelog 2018-12-16 13:19:44 +01:00
Alejandro Celaya
32861b1c72 Added new travis deployment which will build the docker image 2018-12-16 13:19:17 +01:00
Alejandro Celaya
7248ca2e9b Merge pull request #324 from acelaya/feature/entities-config
Moved entities mappings from annotations to external config files
2018-12-16 13:18:19 +01:00
Alejandro Celaya
a6ec93f883 Updated changelog 2018-12-16 12:14:13 +01:00
Alejandro Celaya
a28c1d17c5 Moved entities mappings from annotations to external config files 2018-12-16 12:08:03 +01:00
Alejandro Celaya
fb705b44a4 Merge pull request #318 from acelaya/feature/document-non-rest
Feature/document non rest
2018-12-09 16:35:59 +01:00
Alejandro Celaya
a32bab9fd0 Updated changelog 2018-12-09 16:25:24 +01:00
Alejandro Celaya
6396e7f964 Added other of non-rest endpoints 2018-12-09 15:43:56 +01:00
Alejandro Celaya
c898cef277 Documented first non-rest endpoint 2018-12-09 15:18:10 +01:00
Alejandro Celaya
baeba54b06 Merge branch 'master' of github.com:shlinkio/shlink 2018-12-09 14:22:40 +01:00
Alejandro Celaya
f5ee5bf7fb Documented that swoole server needs to be restarted when it is being used ot serve shlink 2018-12-09 14:22:21 +01:00
Alejandro Celaya
73605414f9 Merge pull request #316 from acelaya/feature/symfony-42
Feature/symfony 42
2018-12-08 14:30:20 +01:00
Alejandro Celaya
6045c371e1 Updated changelog 2018-12-08 14:12:50 +01:00
Alejandro Celaya
97a9289d5f Created ShlinkTableTest 2018-12-08 14:11:14 +01:00
Alejandro Celaya
1983fc9b67 Added current page message in list short urls CLI command 2018-12-08 12:16:39 +01:00
Alejandro Celaya
bb40d84212 Used ShlinkTable on every location rendering a CLI table 2018-12-08 12:12:11 +01:00
Alejandro Celaya
46a35c553e Created class to wrap CLI table rendering behavior 2018-12-08 11:32:16 +01:00
Alejandro Celaya
080943e810 Updated how Symfony commands are used to fulfill API from v4.2 2018-12-08 10:34:04 +01:00
Alejandro Celaya
62fb3863c6 Merge pull request #315 from acelaya/feature/config
Updated how config is imported and merged, so that it includes any co…
2018-12-07 20:56:57 +01:00
Alejandro Celaya
2db03a163d Updated how config is imported and merged, so that it includes any config file in json format from config/params dir 2018-12-07 20:48:20 +01:00
Alejandro Celaya
9e3dd82efe Merge pull request #314 from acelaya/feature/fix-context
Feature/fix context
2018-12-07 20:30:53 +01:00
Alejandro Celaya
9f1989bfef Updated changelog 2018-12-07 19:49:17 +01:00
Alejandro Celaya
c0bdd8fc77 Removed concept of execution context and piped CloseDbConnectionMiddleware always 2018-12-07 19:46:46 +01:00
Alejandro Celaya
8a23c90e46 Merge pull request #313 from acelaya/feature/favicon
Feature/favicon
2018-12-07 09:45:55 +01:00
Alejandro Celaya
9095e5b057 Enabled static files with swoole, otherwise, robots.txt and favicon.ico are never served when running shlink with swoole 2018-12-07 09:38:07 +01:00
Alejandro Celaya
52c18115af Updated changelog 2018-12-07 09:18:56 +01:00
Alejandro Celaya
737137b19f Added favicon 2018-12-07 09:17:31 +01:00
Alejandro Celaya
7b78bee135 Merge pull request #311 from acelaya/feature/improvements
Feature/improvements
2018-12-07 02:55:27 +01:00
Alejandro Celaya
accda36a7b Updated default secret_key value 2018-12-07 02:49:50 +01:00
Alejandro Celaya
69dd9eb067 Updated readme mentioning docker image 2018-12-07 02:41:06 +01:00
Alejandro Celaya
a562bc661d Improved CacheFactory class 2018-12-06 21:05:11 +01:00
Alejandro Celaya
258f12f684 Merge pull request #303 from acelaya/feature/expressive-swoole-2.2
Feature/expressive swoole 2.2
2018-12-05 21:46:48 +01:00
Alejandro Celaya
4dc8d77a5a Updated changelog 2018-12-05 21:29:16 +01:00
Alejandro Celaya
7c5825d1bc Removed custom AccessLogFactory by updating to zend-expressive-swoole 2.2 2018-12-05 21:26:19 +01:00
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
390 changed files with 8124 additions and 7213 deletions

10
.gitattributes vendored
View File

@@ -1,12 +1,14 @@
/config/test export-ignore
/data/infra export-ignore
/docs export-ignore
/module/CLI/test export-ignore
/module/CLI/test-resources export-ignore
/module/Common/test export-ignore
/module/Common/test-func export-ignore
/module/Common/test-db export-ignore
/module/Core/test export-ignore
/module/Core/test-func export-ignore
/module/Core/test-db export-ignore
/module/Rest/test export-ignore
/module/Rest/test-api export-ignore
.env.dist export-ignore
.gitattributes export-ignore
.gitignore export-ignore
@@ -17,9 +19,9 @@ build.sh export-ignore
CHANGELOG.md export-ignore
docker-compose.override.yml.dist export-ignore
docker-compose.yml export-ignore
func_tests_bootstrap.php export-ignore
indocker export-ignore
phpcs.xml export-ignore
phpunit.xml.dist export-ignore
phpunit-func.xml export-ignore
phpunit-api.xml export-ignore
phpunit-db.xml export-ignore
phpstan.neon

5
.gitignore vendored
View File

@@ -5,5 +5,8 @@ composer.phar
vendor/
.env
data/database.sqlite
docs/swagger-ui
data/shlink-tests.db
data/GeoLite2-City.mmdb
docs/swagger-ui*
docker-compose.override.yml
.phpunit.result.cache

View File

@@ -2,7 +2,7 @@
namespace PHPSTORM_META;
use Psr\Container\ContainerInterface;
use Zend\ServiceManager\ServiceManager;
use Zend\ServiceManager\ServiceLocatorInterface;
/**
* PhpStorm Container Interop code completion
@@ -17,7 +17,7 @@ $STATIC_METHOD_TYPES = [
ContainerInterface::get('') => [
'' == '@',
],
ServiceManager::build('') => [
ServiceLocatorInterface::build('') => [
'' == '@',
],
];

View File

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

View File

@@ -1,7 +1,5 @@
language: php
sudo: false # Use containerized environment
branches:
only:
- /.*/
@@ -11,13 +9,11 @@ php:
- 7.2
- 7.3
matrix:
allow_failures:
- php: 7.3
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
@@ -25,10 +21,11 @@ install:
script:
- mkdir build
- composer check
- composer ci
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
@@ -38,11 +35,17 @@ before_deploy:
- ./build.sh ${TRAVIS_TAG#?}
deploy:
provider: releases
api_key:
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true
php: 7.1
- provider: releases
api_key:
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true
php: 7.1
- provider: script
script: bash data/travis/trigger_docker_build.sh
skip_cleanup: true
on:
tags: true
php: 7.1

View File

@@ -2,7 +2,237 @@
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).
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## 1.16.2 - 2019-03-05
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#368](https://github.com/shlinkio/shlink/issues/368) Fixed error produced when running a `SELECT COUNT(...)` with `ORDER BY` in PostgreSQL databases.
## 1.16.1 - 2019-02-26
#### Added
* *Nothing*
#### Changed
* [#363](https://github.com/shlinkio/shlink/issues/363) Updated to `shlinkio/php-coding-standard` version 1.1.0
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#362](https://github.com/shlinkio/shlink/issues/362) Fixed all visits without an IP address being processed every time the `visit:process` command is executed.
## 1.16.0 - 2019-02-23
#### Added
* [#304](https://github.com/shlinkio/shlink/issues/304) Added health endpoint to check healthiness of the service. Useful in container-based infrastructures.
Call [GET /rest/health] in order to get a response like this:
```http
HTTP/1.1 200 OK
Content-Type: application/health+json
Content-Length: 681
{
"status": "pass",
"version": "1.16.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
}
}
```
The status code can be `200 OK` in case of success or `503 Service Unavailable` in case of error, while the `status` property will be one of `pass` or `fail`, as defined in the [Health check RFC](https://inadarei.github.io/rfc-healthcheck/).
* [#279](https://github.com/shlinkio/shlink/issues/279) Added new `findIfExists` flag to the `[POST /short-url]` REST endpoint and the `short-urls:generate` CLI command. It can be used to return existing short URLs when found, instead of creating new ones.
Thanks to this flag you won't need to remember if you created a short URL for a long one. It will just create it if needed or return the existing one if possible.
The behavior might be a little bit counterintuitive when combined with other params. This is how the endpoint behaves when providing this new flag:
* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.
* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.
* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.
* [#336](https://github.com/shlinkio/shlink/issues/336) Added an API test suite which performs API calls to an actual instance of the web service.
#### Changed
* [#342](https://github.com/shlinkio/shlink/issues/342) The installer no longer asks for a charset to be provided, and instead, it shuffles the base62 charset.
* [#320](https://github.com/shlinkio/shlink/issues/320) Replaced query builder by plain DQL for all queries which do not need to be dynamically generated.
* [#330](https://github.com/shlinkio/shlink/issues/330) No longer allow failures on PHP 7.3 envs during project CI build.
* [#335](https://github.com/shlinkio/shlink/issues/335) Renamed functional test suite to database test suite, since that better describes what it actually does.
* [#346](https://github.com/shlinkio/shlink/issues/346) Extracted installer as an independent tool.
* [#261](https://github.com/shlinkio/shlink/issues/261) Increased mutation score to 70%.
#### Deprecated
* [#351](https://github.com/shlinkio/shlink/issues/351) Deprecated `config:generate-charset` and `config:generate-secret` CLI commands.
#### Removed
* *Nothing*
#### Fixed
* [#317](https://github.com/shlinkio/shlink/issues/317) Fixed error while trying to generate previews because of global config file being deleted by mistake by build script.
* [#307](https://github.com/shlinkio/shlink/issues/307) Fixed memory leak while trying to process huge amounts of visits due to the query not being properly paginated.
## 1.15.1 - 2018-12-16
#### Added
* [#162](https://github.com/shlinkio/shlink/issues/162) Added non-rest endpoints to swagger definition.
#### Changed
* [#312](https://github.com/shlinkio/shlink/issues/312) Now all config files both in `php` and `json` format are loaded from `config/params` folder, easing users to provided customizations to docker image.
* [#226](https://github.com/shlinkio/shlink/issues/226) Updated how table are rendered in CLI commands, making use of new features in Symfony 4.2.
* [#321](https://github.com/shlinkio/shlink/issues/321) Extracted entities mappings from entities to external config files.
* [#308](https://github.com/shlinkio/shlink/issues/308) Automated docker image building.
#### Deprecated
* *Nothing*
#### Removed
* [#301](https://github.com/shlinkio/shlink/issues/301) Removed custom `AccessLogFactory` in favor of the implementation included in [zendframework/zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) v2.2.0
#### Fixed
* [#309](https://github.com/shlinkio/shlink/issues/309) Added missing favicon to prevent 404 errors logged when an error page is loaded in a browser.
* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddlware` to be always piped. Now the check is not even made, which simplifies everything.
## 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

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2018 Alejandro Celaya
Copyright (c) 2016-2019 Alejandro Celaya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

190
README.md
View File

@@ -9,6 +9,14 @@
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
## Table of Contents
- [Installation](#installation)
- [Update to new version](#update-to-new-version)
- [Using a docker image](#using-a-docker-image)
- [Using shlink](#using-shlink)
- [Shlink CLI Help](#shlink-cli-help)
## Installation
First make sure the host where you are going to run shlink fulfills these requirements:
@@ -44,52 +52,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,19 +192,30 @@ 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:
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`)
2. Download and extract the new version of Shlink, and set the directories name to that of the old version. (ie. `shlink`)
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`).
2. Download and extract the new version of Shlink, and set the directories name to that of the old version. (ie. `shlink`).
3. Run the `bin/update` script in the new version's directory to migrate your configuration over.
4. If you are using shlink with swoole, restart the service by running `/etc/init.d/shlink_swoole restart`.
The script will ask you for the location from previous shlink version, and use it in order to import the configuration. It will then update the database and generate some the assets neccessary for Shlink to function.
The `bin/update` script will ask you for the location from previous shlink version, and use it in order to import the configuration. It will then update the database and generate some assets shlink needs to work.
Right now, it does not import cached info (like website previews), but it will. For now you will need to regenerate them again.
@@ -124,17 +223,15 @@ Right now, it does not import cached info (like website previews), but it will.
## Using a docker image
Currently there's no official docker image, but there's a work in progress alpha version you can find [here](https://hub.docker.com/r/shlinkio/shlink/).
Starting with version 1.15.0, an official docker image is provided. You can find the docs on how to use it [here](https://hub.docker.com/r/shlinkio/shlink/).
The idea will be that you can just generate a container using the image and provide predefined config files via volumes or CLI arguments, so that you get shlink up and running.
Currently the image does not expose an entry point which let's you interact with shlink's CLI interface, nor allows configuration to be passed.
The idea is that you can just generate a container using the image and provide custom config via env vars.
## Using shlink
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.
@@ -169,8 +266,8 @@ Available commands:
api-key:generate Generates a new valid API key.
api-key:list Lists all the available API keys.
config
config:generate-charset Generates a character set sample just by shuffling the default one, "123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ". Then it can be set in the SHORTCODE_CHARS environment variable
config:generate-secret Generates a random secret string that can be used for JWT token encryption
config:generate-charset [DEPRECATED] Generates a character set sample just by shuffling the default one, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ". Then it can be set in the SHORTCODE_CHARS environment variable
config:generate-secret [DEPRECATED] Generates a random secret string that can be used for JWT token encryption
short-url
short-url:delete [short-code:delete] Deletes a short URL
short-url:generate [shortcode:generate|short-code:generate] Generates a short URL for provided long URL and returns it
@@ -185,4 +282,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

@@ -2,11 +2,11 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
namespace Shlinkio\Shlink;
use Symfony\Component\Console\Application;
use Zend\ServiceManager\ServiceLocatorInterface;
use function chdir;
use function dirname;
/** @var ServiceLocatorInterface $container */
$container = include __DIR__ . '/../config/install-container.php';
$container->build(Application::class)->run();
chdir(dirname(__DIR__));
$run = require __DIR__ . '/../vendor/shlinkio/shlink-installer/bin/run.php';
$run(false);

14
bin/test/run-api-tests.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env sh
set -e
export APP_ENV=test
# Try to stop server just in case it hanged in last execution
vendor/bin/zend-expressive-swoole stop
echo 'Starting server...'
vendor/bin/zend-expressive-swoole start -d
sleep 2
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox
vendor/bin/zend-expressive-swoole stop

View File

@@ -2,11 +2,11 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
namespace Shlinkio\Shlink;
use Symfony\Component\Console\Application;
use Zend\ServiceManager\ServiceLocatorInterface;
use function chdir;
use function dirname;
/** @var ServiceLocatorInterface $container */
$container = include __DIR__ . '/../config/install-container.php';
$container->build(Application::class, ['isUpdate' => true])->run();
chdir(dirname(__DIR__));
$run = require __DIR__ . '/../vendor/shlinkio/shlink-installer/bin/run.php';
$run(true);

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -e
if [ "$#" -ne 1 ]; then
if [[ "$#" -ne 1 ]]; then
echo "Usage:" >&2
echo " $0 {version}" >&2
exit 1
@@ -10,14 +10,18 @@ fi
version=$1
builtcontent="./build/shlink_${version}_dist"
projectdir=$(pwd)
[ -f ./composer.phar ] && composerBin='./composer.phar' || composerBin='composer'
[[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer'
# Copy project content to temp dir
echo 'Copying project files...'
rm -rf "${builtcontent}"
mkdir -p "${builtcontent}"
rsync -av * "${builtcontent}" \
--exclude=bin/test \
--exclude=data/infra \
--exclude=data/travis \
--exclude=data/migrations_template.txt \
--exclude=data/GeoLite2-City.mmdb \
--exclude=**/.gitignore \
--exclude=CHANGELOG.md \
--exclude=composer.lock \
@@ -25,11 +29,11 @@ rsync -av * "${builtcontent}" \
--exclude=docs \
--exclude=indocker \
--exclude=docker* \
--exclude=func_tests_bootstrap.php \
--exclude=php* \
--exclude=infection.json \
--exclude=phpstan.neon \
--exclude=config/autoload/*local* \
--exclude=config/test \
--exclude=**/test* \
--exclude=build*
cd "${builtcontent}"
@@ -37,7 +41,7 @@ cd "${builtcontent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
${composerBin} self-update
${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction
${composerBin} install --no-dev --optimize-autoloader --apcu-autoloader --no-progress --no-interaction
# Delete development files
echo 'Deleting dev files...'

View File

@@ -24,21 +24,25 @@
"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",
"shlinkio/shlink-installer": "^1.1",
"symfony/console": "^4.2",
"symfony/filesystem": "^4.2",
"symfony/lock": "^4.2",
"symfony/process": "^4.2",
"theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0",
"zendframework/zend-diactoros": "^2.0",
"zendframework/zend-diactoros": "^2.1.1",
"zendframework/zend-expressive": "^3.0",
"zendframework/zend-expressive-fastroute": "^3.0",
"zendframework/zend-expressive-helpers": "^5.0",
"zendframework/zend-expressive-platesrenderer": "^2.0",
"zendframework/zend-expressive-swoole": "^2.2",
"zendframework/zend-i18n": "^2.7",
"zendframework/zend-inputfilter": "^2.8",
"zendframework/zend-paginator": "^2.6",
@@ -46,15 +50,17 @@
"zendframework/zend-stdlib": "^3.0"
},
"require-dev": {
"devster/ubench": "^2.0",
"doctrine/data-fixtures": "^1.3",
"filp/whoops": "^2.0",
"infection/infection": "^0.9.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",
"symfony/dotenv": "^4.0",
"symfony/var-dumper": "^4.0",
"infection/infection": "^0.12.2",
"phpstan/phpstan": "^0.11.2",
"phpunit/phpcov": "^6.0@dev || ^5.0",
"phpunit/phpunit": "^8.0 || ^7.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~1.1.0",
"symfony/dotenv": "^4.2",
"symfony/var-dumper": "^4.2",
"zendframework/zend-component-installer": "^2.1",
"zendframework/zend-expressive-tooling": "^1.0"
},
@@ -63,8 +69,7 @@
"Shlinkio\\Shlink\\CLI\\": "module/CLI/src",
"Shlinkio\\Shlink\\Rest\\": "module/Rest/src",
"Shlinkio\\Shlink\\Core\\": "module/Core/src",
"Shlinkio\\Shlink\\Common\\": "module/Common/src",
"Shlinkio\\Shlink\\Installer\\": "module/Installer/src"
"Shlinkio\\Shlink\\Common\\": "module/Common/src"
},
"files": [
"module/Common/functions/functions.php"
@@ -74,58 +79,76 @@
"psr-4": {
"ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test",
"ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test",
"ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api",
"ShlinkioTest\\Shlink\\Core\\": [
"module/Core/test",
"module/Core/test-func"
"module/Core/test-db"
],
"ShlinkioTest\\Shlink\\Common\\": [
"module/Common/test",
"module/Common/test-func"
],
"ShlinkioTest\\Shlink\\Installer\\": "module/Installer/test"
"module/Common/test-db"
]
}
},
"scripts": {
"check": [
"ci": [
"@cs",
"@stan",
"@test",
"@infect"
"@test:ci",
"@infect:ci"
],
"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:db",
"@test:api"
],
"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:db",
"@test:api"
],
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov --testdox",
"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 --testdox",
"test:db": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-db.xml --coverage-php build/coverage-db.cov --testdox",
"test:api": "bin/test/run-api-tests.sh",
"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=70 --log-verbosity=default --only-covered",
"infect:ci": "infection --threads=4 --min-msi=70 --log-verbosity=default --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=70 --log-verbosity=default --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:func": "<fg=blue;options=bold>Runs functional test suites (covering entity repositories)</>",
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:db": "<fg=blue;options=bold>Runs database test suites (covering entity repositories)</>",
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
"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:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>"
"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</>",
"infect:test": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>"
},
"config": {
"sort-packages": true

View File

@@ -8,7 +8,7 @@ return [
'app_options' => [
'name' => 'Shlink',
'version' => '%SHLINK_VERSION%',
'secret_key' => env('SECRET_KEY'),
'secret_key' => env('SECRET_KEY', ''),
'disable_track_param' => null,
],

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,23 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Psr\Log;
return [
'dependencies' => [
'lazy_services' => [
'write_proxy_files' => false,
],
'initializers' => [
function (ContainerInterface $container, $instance) {
if ($instance instanceof Log\LoggerAwareInterface) {
$instance->setLogger($container->get(Log\LoggerInterface::class));
}
},
],
],
];

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

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
use Zend\Expressive\Container\WhoopsErrorResponseGeneratorFactory;
return [

View File

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

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Installer\Config\Plugin;
return [
'installer_plugins_expected_config' => [
Plugin\LanguageConfigCustomizer::class => [
Plugin\LanguageConfigCustomizer::DEFAULT_LANG,
],
Plugin\UrlShortenerConfigCustomizer::class => [
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
Plugin\UrlShortenerConfigCustomizer::CHARS,
Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL,
Plugin\UrlShortenerConfigCustomizer::ENABLE_NOT_FOUND_REDIRECTION,
Plugin\UrlShortenerConfigCustomizer::NOT_FOUND_REDIRECT_TO,
],
Plugin\ApplicationConfigCustomizer::class => [
Plugin\ApplicationConfigCustomizer::SECRET,
Plugin\ApplicationConfigCustomizer::DISABLE_TRACK_PARAM,
Plugin\ApplicationConfigCustomizer::CHECK_VISITS_THRESHOLD,
Plugin\ApplicationConfigCustomizer::VISITS_THRESHOLD,
],
Plugin\DatabaseConfigCustomizer::class => [
Plugin\DatabaseConfigCustomizer::DRIVER,
Plugin\DatabaseConfigCustomizer::NAME,
Plugin\DatabaseConfigCustomizer::USER,
Plugin\DatabaseConfigCustomizer::PASSWORD,
Plugin\DatabaseConfigCustomizer::HOST,
Plugin\DatabaseConfigCustomizer::PORT,
],
],
];

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,72 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Processor;
use const PHP_EOL;
return [
'logger' => [
'formatters' => [
'dashed' => [
'format' => '[%datetime%] %channel%.%level_name% - %message% %context%' . PHP_EOL,
'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL,
'include_stacktraces' => true,
],
],
'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',
],
],
'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,
],
],
'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

@@ -13,7 +13,7 @@ return [
'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
Common\Middleware\LocaleMiddleware::class,
Common\Middleware\CloseDbConnectionMiddleware::class,
],
'priority' => 12,
],
@@ -47,6 +47,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,15 @@
<?php
declare(strict_types=1);
return [
'zend-expressive-swoole' => [
'enable_coroutine' => true,
'swoole-http-server' => [
'host' => '0.0.0.0',
'process-name' => 'shlink',
],
],
];

View File

@@ -1,7 +1,8 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use function Shlinkio\Shlink\Common\env;
return [
@@ -11,8 +12,12 @@ return [
'schema' => env('SHORTENED_URL_SCHEMA', 'http'),
'hostname' => env('SHORTENED_URL_HOSTNAME'),
],
'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS),
'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortenerOptions::DEFAULT_CHARS),
'validate_url' => true,
'not_found_short_url' => [
'enable_redirection' => false,
'redirect_to' => null,
],
],
];

View File

@@ -3,9 +3,9 @@ declare(strict_types=1);
return [
'phpwkhtmltopdf' => [
'wkhtmltopdf' => [
'images' => [
'binary' => 'bin/wkhtmltoimage',
'binary' => __DIR__ . '/../../bin/wkhtmltoimage',
'type' => 'jpg',
],
],

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

@@ -6,30 +6,8 @@ use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Interop\Container\ContainerInterface;
use Zend\ServiceManager\ServiceManager;
$isTest = false;
foreach ($_SERVER['argv'] as $i => $arg) {
if ($arg === '--test') {
unset($_SERVER['argv'][$i]);
$isTest = true;
break;
}
}
/** @var ContainerInterface|ServiceManager $container */
$container = include __DIR__ . '/container.php';
// If in testing env, override DB connection to use an in-memory sqlite database
if ($isTest) {
$container->setAllowOverride(true);
$config = $container->get('config');
$config['entity_manager']['connection'] = [
'driver' => 'pdo_sqlite',
'path' => realpath(sys_get_temp_dir()) . '/shlink-tests.db',
];
$container->setService('config', $config);
}
/** @var EntityManager $em */
$em = $container->get(EntityManager::class);
return ConsoleRunner::createHelperSet($em);

View File

@@ -7,20 +7,22 @@ use Acelaya\ExpressiveErrorHandler;
use Zend\ConfigAggregator;
use Zend\Expressive;
use function Shlinkio\Shlink\Common\env;
return (new ConfigAggregator\ConfigAggregator([
Expressive\ConfigProvider::class,
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,
CLI\ConfigProvider::class,
Installer\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\ZendConfigProvider('config/{autoload/{{,*.}global,{,*.}local},params/generated_config}.php'),
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
new ConfigAggregator\ZendConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
env('APP_ENV') === 'test'
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
: new ConfigAggregator\ArrayProvider([]),
], 'data/cache/app_config.php'))->getMergedConfig();

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Installer\Config\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\Installer\Factory\InstallApplicationFactory;
use Symfony\Component\Console\Application;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\ServiceManager;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$container = new ServiceManager([
'factories' => [
Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class,
],
'services' => [
'config' => [
ConfigAbstractFactory::class => [
DatabaseConfigCustomizer::class => [Filesystem::class],
],
],
],
]);
return $container;

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common;
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
use function file_exists;
use function touch;
// Create an empty .env file
if (! file_exists('.env')) {
touch('.env');
}
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$testHelper = $container->get(TestHelper::class);
$config = $container->get('config');
$em = $container->get(EntityManager::class);
$testHelper->createTestDb($config['entity_manager']['connection']['path']);
ApiTest\ApiTestCase::setApiClient($container->get('shlink_test_api_client'));
ApiTest\ApiTestCase::setSeedFixturesCallback(function () use ($testHelper, $em, $config) {
$testHelper->seedFixtures($em, $config['data_fixtures'] ?? []);
});

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common;
use Psr\Container\ContainerInterface;
use function file_exists;
use function touch;
// Create an empty .env file
if (! file_exists('.env')) {
touch('.env');
}
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$config = $container->get('config');
$container->get(TestHelper::class)->createTestDb($config['entity_manager']['connection']['path']);
DbTest\DatabaseTestCase::setEntityManager($container->get('em'));

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink;
use GuzzleHttp\Client;
use Zend\ConfigAggregator\ConfigAggregator;
use Zend\ServiceManager\Factory\InvokableFactory;
use function sprintf;
use function sys_get_temp_dir;
$swooleTestingHost = '127.0.0.1';
$swooleTestingPort = 9999;
return [
'debug' => true,
ConfigAggregator::ENABLE_CACHE => false,
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => 'doma.in',
],
],
'zend-expressive-swoole' => [
'swoole-http-server' => [
'host' => $swooleTestingHost,
'port' => $swooleTestingPort,
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
],
],
],
'dependencies' => [
'services' => [
'shlink_test_api_client' => new Client([
'base_uri' => sprintf('http://%s:%s/', $swooleTestingHost, $swooleTestingPort),
'http_errors' => false,
]),
],
'factories' => [
Common\TestHelper::class => InvokableFactory::class,
],
],
'entity_manager' => [
'connection' => [
'driver' => 'pdo_sqlite',
'path' => sys_get_temp_dir() . '/shlink-tests.db',
// 'path' => __DIR__ . '/../../data/shlink-tests.db',
],
],
'data_fixtures' => [
'paths' => [
__DIR__ . '/../../module/Rest/test-api/Fixtures',
],
],
];

2
data/infra/database_pg/.gitignore vendored Executable file
View File

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

View File

@@ -1,6 +0,0 @@
FROM mysql:5.7
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
# Enable remote access (default is localhost only, we change this
# otherwise our database would not be reachable from outside the container)
RUN sed -i -e"s/^bind-address\s*=\s*127.0.0.1/bind-address = 0.0.0.0/" /etc/mysql/my.cnf

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

@@ -1,5 +0,0 @@
FROM nginx:1.11.6-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
# Delete default nginx vhost
RUN rm /etc/nginx/conf.d/default.conf

View File

@@ -1,6 +1,12 @@
FROM php:7.1.22-fpm-alpine
FROM php:7.3.1-fpm-alpine3.8
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PREDIS_VERSION 4.2.0
ENV MEMCACHED_VERSION 3.1.3
ENV APCU_VERSION 5.1.16
ENV APCU_BC_VERSION 1.0.4
ENV XDEBUG_VERSION "2.7.0RC1"
RUN apk update
# Install common php extensions
@@ -16,17 +22,17 @@ 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 apk add --no-cache --virtual libzip-dev 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
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
# Install redis extension
ADD https://github.com/phpredis/phpredis/archive/3.1.4.tar.gz /tmp/phpredis.tar.gz
ADD https://github.com/phpredis/phpredis/archive/$PREDIS_VERSION.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
@@ -38,7 +44,7 @@ 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
ADD https://github.com/php-memcached-dev/php-memcached/archive/v$MEMCACHED_VERSION.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
@@ -48,7 +54,7 @@ RUN docker-php-ext-configure memcached\
RUN rm /tmp/memcached.tar.gz
# Install APCu extension
ADD https://pecl.php.net/get/apcu-5.1.3.tgz /tmp/apcu.tar.gz
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.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
@@ -58,7 +64,7 @@ RUN docker-php-ext-configure apcu\
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
ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.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
@@ -72,7 +78,7 @@ 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 xdebug
ADD https://pecl.php.net/get/xdebug-2.5.0 /tmp/xdebug.tar.gz
ADD https://pecl.php.net/get/xdebug-$XDEBUG_VERSION /tmp/xdebug.tar.gz
RUN mkdir -p /usr/src/php/ext/xdebug\
&& tar xf /tmp/xdebug.tar.gz -C /usr/src/php/ext/xdebug --strip-components=1
# configure and install

View File

@@ -0,0 +1,103 @@
FROM php:7.3.1-cli-alpine3.8
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PREDIS_VERSION 4.2.0
ENV MEMCACHED_VERSION 3.1.3
ENV APCU_VERSION 5.1.16
ENV APCU_BC_VERSION 1.0.4
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 libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache --virtual libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
# Install redis extension
ADD https://github.com/phpredis/phpredis/archive/$PREDIS_VERSION.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/v$MEMCACHED_VERSION.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-$APCU_VERSION.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-$APCU_BC_VERSION.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

@@ -0,0 +1,20 @@
#!/bin/bash
set -e
# Get latest commit in master, in plain text
LATEST_MASTER_COMMIT=$(curl -H "Accept: application/vnd.github.sha" -X GET https://api.github.com/repos/shlinkio/shlink-docker-image/commits/master)
# Create new tag and a ref to the tag, which will trigger image build on it
curl -u acelaya:${GITHUB_OAUTH_KEY} \
-H "Content-Type: application/json" \
--data "{ \"tag\": \"${TRAVIS_TAG}\", \"message\": \"${TRAVIS_TAG}\", \"object\": \"${LATEST_MASTER_COMMIT}\", \"type\": \"commit\" }" \
-X POST https://api.github.com/repos/shlinkio/shlink-docker-image/git/tags
curl -u acelaya:${GITHUB_OAUTH_KEY} \
-H "Content-Type: application/json" \
--data "{ \"ref\": \"refs/tags/${TRAVIS_TAG}\", \"sha\": \"${LATEST_MASTER_COMMIT}\" }" \
-X POST https://api.github.com/repos/shlinkio/shlink-docker-image/git/refs
# Trigger image build for "latest
curl -H "Content-Type: application/json" \
--data '{ "docker_tag": "latest" }' \
-X POST https://registry.hub.docker.com/u/shlinkio/shlink/trigger/${DOCKER_TRIGGER_TOKEN}/

View File

@@ -1,4 +1,4 @@
version: '2'
version: '3'
services:
shlink_php:
@@ -6,3 +6,21 @@ 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
shlink_db:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_postgres:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro

View File

@@ -1,17 +1,15 @@
version: '2'
version: '3'
services:
shlink_nginx:
container_name: shlink_nginx
build:
context: .
dockerfile: ./data/infra/nginx.Dockerfile
image: nginx:1.15.9-alpine
ports:
- "8000:80"
volumes:
- ./:/home/shlink/www
- ./docs:/home/shlink/www/public/docs
- ./data/infra/vhost.conf:/etc/nginx/conf.d/shlink-vhost.conf
- ./data/infra/vhost.conf:/etc/nginx/conf.d/default.conf
links:
- shlink_php
@@ -25,12 +23,24 @@ services:
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
links:
- shlink_db
- shlink_db_postgres
shlink_swoole:
container_name: shlink_swoole
build:
context: .
dockerfile: ./data/infra/swoole.Dockerfile
ports:
- "8080:8080"
volumes:
- ./:/home/shlink
links:
- shlink_db
- shlink_db_postgres
shlink_db:
container_name: shlink_db
build:
context: .
dockerfile: ./data/infra/db.Dockerfile
image: mysql:5.7
ports:
- "3307:3306"
volumes:
@@ -39,3 +49,16 @@ services:
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shlink
shlink_db_postgres:
container_name: shlink_db_postgres
image: postgres:10.7-alpine
ports:
- "5433:5432"
volumes:
- ./:/home/shlink/www
- ./data/infra/database_pg:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: root
POSTGRES_DB: shlink
PGDATA: /var/lib/postgresql/data/pgdata

View File

@@ -0,0 +1,31 @@
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"pass",
"fail"
],
"description": "The status of the service"
},
"version": {
"type": "string",
"description": "Shlink version"
},
"links": {
"type": "object",
"properties": {
"about": {
"type": "string",
"description": "About shlink"
},
"project": {
"type": "string",
"description": "Shlink project repository"
}
},
"description": "A list of links"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"get": {
"operationId": "health",
"tags": [
"Monitoring"
],
"summary": "Check healthiness",
"description": "Checks the healthiness of the service, making sure it can access required resources.",
"responses": {
"200": {
"description": "The passing health status",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Health.json"
}
}
},
"examples": {
"application/json": {
"status": "pass",
"version": "1.16.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
}
}
}
},
"503": {
"description": "The failing health status",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Health.json"
}
}
},
"examples": {
"application/json": {
"status": "fail",
"version": "1.16.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -151,7 +151,7 @@
"Short URLs"
],
"summary": "Create short URL",
"description": "Creates a new short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"description": "Creates a new short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.<br></br>**Param findIfExists:**: Starting with v1.16, this new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
"security": [
{
"ApiKey": []
@@ -197,6 +197,10 @@
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
},
"findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean"
}
}
}
@@ -209,23 +213,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

@@ -0,0 +1,36 @@
{
"get": {
"operationId": "shortUrl",
"tags": [
"URL Shortener"
],
"summary": "Short URL",
"description": "Represents a short URL. Tracks the visit and redirects tio the corresponding long URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"302": {
"description": "Visit properly tracked and redirected"
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
{
"get": {
"operationId": "shortUrlPreview",
"tags": [
"URL Shortener"
],
"summary": "Short URL preview image",
"description": "Returns the preview of the page behind a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Image in PNG format",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,56 @@
{
"get": {
"operationId": "shortUrlQrCode",
"tags": [
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "size",
"in": "path",
"description": "The size of the image to be returned.",
"required": false,
"schema": {
"type": "integer",
"minimum": 50,
"maximum": 1000,
"default": 300
}
}
],
"responses": {
"200": {
"description": "QR code in PNG format",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
{
"get": {
"operationId": "trackShortUrl",
"tags": [
"URL Shortener"
],
"summary": "Short URL tracking pixel",
"description": "Generates a 1px transparent image which can be used to track emails with a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Image in GIF format",
"content": {
"image/gif": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -6,15 +6,20 @@
"version": "1.0"
},
"externalDocs": {
"url": "https://shlink.io/api-docs",
"description": "Find more info on how to start using this API here"
},
"servers": [
{
"url": "{schema}://{server}/rest",
"url": "{scheme}://{host}",
"variables": {
"schema": {
"scheme": {
"default": "https",
"enum": ["https", "http"]
},
"server": {
"host": {
"default": ""
}
}
@@ -51,35 +56,60 @@
"name": "Visits",
"description": "Operations to manage visits on short URLs"
},
{
"name": "Monitoring",
"description": "Public endpoints designed to monitor the service"
},
{
"name": "URL Shortener",
"description": "Non-rest endpoints, used to be publicly exposed"
},
{
"name": "Authentication",
"description": "Authentication-related endpoints"
"description": "**[Deprecated]** Authentication-related endpoints"
}
],
"paths": {
"/v1/short-urls": {
"/rest/v1/short-urls": {
"$ref": "paths/v1_short-urls.json"
},
"/v1/short-urls/shorten": {
"/rest/v1/short-urls/shorten": {
"$ref": "paths/v1_short-urls_shorten.json"
},
"/v1/short-urls/{shortCode}": {
"/rest/v1/short-urls/{shortCode}": {
"$ref": "paths/v1_short-urls_{shortCode}.json"
},
"/v1/short-urls/{shortCode}/tags": {
"/rest/v1/short-urls/{shortCode}/tags": {
"$ref": "paths/v1_short-urls_{shortCode}_tags.json"
},
"/v1/tags": {
"/rest/v1/tags": {
"$ref": "paths/v1_tags.json"
},
"/v1/short-urls/{shortCode}/visits": {
"/rest/v1/short-urls/{shortCode}/visits": {
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
},
"/v1/authenticate": {
"/rest/health": {
"$ref": "paths/health.json"
},
"/{shortCode}": {
"$ref": "paths/{shortCode}.json"
},
"/{shortCode}/track": {
"$ref": "paths/{shortCode}_track.json"
},
"/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json"
},
"/{shortCode}/preview": {
"$ref": "paths/{shortCode}_preview.json"
},
"/rest/v1/authenticate": {
"$ref": "paths/v1_authenticate.json"
}
}

View File

@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
use Symfony\Component\Process\Process;
use Zend\ServiceManager\ServiceManager;
// Create an empty .env file
if (! file_exists('.env')) {
touch('.env');
}
$shlinkDbPath = realpath(sys_get_temp_dir()) . '/shlink-tests.db';
if (file_exists($shlinkDbPath)) {
unlink($shlinkDbPath);
}
/** @var ServiceManager $sm */
$sm = require __DIR__ . '/config/container.php';
$sm->setAllowOverride(true);
$config = $sm->get('config');
$config['entity_manager']['connection'] = [
'driver' => 'pdo_sqlite',
'path' => $shlinkDbPath,
];
$sm->setService('config', $config);
// Create database
$process = new Process('vendor/bin/doctrine orm:schema-tool:create --no-interaction -q --test', __DIR__);
$process->inheritEnvironmentVariables()
->mustRun();
DatabaseTestCase::$em = $sm->get('em');

View File

@@ -1,2 +1,8 @@
#!/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
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,52 +3,49 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use InvalidArgumentException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
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
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$apiKey = $input->getArgument('apiKey');
$io = new SymfonyStyle($input, $output);
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));
return ExitCodes::EXIT_SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error(sprintf('API key "%s" does not exist.', $apiKey));
return ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -4,54 +4,48 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
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
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$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));
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,15 +3,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
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,62 +24,51 @@ 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
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$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() : '-';
return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly));
$rows[] = $rowData;
}
$io->table(array_filter([
$this->translator->translate('Key'),
! $enabledOnly ? $this->translator->translate('Is enabled') : null,
$this->translator->translate('Expiration date'),
ShlinkTable::fromOutput($output)->render(array_filter([
'Key',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
]), $rows);
return ExitCodes::EXIT_SUCCESS;
}
private function determineMessagePattern(ApiKey $apiKey): string

View File

@@ -3,44 +3,37 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Config;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
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;
/** @deprecated */
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(
'[DEPRECATED] Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable',
UrlShortenerOptions::DEFAULT_CHARS
))
->setHelp('<fg=red;options=bold>This command is deprecated. Better leave shlink generate the charset.</>');
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
(new SymfonyStyle($input, $output))->success(
sprintf($this->translator->translate('Character set: "%s"'), $charSet)
);
$charSet = str_shuffle(UrlShortenerOptions::DEFAULT_CHARS);
(new SymfonyStyle($input, $output))->success(sprintf('Character set: "%s"', $charSet));
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,44 +3,36 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Config;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
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;
/** @deprecated */
class GenerateSecretCommand extends Command
{
use StringUtilsTrait;
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')
->setHelp(
'<fg=red;options=bold>This command is deprecated. Better leave shlink generate the secret key.</>'
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$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));
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -11,27 +12,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 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,26 +34,18 @@ 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'
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
@@ -66,36 +53,37 @@ class DeleteShortUrlCommand extends Command
try {
$this->runDelete($io, $shortCode, $ignoreThreshold);
return ExitCodes::EXIT_SUCCESS;
} 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));
return ExitCodes::EXIT_FAILURE;
} catch (Exception\DeleteShortUrlException $e) {
$this->retry($io, $shortCode, $e);
return $this->retry($io, $shortCode, $e);
}
}
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): void
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): int
{
$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.');
}
return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING;
}
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

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -10,35 +11,24 @@ 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,13 +37,11 @@ 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.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$page = 1;
do {
@@ -61,21 +49,22 @@ 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');
return ExitCodes::EXIT_SUCCESS;
}
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

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
use Symfony\Component\Console\Command\Command;
@@ -15,7 +17,12 @@ 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_map;
use function Functional\curry;
use function Functional\flatten;
use function Functional\unique;
use function sprintf;
class GenerateShortUrlCommand extends Command
{
@@ -24,28 +31,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 +48,46 @@ 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.'
));
)
->addOption(
'findIfExists',
'f',
InputOption::VALUE_NONE,
'This will force existing matching URL to be returned if found, instead of creating a new one.'
);
}
protected function interact(InputInterface $input, OutputInterface $output): void
@@ -87,30 +98,23 @@ 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);
}
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl');
if (empty($longUrl)) {
$io->error($this->translator->translate('A URL was not provided!'));
return;
$io->error('A URL was not provided!');
return ExitCodes::EXIT_FAILURE;
}
$tags = $input->getOption('tags');
$processedTags = [];
foreach ($tags as $key => $tag) {
$explodedTags = \explode(',', $tag);
$processedTags = \array_merge($processedTags, $explodedTags);
}
$tags = $processedTags;
$explodeWithComma = curry('explode')(',');
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('customSlug');
$maxVisits = $input->getOption('maxVisits');
@@ -118,29 +122,29 @@ class GenerateShortUrlCommand extends Command
$shortCode = $this->urlShortener->urlToShortCode(
new Uri($longUrl),
$tags,
$this->getOptionalDate($input, 'validSince'),
$this->getOptionalDate($input, 'validUntil'),
$customSlug,
$maxVisits !== null ? (int) $maxVisits : null
ShortUrlMeta::createFromParams(
$this->getOptionalDate($input, 'validSince'),
$this->getOptionalDate($input, 'validUntil'),
$customSlug,
$maxVisits !== null ? (int) $maxVisits : null,
$input->getOption('findIfExists')
)
)->getShortCode();
$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),
]);
return ExitCodes::EXIT_SUCCESS;
} 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));
return ExitCodes::EXIT_FAILURE;
} 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)
);
return ExitCodes::EXIT_FAILURE;
}
}

View File

@@ -4,7 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
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 +16,22 @@ 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 +40,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,39 +64,28 @@ 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);
}
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
$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);
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
return ExitCodes::EXIT_SUCCESS;
}
private function getDateOption(InputInterface $input, $key)

View File

@@ -3,8 +3,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Symfony\Component\Console\Command\Command;
@@ -12,7 +15,13 @@ 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\Paginator\Paginator;
use function array_values;
use function count;
use function explode;
use function implode;
use function sprintf;
class ListShortUrlsCommand extends Command
{
@@ -21,27 +30,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,96 +47,93 @@ 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
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('searchTerm');
$tags = $input->getOption('tags');
$tags = ! empty($tags) ? \explode(',', $tags) : [];
$showTags = $input->getOption('showTags');
$tags = ! empty($tags) ? explode(',', $tags) : [];
$showTags = (bool) $input->getOption('showTags');
$transformer = new ShortUrlDataTransformer($this->domainConfig);
do {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$result = $this->renderPage($input, $output, $page, $searchTerm, $tags, $showTags, $transformer);
$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'),
];
if ($showTags) {
$headers[] = $this->translator->translate('Tags');
}
$rows = [];
foreach ($result as $row) {
$shortUrl = $transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = \implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = \array_values($shortUrl);
}
$io->table($headers, $rows);
if ($this->isLastPage($result)) {
$continue = false;
$io->success($this->translator->translate('Short URLs properly listed'));
} else {
$continue = $io->confirm(
\sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),
false
);
}
$continue = $this->isLastPage($result)
? false
: $io->confirm(sprintf('Continue with page <options=bold>%s</>?', $page), false);
} while ($continue);
$io->newLine();
$io->success('Short URLs properly listed');
return ExitCodes::EXIT_SUCCESS;
}
private function renderPage(
InputInterface $input,
OutputInterface $output,
int $page,
?string $searchTerm,
array $tags,
bool $showTags,
DataTransformerInterface $transformer
): Paginator {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {
$headers[] = 'Tags';
}
$rows = [];
foreach ($result as $row) {
$shortUrl = $transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = array_values($shortUrl);
}
ShlinkTable::fromOutput($output)->render($headers, $rows, $this->formatCurrentPageMessage(
$result,
'Page %s of %s'
));
return $result;
}
private function processOrderBy(InputInterface $input)
@@ -149,7 +143,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

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@@ -11,27 +12,21 @@ 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 +34,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,32 +46,27 @@ 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);
}
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
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()));
return ExitCodes::EXIT_SUCCESS;
} 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));
return ExitCodes::EXIT_FAILURE;
} 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));
return ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -3,58 +3,52 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
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'
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name');
if (empty($tagNames)) {
$io->warning($this->translator->translate('You have to provide at least one tag name'));
return;
$io->warning('You have to provide at least one tag name');
return ExitCodes::EXIT_WARNING;
}
$this->tagService->createTags($tagNames);
$io->success($this->translator->translate('Tags properly created'));
$io->success('Tags properly created');
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,58 +3,52 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
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'
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name');
if (empty($tagNames)) {
$io->warning($this->translator->translate('You have to provide at least one tag name'));
return;
$io->warning('You have to provide at least one tag name');
return ExitCodes::EXIT_WARNING;
}
$this->tagService->deleteTags($tagNames);
$io->success($this->translator->translate('Tags properly deleted'));
$io->success('Tags properly deleted');
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,57 +3,51 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
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
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$io->table([$this->translator->translate('Name')], $this->getTagsRows());
ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows());
return ExitCodes::EXIT_SUCCESS;
}
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

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -10,39 +11,32 @@ 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
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
@@ -50,9 +44,11 @@ class RenameTagCommand extends Command
try {
$this->tagService->renameTag($oldName, $newName);
$io->success($this->translator->translate('Tag properly renamed.'));
$io->success('Tag properly renamed.');
return ExitCodes::EXIT_SUCCESS;
} 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));
return ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -3,112 +3,110 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
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
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$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 ExitCodes::EXIT_WARNING;
}
$io->success($this->translator->translate('Finished processing all IPs'));
try {
$this->visitService->locateUnlocatedVisits(
[$this, 'getGeolocationDataForVisit'],
function (VisitLocation $location) use ($output) {
if (! $location->isEmpty()) {
$output->writeln(
sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName())
);
}
}
);
$io->success('Finished processing all IPs');
} finally {
$lock->release();
return ExitCodes::EXIT_SUCCESS;
}
}
public function getGeolocationDataForVisit(Visit $visit): Location
{
if (! $visit->hasRemoteAddr()) {
$this->output->writeln(
'<comment>Ignored visit with no IP address</comment>',
OutputInterface::VERBOSITY_VERBOSE
);
throw IpCannotBeLocatedException::forEmptyAddress();
}
$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 IpCannotBeLocatedException::forLocalhost();
}
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 IpCannotBeLocatedException::forError($e);
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
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): ?int
{
$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');
return ExitCodes::EXIT_SUCCESS;
} catch (RuntimeException $e) {
$progressBar->finish();
$io->writeln('');
$io->error('An error occurred while updating GeoLite2 database');
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
return ExitCodes::EXIT_FAILURE;
}
}
}

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

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
final class ExitCodes
{
public const EXIT_SUCCESS = 0;
public const EXIT_FAILURE = -1;
public const EXIT_WARNING = 1;
}

View File

@@ -10,55 +10,51 @@ 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()
public function setUp(): void
{
$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);
}
/**
* @test
*/
/** @test */
public function providedApiKeyIsDisabled()
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
}
/**
* @test
*/
public function errorIsReturnedIfServiceThrowsException()
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class)
->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" does not exist.', $output);
$this->assertStringContainsString('API key "abcd1234" properly disabled', $output);
}
/** @test */
public function errorIsReturnedIfServiceThrowsException()
{
$apiKey = 'abcd1234';
$disable = $this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class);
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('API key "abcd1234" does not exist.', $output);
$disable->shouldHaveBeenCalledOnce();
}
}

View File

@@ -12,46 +12,41 @@ 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()
public function setUp(): void
{
$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);
}
/**
* @test
*/
/** @test */
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->assertStringContainsString('Generated API key: ', $output);
$create->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
/** @test */
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,55 +10,59 @@ 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()
public function setUp(): void
{
$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);
}
/**
* @test
*/
public function ifEnabledOnlyIsNotProvidedEverythingIsListed()
/** @test */
public function everythingIsListedIfEnabledOnlyIsNotProvided(): void
{
$this->apiKeyService->listKeys(false)->willReturn([
new ApiKey(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'api-key:list',
]);
])->shouldBeCalledOnce();
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Key', $output);
$this->assertStringContainsString('Is enabled', $output);
$this->assertStringContainsString(' +++ ', $output);
$this->assertStringNotContainsString(' --- ', $output);
$this->assertStringContainsString('Expiration date', $output);
}
/**
* @test
*/
public function ifEnabledOnlyIsProvidedOnlyThoseKeysAreListed()
/** @test */
public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided(): void
{
$this->apiKeyService->listKeys(true)->willReturn([
(new ApiKey())->disable(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:list',
'--enabledOnly' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Key', $output);
$this->assertStringNotContainsString('Is enabled', $output);
$this->assertStringNotContainsString(' +++ ', $output);
$this->assertStringNotContainsString(' --- ', $output);
$this->assertStringContainsString('Expiration date', $output);
}
}

View File

@@ -7,27 +7,26 @@ 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()
public function setUp(): void
{
$command = new GenerateCharsetCommand(Translator::factory([]));
$command = new GenerateCharsetCommand();
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function charactersAreGeneratedFromDefault()
{
$prefix = 'Character set: ';
@@ -38,7 +37,7 @@ class GenerateCharsetCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
// Both default character set and the new one should have the same length
$this->assertContains($prefix, $output);
$this->assertStringContainsString($prefix, $output);
}
protected function orderStringLetters($string)

View File

@@ -11,34 +11,32 @@ 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;
class DeleteShortCodeCommandTest extends TestCase
use const PHP_EOL;
use function array_pop;
use function sprintf;
class DeleteShortUrlCommandTest extends TestCase
{
/**
* @var CommandTester
*/
/** @var CommandTester */
private $commandTester;
/**
* @var ObjectProphecy
*/
/** @var ObjectProphecy */
private $service;
public function setUp()
public function setUp(): void
{
$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);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function successMessageIsPrintedIfUrlIsProperlyDeleted()
/** @test */
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->will(function () {
@@ -47,14 +45,15 @@ 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->assertStringContainsString(
sprintf('Short URL with short code "%s" successfully deleted.', $shortCode),
$output
);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function invalidShortCodePrintsMessage()
/** @test */
public function invalidShortCodePrintsMessage(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
@@ -64,42 +63,51 @@ 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->assertStringContainsString(sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideRetryDeleteAnswers
*/
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted()
{
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
array $retryAnswer,
int $expectedDeleteCalls,
string $expectedMessage
): void {
$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);
}
}
);
$this->commandTester->setInputs(['yes']);
$this->commandTester->setInputs($retryAnswer);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
$this->assertStringContainsString(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);
$deleteByShortCode->shouldHaveBeenCalledTimes(2);
$this->assertStringContainsString($expectedMessage, $output);
$deleteByShortCode->shouldHaveBeenCalledTimes($expectedDeleteCalls);
}
/**
* @test
*/
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined()
public function provideRetryDeleteAnswers(): iterable
{
yield 'answering yes to retry' => [['yes'], 2, 'Short URL with short code "abc123" successfully deleted.'];
yield 'answering no to retry' => [['no'], 1, 'Short URL was not deleted.'];
yield 'answering default to retry' => [[PHP_EOL], 1, 'Short URL was not deleted.'];
}
/** @test */
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
@@ -110,11 +118,11 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
$this->assertStringContainsString(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);
$this->assertStringContainsString('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
}

View File

@@ -13,74 +13,71 @@ 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()
public function setUp(): void
{
$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);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
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->assertStringContainsString('Processing URL http://foo.com', $output);
$this->assertStringContainsString('Processing URL https://bar.com', $output);
$this->assertStringContainsString('Processing URL http://baz.com/something', $output);
$this->assertStringContainsString('Finished processing all URLs', $output);
$generatePreview1->shouldHaveBeenCalledOnce();
$generatePreview2->shouldHaveBeenCalledOnce();
$generatePreview3->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
/** @test */
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()
public function setUp(): void
{
$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',
]);
@@ -37,42 +34,61 @@ class GenerateShortcodeCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
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->assertStringContainsString('http://foo.com/abc123', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
/** @test */
public function exceptionWhileParsingLongUrlOutputsError()
{
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/invalid',
]);
$output = $this->commandTester->getDisplay();
$this->assertContains(
$this->assertStringContainsString(
'Provided URL "http://domain.com/invalid" is invalid.',
$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,baz'],
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('http://foo.com/abc123', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
}

View File

@@ -8,42 +8,42 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
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()
public function setUp(): void
{
$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);
}
/**
* @test
*/
/** @test */
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',
@@ -51,17 +51,18 @@ class GetVisitsCommandTest extends TestCase
]);
}
/**
* @test
*/
/** @test */
public function providingDateFlagsTheListGetsFiltered()
{
$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',
@@ -71,25 +72,25 @@ class GetVisitsCommandTest extends TestCase
]);
}
/**
* @test
*/
public function outputIsProperlyGenerated()
/** @test */
public function outputIsProperlyGenerated(): void
{
$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(new Location('', 'Spain', '', '', 0, 0, ''))
),
]))
)->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->assertStringContainsString('foo', $output);
$this->assertStringContainsString('Spain', $output);
$this->assertStringContainsString('bar', $output);
}
}

View File

@@ -11,51 +11,42 @@ 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()
public function setUp(): void
{
$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);
}
/**
* @test
*/
/** @test */
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']);
}
/**
* @test
*/
/** @test */
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,34 +55,44 @@ class ListShortcodesCommandTest extends TestCase
$this->commandTester->setInputs(['y', 'y', 'n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Continue with page 2?', $output);
$this->assertStringContainsString('Continue with page 3?', $output);
$this->assertStringContainsString('Continue with page 4?', $output);
}
/**
* @test
*/
/** @test */
public function havingMorePagesButAnsweringNoCallsListJustOnce()
{
// 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->assertStringContainsString('url_1', $output);
$this->assertStringContainsString('url_9', $output);
$this->assertStringNotContainsString('url_10', $output);
$this->assertStringNotContainsString('url_20', $output);
$this->assertStringNotContainsString('url_30', $output);
$this->assertStringContainsString('Continue with page 2?', $output);
$this->assertStringNotContainsString('Continue with page 3?', $output);
}
/**
* @test
*/
/** @test */
public function passingPageWillMakeListStartOnThatPage()
{
$page = 5;
$this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
@@ -100,13 +101,11 @@ class ListShortcodesCommandTest extends TestCase
]);
}
/**
* @test
*/
/** @test */
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
@@ -114,6 +113,6 @@ class ListShortcodesCommandTest extends TestCase
'--showTags' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Tags', $output);
$this->assertStringContainsString('Tags', $output);
}
}

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