Compare commits

..

141 Commits

Author SHA1 Message Date
Alejandro Celaya
c17c4c1319 Merge pull request #448 from acelaya/feature/improve-msi
Feature/improve msi
2019-08-08 17:19:13 +02:00
Alejandro Celaya
5967dd97c5 Updated changelog 2019-08-08 17:08:22 +02:00
Alejandro Celaya
0c26198b55 Improved tests to reach 75% MSI 2019-08-08 17:06:20 +02:00
Alejandro Celaya
a304cca3b6 Improved ListShortUrlsActionTest 2019-08-08 16:43:47 +02:00
Alejandro Celaya
564b65c8ca Created ValidationExceptionTest 2019-08-08 16:20:37 +02:00
Alejandro Celaya
9de0cf5c03 Merge pull request #447 from acelaya/feature/fix-command-error
Feature/fix command error
2019-08-08 14:58:23 +02:00
Alejandro Celaya
1349079f59 Updated TaskRunner and ListenerProvider so that they are lazyly created, preventing the Swoole server to be created more than once 2019-08-08 14:12:54 +02:00
Alejandro Celaya
38016b3ba3 Created delegator factory that injects logger on services implementing LoggerAware, and used it for locks factory 2019-08-08 13:42:14 +02:00
Alejandro Celaya
8db9962282 Updated proxy-manager version to ensure v2.3 or higher is notinstalled 2019-08-08 10:01:21 +02:00
Alejandro Celaya
dca3fb35c7 Improved build script 2019-08-08 09:56:53 +02:00
Alejandro Celaya
8484449d66 Merge pull request #445 from acelaya/feature/redis-missings
Feature/redis missings
2019-08-07 18:58:35 +02:00
Alejandro Celaya
6b8ca3e611 Updated SimplifiedConfigParser so that it properly converts the redis_servers keys and aliases the store as a side effect 2019-08-07 18:45:28 +02:00
Alejandro Celaya
73fd348490 Ensured Redis lock store is wrapped into a retry adapter 2019-08-07 17:37:24 +02:00
Alejandro Celaya
04389fc8b0 Added support in RedisFactory to provide servers as a comma-separated string 2019-08-07 17:01:09 +02:00
Alejandro Celaya
b0bb77ca81 Merge pull request #444 from acelaya/feature/redis-support
Feature/redis support
2019-08-07 16:31:47 +02:00
Alejandro Celaya
22598e75e8 Updated changelog 2019-08-07 16:20:23 +02:00
Alejandro Celaya
0f8dd1effb Added post processing mapping to define the lock store to be used 2019-08-07 16:16:53 +02:00
Alejandro Celaya
2c4a8543db Added redis container to docker compose 2019-08-07 16:07:40 +02:00
Alejandro Celaya
7aa246b550 Created RedisFactoryTest 2019-08-07 16:07:17 +02:00
Alejandro Celaya
1e294fe1bc Created RedisFactory which will create the redis adapter for the redis lock 2019-08-07 14:17:15 +02:00
Alejandro Celaya
dcfb12f454 Moved some classes to proper namespaces 2019-08-07 13:50:38 +02:00
Alejandro Celaya
685ee51e1f Made commands run indocker to use the shlink_php container instead of the shlink_swoole 2019-08-07 11:05:21 +02:00
Alejandro Celaya
8407fee96d Ensured generated installation config is not loaded on test envs 2019-08-07 10:59:05 +02:00
Alejandro Celaya
7c881377a9 Removed extra spaces 2019-08-06 21:18:01 +02:00
Alejandro Celaya
acf2961f9e Merge pull request #442 from acelaya/feature/locked-migrations-command
Feature/locked migrations command
2019-08-06 21:16:11 +02:00
Alejandro Celaya
f5faeb8f68 Updated changelog 2019-08-06 21:09:56 +02:00
Alejandro Celaya
8985a6932f Created MigrateDatabaseCommandTest 2019-08-06 21:06:14 +02:00
Alejandro Celaya
c04f0af56f Created command to run migrations with a lock 2019-08-06 20:48:48 +02:00
Alejandro Celaya
1341d4fe57 Merge pull request #440 from acelaya/feature/locked-installation
Feature/locked installation
2019-08-06 20:31:51 +02:00
Alejandro Celaya
bc3fc59b1e Fixed error on new database creation command when database platform is sqlite 2019-08-06 20:16:16 +02:00
Alejandro Celaya
e04838eaa2 Updated readme cli help 2019-08-06 18:56:47 +02:00
Alejandro Celaya
5d5d89afb9 Updated changelog 2019-08-06 18:49:32 +02:00
Alejandro Celaya
749671c230 Created CreateDatabaseCommandTest 2019-08-06 18:40:32 +02:00
Alejandro Celaya
e79c41d753 Created NoDbNameConnectionFactoryTest 2019-08-06 17:30:28 +02:00
Alejandro Celaya
a575f2eced Created new service which is the database connection but without the dbname, and used in in create db command 2019-08-05 18:48:33 +02:00
Alejandro Celaya
1aba77c752 Enforced fixed shlink-installer version 2019-08-05 10:27:38 +02:00
Alejandro Celaya
b68e262eac Implemented how the CreateDatabaseCommand checks if the database tables exist 2019-08-05 10:16:58 +02:00
Alejandro Celaya
f78fa58cf1 Updated CreateDatabaseCommand to create the empty database if it does not exist 2019-08-05 10:08:59 +02:00
Alejandro Celaya
3916b06e7c Added improvements and new steps to CreateDatabaseCommand 2019-08-04 21:31:37 +02:00
Alejandro Celaya
7fa1f1c63c Created empoty locked command to create shlink database 2019-08-04 11:30:35 +02:00
Alejandro Celaya
7ed85e8916 Moved locking logic for CLI commands to a common abstract class 2019-08-04 11:16:46 +02:00
Alejandro Celaya
94e1e6a7b6 Merge pull request #437 from acelaya/feature/decorate-em
Feature/decorate em
2019-08-02 20:13:58 +02:00
Alejandro Celaya
3cba3f7a4b Removed error which no longer needs to be supressed from phpstan 2019-08-02 19:56:24 +02:00
Alejandro Celaya
bfd2ce782c Created ReopeningEntityManagerTest 2019-08-02 19:53:19 +02:00
Alejandro Celaya
f99053d251 Created ReopeningEntityManagerDelegatorTest 2019-08-02 19:33:31 +02:00
Alejandro Celaya
bdc93a45b5 Created EntityManagerDecorator to handle the automatic reopening, and removed this behavior from ClosDbConnectionMiddleware 2019-08-02 19:28:10 +02:00
Alejandro Celaya
a771743756 Merge pull request #433 from acelaya/feature/coding-standard
Updated to coding-standard library v1.2.2
2019-08-01 20:00:55 +02:00
Alejandro Celaya
aff1df32f2 Updated to coding-standard library v1.2.2 2019-08-01 19:49:54 +02:00
Alejandro Celaya
3562afc2bd Merge pull request #432 from acelaya/feature/extended-ip-addresses
Feature/extended ip addresses
2019-08-01 18:42:53 +02:00
Alejandro Celaya
ac08ed7cf9 Updated changelog 2019-08-01 18:31:18 +02:00
Alejandro Celaya
9cb316bdfa Added more headers to inspect while looking for the remote IP address 2019-08-01 18:27:43 +02:00
Alejandro Celaya
6682b52159 Merge pull request #431 from acelaya/feature/close-db-on-error
Feature/close db on error
2019-07-31 21:19:08 +02:00
Alejandro Celaya
f5878a5e7b Ensured EntityManager is reopened by CloseDbConnectionMiddleware after an error closed it 2019-07-31 20:54:41 +02:00
Alejandro Celaya
406de16a0d Ensured database connection is closed even if an error is thrown during dispatch process 2019-07-31 20:08:46 +02:00
Alejandro Celaya
a73a59f184 Merge pull request #425 from SirFlip/master
Update wkhtmltoimage shlinkio/shlink#424
2019-07-31 16:28:30 +02:00
Hannes Filip
cca667cf46 Update wkhtmltoimage shlinkio/shlink#424 2019-07-31 16:03:32 +02:00
Alejandro Celaya
e6a63a9b85 Added missing explicit dependency 2019-07-25 23:05:51 +02:00
Alejandro Celaya
22630c7656 Merge pull request #421 from acelaya/bugfix/db-reader-proxy
Bugfix/db reader proxy
2019-07-23 22:27:40 +02:00
Alejandro Celaya
c9ec3b3b42 Fixed composer commands to be more aqurate based on their name 2019-07-23 22:17:49 +02:00
Alejandro Celaya
a6727c5382 Fixed coding styles 2019-07-23 22:09:38 +02:00
Alejandro Celaya
9fe2111d62 Updated changelog 2019-07-23 22:06:09 +02:00
Alejandro Celaya
173bfbd300 Updated tests to fit current implementations 2019-07-23 22:04:01 +02:00
Alejandro Celaya
999beef349 Fixed GeolocationDbUpdater so that it does not try to interact with the reader if the file does not exist, preventing later errors 2019-07-23 17:07:40 +02:00
Alejandro Celaya
c6fdd8a59f Improvements and ensured LocateVisitsCommand does not swallow exceptions 2019-07-23 16:41:32 +02:00
Alejandro Celaya
0ec7e8c41b Merge pull request #417 from acelaya/feature/swoole-tasks
Feature/swoole tasks
2019-07-20 12:35:43 +02:00
Alejandro Celaya
89e4ed5573 Update docs 2019-07-20 12:27:28 +02:00
Alejandro Celaya
4c76df91ce Added ConfigProviderTest for EventDispatcher module 2019-07-20 12:16:31 +02:00
Alejandro Celaya
a1c7e7d5da Updated tests 2019-07-20 12:11:07 +02:00
Alejandro Celaya
f28540a53e Updated GeolocationDbUpdater so that it handles a lock which prevents the db to be updated in parallel 2019-07-20 11:30:26 +02:00
Alejandro Celaya
e0e522c3f5 Updated LocateShortUrlVisit listener so that it updates geolite db is needed 2019-07-20 11:21:00 +02:00
Alejandro Celaya
37e286df48 Created more tests 2019-07-20 10:47:12 +02:00
Alejandro Celaya
bc99ee6ebe Created EventListenerTaskTest 2019-07-19 21:16:09 +02:00
Alejandro Celaya
7e8126a421 Added AsyncEventListenerTest 2019-07-19 21:06:34 +02:00
Alejandro Celaya
af4ee8f7ec Created TaskRunnerTest 2019-07-19 20:59:06 +02:00
Alejandro Celaya
af40e8de5c Improved ListenerProviderFactoryTest 2019-07-19 20:28:56 +02:00
Alejandro Celaya
d086131630 Moved all event-dispatching stuff to its own module 2019-07-19 19:54:39 +02:00
Alejandro Celaya
bccc177414 Created task running system based on event listener which are transparently cast into tasks 2019-07-18 19:07:07 +02:00
Alejandro Celaya
0dfadcbb4a Added package to delegate the execution of event listeners to a swoole task worker 2019-07-14 10:46:31 +02:00
Alejandro Celaya
4380b62715 Fixed event handler not being properly registered as a service 2019-07-13 15:47:19 +02:00
Alejandro Celaya
91698034e7 Added event dispatcher to track when a short URL is visited 2019-07-13 12:04:21 +02:00
Alejandro Celaya
014eb2a924 Merge pull request #415 from acelaya/feature/get-meta
Feature/get meta
2019-07-08 19:01:22 +02:00
Alejandro Celaya
96357a57d2 Updated changelog 2019-07-08 18:51:20 +02:00
Alejandro Celaya
c7cfdffaf6 Documented new meta param on swagger docs 2019-07-08 18:42:53 +02:00
Alejandro Celaya
46a27a9d0a Added meta property to ShortUrlDataTransformer 2019-07-08 18:23:38 +02:00
Alejandro Celaya
35950a6294 Added release title to changelog 2019-05-13 20:07:33 +02:00
Alejandro Celaya
c104eee2b1 Merge pull request #408 from acelaya/feature/improve-logs
Renamed Swoole logger to Access logger
2019-05-13 19:30:39 +02:00
Alejandro Celaya
f0972c6220 Removed optional dependency constraints used for no longer support PHP versions 2019-05-13 19:21:59 +02:00
Alejandro Celaya
42a5145895 Renamed Swoole logger to Access logger 2019-05-13 19:16:14 +02:00
Alejandro Celaya
8d412e7d4c Merge pull request #407 from acelaya/feature/edit-patch
Feature/edit patch
2019-05-05 10:22:52 +02:00
Alejandro Celaya
f45e34cfcf Documented deprecated endpoint 2019-05-05 09:52:49 +02:00
Alejandro Celaya
320c8e2d6b Ensured accepted methods on CORS requests are dynamically fetched from route match when possible 2019-05-05 09:45:35 +02:00
Alejandro Celaya
988de0b96e Updated edit short URL endpoint to be used with patch instead of put 2019-05-05 09:21:57 +02:00
Alejandro Celaya
25a785dfa7 Merge pull request #404 from acelaya/feature/config-post-processor
Feature/config post processor
2019-04-18 10:59:50 +02:00
Alejandro Celaya
c993bbd993 Updated changelog 2019-04-18 10:47:26 +02:00
Alejandro Celaya
479760c0ee Created config post processor that parses a simplified config to what shlink expects 2019-04-18 10:37:38 +02:00
Alejandro Celaya
e186237410 Merge pull request #403 from acelaya/feature/tweaks
Removed superfluous option from command tester
2019-04-14 22:28:00 +02:00
Alejandro Celaya
4084e3f0d8 Removed superfluous option from command tester 2019-04-14 22:20:58 +02:00
Alejandro Celaya
dddf64031f Merge pull request #402 from acelaya/feature/update-db-on-process
Feature/update db on process
2019-04-14 18:15:40 +02:00
Alejandro Celaya
8f1477e893 Updated changelog 2019-04-14 18:07:23 +02:00
Alejandro Celaya
4866fe241e Updated LocateVisitsCommand to update the database if needed 2019-04-14 18:00:19 +02:00
Alejandro Celaya
6613cb5c60 Updated amount of days to wait for the GeoLite2 database to be updated 2019-04-14 13:18:03 +02:00
Alejandro Celaya
0f48dd567f Registered GeolocationDbUpdater service and added callable which is invoked when db is going to be updated 2019-04-14 11:19:21 +02:00
Alejandro Celaya
b24511b7b5 Created service that updated GeoLite database when it is older than 7 days 2019-04-14 10:54:01 +02:00
Alejandro Celaya
df40199134 Renamed common config files so that they have the same preffix 2019-04-14 10:25:32 +02:00
Alejandro Celaya
935562acc9 Created exception to handle cases in which downloading a new geolite db fails 2019-04-14 10:10:20 +02:00
Alejandro Celaya
feb67e76f0 Updated commands 2019-04-14 09:10:00 +02:00
Alejandro Celaya
fdbe93f0fb Merge pull request #401 from acelaya/feature/templates
Feature/templates
2019-04-14 09:07:04 +02:00
Alejandro Celaya
f27058e255 Updated lang files 2019-04-14 08:59:55 +02:00
Alejandro Celaya
6ddbbb4ba0 Restyled error templates and removed copyright 2019-04-14 08:57:48 +02:00
Alejandro Celaya
ef32f2c129 Merge pull request #400 from acelaya/feature/simplify-cache
Dropped support for all caches other than APCu and Array
2019-04-11 22:56:54 +02:00
Alejandro Celaya
760bb2db2a Removed redis from dockerfiles for dev 2019-04-11 22:39:55 +02:00
Alejandro Celaya
68f38fd9fe Dropped support for all caches other than APCu and Array 2019-04-11 22:36:50 +02:00
Alejandro Celaya
5c6829fb62 Merge pull request #398 from acelaya/feature/issue-template
Created issue template with some reminders
2019-04-11 22:11:21 +02:00
Alejandro Celaya
91c48919c6 Excluded gihub dir from build 2019-04-11 22:01:35 +02:00
Alejandro Celaya
72313800fa Created issue template with some reminders 2019-04-11 21:57:12 +02:00
Alejandro Celaya
478d5a16fd Merge pull request #395 from acelaya/feature/drop-php7.1
Feature/drop php7.1
2019-04-09 22:51:17 +02:00
Alejandro Celaya
b8909d8043 Updated changelog 2019-04-09 22:43:01 +02:00
Alejandro Celaya
c2c659b0fe Dropped support for PHP 7.1 2019-04-09 22:40:15 +02:00
Alejandro Celaya
20c3bde036 Merge pull request #387 from acelaya/feature/fix-check-exists
Feature/fix check exists
2019-03-30 08:04:44 +01:00
Alejandro Celaya
e77e37076f Updated changelog 2019-03-30 07:48:54 +01:00
Alejandro Celaya
734fdf83c1 Added test covering the case in which fetching existing short URLs, more than one result is found 2019-03-30 07:45:57 +01:00
Alejandro Celaya
2906d42f97 Updated how existing short URLs are checked, so that not only the first one matching the slug or url is checked 2019-03-30 07:36:57 +01:00
Alejandro Celaya
0135f205df Updated changelog 2019-03-17 17:54:57 +01:00
Alejandro Celaya
781c6e94a0 Merge pull request #381 from acelaya/feature/update-db-errors
Feature/update db errors
2019-03-16 11:25:32 +01:00
Alejandro Celaya
1d64dc8a26 Updated changelog 2019-03-16 11:11:39 +01:00
Alejandro Celaya
34ff831473 Added support to ignore errors in UpdateDbCommand 2019-03-16 11:08:12 +01:00
Alejandro Celaya
3734160cb4 Used phpcov v6 stable 2019-03-16 10:31:13 +01:00
Alejandro Celaya
21234cacfb Merge pull request #380 from acelaya/feature/reload-swoole
Feature/reload swoole
2019-03-16 10:29:13 +01:00
Alejandro Celaya
eb4dc85006 Updated to expressive swoole 2.4 2019-03-16 10:15:21 +01:00
Alejandro Celaya
249b8a4768 Added config to reload swoole during development 2019-03-16 09:57:09 +01:00
Alejandro Celaya
1a1868c7f4 Merge pull request #374 from acelaya/feature/migrations-v2
Feature/migrations v2
2019-03-09 18:54:51 +01:00
Alejandro Celaya
487659d5b4 Updated changelog 2019-03-09 18:47:58 +01:00
Alejandro Celaya
f46de4d3e1 Updated to doctrine migrations 2 2019-03-09 18:45:58 +01:00
Alejandro Celaya
6314315db7 Merge pull request #370 from acelaya/feature/extended-db-tests
Feature/extended db tests
2019-03-05 21:10:16 +01:00
Alejandro Celaya
a22beeed08 Replaced localhost name by 127.0.0.1 for databases when in travis 2019-03-05 21:01:52 +01:00
Alejandro Celaya
840e377245 Added execution of db tests with mysql and postgres to travis 2019-03-05 20:50:32 +01:00
Alejandro Celaya
6fa255386b Defined config to run database tests against mysql and postgres 2019-03-05 20:36:35 +01:00
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
207 changed files with 4655 additions and 929 deletions

6
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,6 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for a project to cover all use cases.
-->

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ vendor/
data/database.sqlite
data/shlink-tests.db
data/GeoLite2-City.mmdb
data/GeoLite2-City.mmdb.*
docs/swagger-ui*
docker-compose.override.yml
.phpunit.result.cache

View File

@@ -5,12 +5,14 @@ branches:
- /.*/
php:
- 7.1
- 7.2
- 7.3
services:
- mysql
- postgresql
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
@@ -19,8 +21,12 @@ install:
- composer self-update
- composer install --no-interaction
script:
before_script:
- mysql -e 'CREATE DATABASE shlink_test;'
- psql -c 'create database shlink_test;' -U postgres
- mkdir build
script:
- composer ci
after_success:
@@ -42,10 +48,10 @@ deploy:
skip_cleanup: true
on:
tags: true
php: 7.1
php: 7.2
- provider: script
script: bash data/travis/trigger_docker_build.sh
skip_cleanup: true
on:
tags: true
php: 7.1
php: 7.2

View File

@@ -4,6 +4,150 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## 1.18.0 - 2019-08-08
#### Added
* [#411](https://github.com/shlinkio/shlink/issues/411) Added new `meta` property on the `ShortUrl` REST API model.
These endpoints are affected and include the new property when suitable:
* `GET /short-urls` - List short URLs.
* `GET /short-urls/shorten` - Create a short URL (for integrations).
* `GET /short-urls/{shortCode}` - Get one short URL.
* `POST /short-urls` - Create short URL.
The property includes the values `validSince`, `validUntil` and `maxVisits` in a single object. All of them are nullable.
```json
{
"validSince": "2016-01-01T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
}
```
* [#285](https://github.com/shlinkio/shlink/issues/285) Visit location resolution is now done asynchronously but in real time thanks to swoole task management.
Now, when a short URL is visited, a task is enqueued to locate it. The user is immediately redirected to the long URL, and in the background, the visit is located, making stats to be available a couple of seconds after the visit without the requirement of cronjobs being run constantly.
Sadly, this feature is not enabled when serving shlink via apache/nginx, where you should still rely on cronjobs.
* [#384](https://github.com/shlinkio/shlink/issues/384) Improved how remote IP addresses are detected.
This new set of headers is now also inspected looking for the IP address:
* CF-Connecting-IP
* True-Client-IP
* X-Real-IP
* [#440](https://github.com/shlinkio/shlink/pull/440) Created `db:create` command, which improves how the shlink database is created, with these benefits:
* It sets up a lock which prevents the command to be run concurrently.
* It checks of the database does not exist, and creates it in that case.
* It checks if the database tables already exist, exiting gracefully in that case.
* [#442](https://github.com/shlinkio/shlink/pull/442) Created `db:migrate` command, which improves doctrine's migrations command by generating a lock, preventing it to be run concurrently.
#### Changed
* [#430](https://github.com/shlinkio/shlink/issues/430) Updated to [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) 1.2.2
* [#305](https://github.com/shlinkio/shlink/issues/305) Implemented changes which will allow Shlink to be truly clusterizable.
* [#262](https://github.com/shlinkio/shlink/issues/262) Increased mutation score to 75%.
### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#416](https://github.com/shlinkio/shlink/issues/416) Fixed error thrown when trying to locate visits after the GeoLite2 DB is downloaded for the first time.
* [#424](https://github.com/shlinkio/shlink/issues/424) Updated wkhtmltoimage to version 0.12.5
* [#427](https://github.com/shlinkio/shlink/issues/427) and [#434](https://github.com/shlinkio/shlink/issues/434) Fixed shlink being unusable after a database error on swoole contexts.
## 1.17.0 - 2019-05-13
#### Added
* [#377](https://github.com/shlinkio/shlink/issues/377) Updated `visit:locate` command (formerly `visit:process`) to automatically update the GeoLite2 database if it is too old or it does not exist.
This simplifies processing visits in a container-based infrastructure, since a fresh container is capable of getting an updated version of the file by itself.
It also removes the need of asynchronously and programmatically updating the file, which deprecates the `visit:update-db` command.
* [#373](https://github.com/shlinkio/shlink/issues/373) Added support for a simplified config. Specially useful to use with the docker container.
#### Changed
* [#56](https://github.com/shlinkio/shlink/issues/56) Simplified supported cache, requiring APCu always.
### Deprecated
* [#406](https://github.com/shlinkio/shlink/issues/406) Deprecated `PUT /short-urls/{shortCode}` REST endpoint in favor of `PATCH /short-urls/{shortCode}`.
#### Removed
* [#385](https://github.com/shlinkio/shlink/issues/385) Dropped support for PHP 7.1
* [#379](https://github.com/shlinkio/shlink/issues/379) Removed copyright from error templates.
#### Fixed
* *Nothing*
## 1.16.3 - 2019-03-30
#### Added
* *Nothing*
#### Changed
* [#153](https://github.com/shlinkio/shlink/issues/153) Updated to [doctrine/migrations](https://github.com/doctrine/migrations) version 2.0.0
* [#376](https://github.com/shlinkio/shlink/issues/376) Allowed `visit:update-db` command to not return an error exit code even if download fails, by passing the `-i` flag.
* [#341](https://github.com/shlinkio/shlink/issues/341) Improved database tests so that they are executed against all supported database engines.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#382](https://github.com/shlinkio/shlink/issues/382) Fixed existing short URLs not properly checked when providing the `findIfExists` flag.
## 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

View File

@@ -21,7 +21,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
First make sure the host where you are going to run shlink fulfills these requirements:
* PHP 7.1 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
* PHP 7.2 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
* MySQL, PostgreSQL or SQLite.
* The web server of your choice with PHP integration (Apache or Nginx recommended).
@@ -74,7 +74,7 @@ Despite how you built the project, you are going to need to install it now, by f
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
@@ -188,23 +188,23 @@ There are a couple of time-consuming tasks that shlink expects you to do manuall
Those tasks can be performed using shlink's CLI, so it should be easy to schedule them to be run in the background (for example, using cron jobs):
* Resolve IP address locations: `/path/to/shlink/bin/cli visit:process`
* **For shlink older than 1.18.0 or not using swoole as the web server**: Resolve IP address locations: `/path/to/shlink/bin/cli visit:locate`
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.*
* **For shlink older than v1.17.0**: Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db`
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.
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.
*Any of these 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
@@ -268,6 +268,9 @@ Available commands:
config
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
db
db:create Creates the database needed for shlink to work. It will do nothing if the database already exists
db:migrate Runs database migrations, which will ensure the shlink database is up to date.
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
@@ -281,8 +284,8 @@ Available commands:
tag:list Lists existing tags.
tag:rename Renames one existing tag.
visit
visit:process Processes visits where location is not set yet
visit:update-db Updates the GeoLite2 database file used to geolocate IP addresses
visit:locate [visit:process] Resolves visits origin locations.
visit:update-db [DEPRECATED] 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)

Binary file not shown.

View File

@@ -20,8 +20,14 @@ rsync -av * "${builtcontent}" \
--exclude=bin/test \
--exclude=data/infra \
--exclude=data/travis \
--exclude=data/cache/* \
--exclude=data/log/* \
--exclude=data/locks/* \
--exclude=data/proxies/* \
--exclude=data/migrations_template.txt \
--exclude=data/GeoLite2-City.mmdb \
--exclude=data/GeoLite2-City.* \
--exclude=data/database.sqlite \
--exclude=data/shlink-tests.db \
--exclude=**/.gitignore \
--exclude=CHANGELOG.md \
--exclude=composer.lock \
@@ -35,7 +41,8 @@ rsync -av * "${builtcontent}" \
--exclude=config/autoload/*local* \
--exclude=config/test \
--exclude=**/test* \
--exclude=build*
--exclude=build* \
--exclude=.github
cd "${builtcontent}"
# Install dependencies
@@ -46,7 +53,6 @@ ${composerBin} install --no-dev --optimize-autoloader --apcu-autoloader --no-pro
# Delete development files
echo 'Deleting dev files...'
rm composer.*
rm -f data/database.sqlite
# Update shlink version in config
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php

View File

@@ -12,7 +12,7 @@
}
],
"require": {
"php": "^7.1",
"php": "^7.2",
"ext-json": "*",
"ext-pdo": "*",
"acelaya/ze-content-based-error-handler": "^2.2",
@@ -20,7 +20,7 @@
"cakephp/chronos": "^1.2",
"cocur/slugify": "^3.0",
"doctrine/cache": "^1.6",
"doctrine/migrations": "^1.4",
"doctrine/migrations": "^2.0",
"doctrine/orm": "^2.5",
"endroid/qr-code": "^1.7",
"firebase/php-jwt": "^4.0",
@@ -29,50 +29,56 @@
"lstrojny/functional-php": "^1.8",
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"shlinkio/shlink-installer": "^1.1",
"symfony/console": "^4.2",
"symfony/filesystem": "^4.2",
"symfony/lock": "^4.2",
"symfony/process": "^4.2",
"ocramius/proxy-manager": "~2.2.2",
"phly/phly-event-dispatcher": "^1.0",
"predis/predis": "^1.1",
"shlinkio/shlink-installer": "^1.2.1",
"symfony/console": "^4.3",
"symfony/filesystem": "^4.3",
"symfony/lock": "^4.3",
"symfony/process": "^4.3",
"theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0",
"zendframework/zend-diactoros": "^2.1.1",
"zendframework/zend-expressive": "^3.0",
"zendframework/zend-config": "^3.3",
"zendframework/zend-config-aggregator": "^1.1",
"zendframework/zend-diactoros": "^2.1.3",
"zendframework/zend-expressive": "^3.2",
"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",
"zendframework/zend-servicemanager": "^3.2",
"zendframework/zend-stdlib": "^3.0"
"zendframework/zend-expressive-helpers": "^5.3",
"zendframework/zend-expressive-platesrenderer": "^2.1",
"zendframework/zend-expressive-swoole": "^2.4",
"zendframework/zend-i18n": "^2.9",
"zendframework/zend-inputfilter": "^2.10",
"zendframework/zend-paginator": "^2.8",
"zendframework/zend-servicemanager": "^3.4",
"zendframework/zend-stdlib": "^3.2"
},
"require-dev": {
"devster/ubench": "^2.0",
"doctrine/data-fixtures": "^1.3",
"filp/whoops": "^2.0",
"eaglewu/swoole-ide-helper": "dev-master",
"filp/whoops": "^2.4",
"infection/infection": "^0.12.2",
"phpstan/phpstan": "^0.11.2",
"phpunit/phpcov": "^6.0@dev || ^5.0",
"phpunit/phpunit": "^8.0 || ^7.5",
"phpunit/phpcov": "^6.0",
"phpunit/phpunit": "^8.3",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~1.1.0",
"symfony/dotenv": "^4.2",
"symfony/var-dumper": "^4.2",
"shlinkio/php-coding-standard": "~1.2.2",
"symfony/dotenv": "^4.3",
"symfony/var-dumper": "^4.3",
"zendframework/zend-component-installer": "^2.1",
"zendframework/zend-expressive-tooling": "^1.0"
"zendframework/zend-expressive-tooling": "^1.2"
},
"autoload": {
"psr-4": {
"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\\Common\\": "module/Common/src",
"Shlinkio\\Shlink\\EventDispatcher\\": "module/EventDispatcher/src"
},
"files": [
"module/Common/functions/functions.php"
"module/Common/functions/functions.php",
"module/EventDispatcher/functions/functions.php"
]
},
"autoload-dev": {
@@ -87,7 +93,8 @@
"ShlinkioTest\\Shlink\\Common\\": [
"module/Common/test",
"module/Common/test-db"
]
],
"ShlinkioTest\\Shlink\\EventDispatcher\\": "module/EventDispatcher/test"
}
},
"scripts": {
@@ -114,7 +121,14 @@
],
"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:db": [
"@test:db:sqlite",
"@test:db:mysql",
"@test:db:postgres"
],
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-db.xml --coverage-php build/coverage-db.cov --testdox",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:pretty": [
@@ -123,9 +137,9 @@
],
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --coverage-html build/coverage --order-by=random",
"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": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered",
"infect:ci": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --show-mutations",
"infect:test": [
"@test:unit:ci",
"@infect:ci"
@@ -141,7 +155,10 @@
"test:ci": "<fg=blue;options=bold>Runs all test suites, generating all needed reports and logs for CI envs</>",
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:db": "<fg=blue;options=bold>Runs database test suites (covering entity repositories)</>",
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL and PostgreSQL</>",
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
"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</>",

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
namespace Shlinkio\Shlink\Common;
return [

View File

@@ -36,4 +36,13 @@ return [
],
],
'installation_commands' => [
'db_create_schema' => [
'command' => 'bin/cli db:create',
],
'db_migrate' => [
'command' => 'bin/cli db:migrate',
],
],
];

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
return [
'ip_address_resolution' => [
'headers_to_inspect' => [
'CF-Connecting-IP',
'True-Client-IP',
'X-Real-IP',
'Forwarded',
'X-Forwarded-For',
'X-Forwarded',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
];

View File

@@ -1,6 +1,9 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Common\Cache\RedisFactory;
use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Symfony\Component\Lock;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
@@ -13,13 +16,28 @@ return [
'dependencies' => [
'factories' => [
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
Lock\Factory::class => ConfigAbstractFactory::class,
],
'aliases' => [
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
'lock_store' => Lock\Store\FlockStore::class,
'redis_lock_store' => Lock\Store\RedisStore::class,
],
'delegators' => [
Lock\Store\RedisStore::class => [
RetryLockStoreDelegatorFactory::class,
],
Lock\Factory::class => [
LoggerAwareDelegatorFactory::class,
],
],
],
ConfigAbstractFactory::class => [
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
Lock\Factory::class => [Lock\Store\FlockStore::class],
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
Lock\Factory::class => ['lock_store'],
],
];

View File

@@ -28,7 +28,7 @@ return [
'max_files' => 30,
'formatter' => 'dashed',
],
'swoole_access_handler' => [
'access_handler' => [
'class' => StreamHandler::class,
'level' => Logger::INFO,
'stream' => 'php://stdout',
@@ -49,24 +49,24 @@ return [
'handlers' => ['shlink_rotating_handler'],
'processors' => ['exception_with_new_line', 'psr3'],
],
'Swoole' => [
'handlers' => ['swoole_access_handler'],
'processors' => ['psr3'],
'Access' => [
'handlers' => ['access_handler'],
'processors' => ['exception_with_new_line', 'psr3'],
],
],
],
'dependencies' => [
'factories' => [
'Logger_Shlink' => Common\Factory\LoggerFactory::class,
'Logger_Swoole' => Common\Factory\LoggerFactory::class,
'Logger_Shlink' => Common\Logger\LoggerFactory::class,
'Logger_Access' => Common\Logger\LoggerFactory::class,
],
],
'zend-expressive-swoole' => [
'swoole-http-server' => [
'logger' => [
'logger-name' => 'Logger_Swoole',
'logger-name' => 'Logger_Access',
],
],
],

View File

@@ -1,16 +1,40 @@
<?php
declare(strict_types=1);
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
return [
$isSwoole = extension_loaded('swoole');
'logger' => [
'handlers' => [
'shlink_rotating_handler' => [
'level' => Logger::DEBUG,
],
// For swoole, send logs to standard output
$logger = $isSwoole ? [
'handlers' => [
'shlink_rotating_handler' => [
'level' => Logger::EMERGENCY, // This basically disables regular file logs
],
'shlink_stdout_handler' => [
'class' => StreamHandler::class,
'level' => Logger::DEBUG,
'stream' => 'php://stdout',
'formatter' => 'dashed',
],
],
'loggers' => [
'Shlink' => [
'handlers' => ['shlink_stdout_handler'],
],
],
] : [
'handlers' => [
'shlink_rotating_handler' => [
'level' => Logger::DEBUG,
],
],
];
return [
'logger' => $logger,
];

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
return [
'redis' => [
'servers' => 'tcp://shlink_redis:6379',
// 'servers' => [
// 'tcp://shlink_redis:6379',
// ],
],
'dependencies' => [
'aliases' => [
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
// 'lock_store' => 'redis_lock_store',
],
],
];

View File

@@ -9,6 +9,11 @@ return [
'swoole-http-server' => [
'host' => '0.0.0.0',
'process-name' => 'shlink',
'options' => [
'worker_num' => 16,
'task_worker_num' => 16,
],
],
],

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Zend\Expressive\Swoole\HotCodeReload\FileWatcher\InotifyFileWatcher;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'zend-expressive-swoole' => [
'hot-code-reload' => [
'enable' => true,
],
],
'dependencies' => [
'factories' => [
InotifyFileWatcher::class => InvokableFactory::class,
],
],
];

View File

@@ -20,9 +20,11 @@ return (new ConfigAggregator\ConfigAggregator([
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
EventDispatcher\ConfigProvider::class,
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();
: new ConfigAggregator\ZendConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
], 'data/cache/app_config.php', [
Core\SimplifiedConfigParser::class,
]))->getMergedConfig();

View File

@@ -20,7 +20,7 @@ $testHelper = $container->get(TestHelper::class);
$config = $container->get('config');
$em = $container->get(EntityManager::class);
$testHelper->createTestDb($config['entity_manager']['connection']['path']);
$testHelper->createTestDb();
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

@@ -15,7 +15,5 @@ if (! file_exists('.env')) {
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$config = $container->get('config');
$container->get(TestHelper::class)->createTestDb($config['entity_manager']['connection']['path']);
$container->get(TestHelper::class)->createTestDb();
DbTest\DatabaseTestCase::setEntityManager($container->get('em'));

View File

@@ -4,15 +4,53 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink;
use GuzzleHttp\Client;
use PDO;
use Zend\ConfigAggregator\ConfigAggregator;
use Zend\ServiceManager\Factory\InvokableFactory;
use function Shlinkio\Shlink\Common\env;
use function sprintf;
use function sys_get_temp_dir;
$swooleTestingHost = '127.0.0.1';
$swooleTestingPort = 9999;
$buildDbConnection = function () {
$driver = env('DB_DRIVER', 'sqlite');
$isCi = env('TRAVIS', false);
switch ($driver) {
case 'sqlite':
return [
'driver' => 'pdo_sqlite',
'path' => sys_get_temp_dir() . '/shlink-tests.db',
];
case 'mysql':
return [
'driver' => 'pdo_mysql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db',
'user' => 'root',
'password' => $isCi ? '' : 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
'driverOptions' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
],
];
case 'postgres':
return [
'driver' => 'pdo_pgsql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
'user' => 'postgres',
'password' => $isCi ? '' : 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
];
default:
return [];
}
};
return [
'debug' => true,
@@ -32,6 +70,8 @@ return [
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
'worker_num' => 1,
'task_worker_num' => 1,
],
],
],
@@ -49,11 +89,7 @@ return [
],
'entity_manager' => [
'connection' => [
'driver' => 'pdo_sqlite',
'path' => sys_get_temp_dir() . '/shlink-tests.db',
// 'path' => __DIR__ . '/../../data/shlink-tests.db',
],
'connection' => $buildDbConnection(),
],
'data_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

@@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

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,8 +1,6 @@
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"
@@ -28,27 +26,8 @@ RUN docker-php-ext-install zip
RUN apk add --no-cache --virtual libpng-dev
RUN docker-php-ext-install gd
# 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
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz

View File

@@ -1,10 +1,9 @@
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
ENV INOTIFY_VERSION 2.0.0
RUN apk update
@@ -27,27 +26,8 @@ RUN docker-php-ext-install zip
RUN apk add --no-cache --virtual libpng-dev
RUN docker-php-ext-install gd
# 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
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
@@ -73,6 +53,16 @@ RUN rm /tmp/apcu_bc.tar.gz
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 inotify extension
ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz
RUN mkdir -p /usr/src/php/ext/inotify\
&& tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1
# configure and install
RUN docker-php-ext-configure inotify\
&& docker-php-ext-install inotify
# cleanup
RUN rm /tmp/inotify.tar.gz
# 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 && \
@@ -94,7 +84,9 @@ WORKDIR /home/shlink
# Expose swoole port
EXPOSE 8080
CMD /usr/local/bin/composer update && \
CMD \
# Install dependencies if the vendor dir does not exist
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
# 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

View File

@@ -3,21 +3,24 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20160819142757 extends AbstractMigration
{
const MYSQL = 'mysql';
const SQLITE = 'sqlite';
private const MYSQL = 'mysql';
private const SQLITE = 'sqlite';
/**
* @param Schema $schema
* @throws DBALException
* @throws SchemaException
*/
public function up(Schema $schema)
public function up(Schema $schema): void
{
$db = $this->connection->getDatabasePlatform()->getName();
$table = $schema->getTable('short_urls');
@@ -31,9 +34,9 @@ class Version20160819142757 extends AbstractMigration
}
/**
* @param Schema $schema
* @throws DBALException
*/
public function down(Schema $schema)
public function down(Schema $schema): void
{
$db = $this->connection->getDatabasePlatform()->getName();
}

View File

@@ -3,19 +3,16 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Type;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20160820191203 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
public function up(Schema $schema): void
{
// Check if the tables already exist
$tables = $schema->getTables();
@@ -29,7 +26,7 @@ class Version20160820191203 extends AbstractMigration
$this->createShortUrlsInTagsTable($schema);
}
protected function createTagsTable(Schema $schema)
private function createTagsTable(Schema $schema): void
{
$table = $schema->createTable('tags');
$table->addColumn('id', Type::BIGINT, [
@@ -46,7 +43,7 @@ class Version20160820191203 extends AbstractMigration
$table->setPrimaryKey(['id']);
}
protected function createShortUrlsInTagsTable(Schema $schema)
private function createShortUrlsInTagsTable(Schema $schema): void
{
$table = $schema->createTable('short_urls_in_tags');
$table->addColumn('short_url_id', Type::BIGINT, [
@@ -70,10 +67,7 @@ class Version20160820191203 extends AbstractMigration
$table->setPrimaryKey(['short_url_id', 'tag_id']);
}
/**
* @param Schema $schema
*/
public function down(Schema $schema)
public function down(Schema $schema): void
{
$schema->dropTable('short_urls_in_tags');
$schema->dropTable('tags');

View File

@@ -3,10 +3,10 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Types\Type;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
@@ -14,10 +14,9 @@ use Doctrine\DBAL\Types\Type;
class Version20171021093246 extends AbstractMigration
{
/**
* @param Schema $schema
* @throws SchemaException
*/
public function up(Schema $schema)
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
if ($shortUrls->hasColumn('valid_since')) {
@@ -33,10 +32,9 @@ class Version20171021093246 extends AbstractMigration
}
/**
* @param Schema $schema
* @throws SchemaException
*/
public function down(Schema $schema)
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
if (! $shortUrls->hasColumn('valid_since')) {

View File

@@ -3,10 +3,10 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Types\Type;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
@@ -14,10 +14,9 @@ use Doctrine\DBAL\Types\Type;
class Version20171022064541 extends AbstractMigration
{
/**
* @param Schema $schema
* @throws SchemaException
*/
public function up(Schema $schema)
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
if ($shortUrls->hasColumn('max_visits')) {
@@ -31,10 +30,9 @@ class Version20171022064541 extends AbstractMigration
}
/**
* @param Schema $schema
* @throws SchemaException
*/
public function down(Schema $schema)
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
if (! $shortUrls->hasColumn('max_visits')) {

View File

@@ -1,4 +1,4 @@
version: '2'
version: '3'
services:
shlink_php:
@@ -12,3 +12,15 @@ services:
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,6 +23,8 @@ services:
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
links:
- shlink_db
- shlink_db_postgres
- shlink_redis
shlink_swoole:
container_name: shlink_swoole
@@ -37,12 +37,12 @@ services:
- ./:/home/shlink
links:
- shlink_db
- shlink_db_postgres
- shlink_redis
shlink_db:
container_name: shlink_db
build:
context: .
dockerfile: ./data/infra/db.Dockerfile
image: mysql:5.7
ports:
- "3307:3306"
volumes:
@@ -51,3 +51,22 @@ 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
shlink_redis:
container_name: shlink_redis
image: redis:5.0-alpine
ports:
- "6380:6379"

View File

@@ -29,6 +29,9 @@
},
"description": "A list of tags applied to this short URL"
},
"meta": {
"$ref": "./ShortUrlMeta.json"
},
"originalUrl": {
"deprecated": true,
"type": "string",

View File

@@ -0,0 +1,21 @@
{
"type": "object",
"required": ["validSince", "validUntil", "maxVisits"],
"properties": {
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string",
"nullable": true
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string",
"nullable": true
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number",
"nullable": true
}
}
}

View File

@@ -100,7 +100,12 @@
"tags": [
"games",
"tech"
]
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
}
},
{
"shortCode": "12Kb3",
@@ -110,7 +115,12 @@
"visitsCount": 1029,
"tags": [
"shlink"
]
],
"meta": {
"validSince": null,
"validUntil": null,
"maxVisits": null
}
},
{
"shortCode": "123bA",
@@ -118,7 +128,12 @@
"longUrl": "https://www.google.com",
"dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25,
"tags": []
"tags": [],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": null
}
}
],
"pagination": {
@@ -227,7 +242,12 @@
"tags": [
"games",
"tech"
]
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 500
}
}
}
},

View File

@@ -64,7 +64,12 @@
"tags": [
"games",
"tech"
]
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
}
},
"text/plain": "https://doma.in/abc123"
}

View File

@@ -44,7 +44,12 @@
"visitsCount": 1029,
"tags": [
"shlink"
]
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
}
}
}
},
@@ -81,7 +86,7 @@
}
},
"put": {
"patch": {
"operationId": "editShortUrl",
"tags": [
"Short URLs"
@@ -169,6 +174,95 @@
}
},
"put": {
"deprecated": true,
"operationId": "editShortUrlPut",
"tags": [
"Short URLs"
],
"summary": "[DEPRECATED] Edit short URL",
"description": "**[DEPRECATED]** Use [editShortUrl](#/Short_URLs/getShortUrl) instead",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to edit.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string"
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
}
}
}
}
}
},
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
],
"responses": {
"204": {
"description": "The short code has been properly updated."
},
"400": {
"description": "Provided meta arguments are invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
},
"delete": {
"operationId": "deleteShortUrl",
"tags": [

View File

@@ -35,7 +35,7 @@
"name": "X-Api-Key"
},
"Bearer": {
"description": "**[Deprecated]** The JWT identifying a previously authenticated API key",
"description": "**[DEPRECATED]** The JWT identifying a previously authenticated API key",
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
@@ -66,7 +66,7 @@
},
{
"name": "Authentication",
"description": "**[Deprecated]** Authentication-related endpoints"
"description": "**[DEPRECATED]** Authentication-related endpoints"
}
],

View File

@@ -14,7 +14,7 @@ return [
Command\ShortUrl\GeneratePreviewCommand::NAME => Command\ShortUrl\GeneratePreviewCommand::class,
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
Command\Visit\UpdateDbCommand::NAME => Command\Visit\UpdateDbCommand::class,
Command\Config\GenerateCharsetCommand::NAME => Command\Config\GenerateCharsetCommand::class,
@@ -28,6 +28,9 @@ return [
Command\Tag\CreateTagCommand::NAME => Command\Tag\CreateTagCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
],
],

View File

@@ -3,13 +3,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use Doctrine\DBAL\Connection;
use GeoIp2\Database\Reader;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Lock;
use Symfony\Component\Console as SymfonyCli;
use Symfony\Component\Lock\Factory as Locker;
use Symfony\Component\Process\PhpExecutableFinder;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
@@ -17,7 +22,11 @@ return [
'dependencies' => [
'factories' => [
Application::class => Factory\ApplicationFactory::class,
SymfonyCli\Application::class => Factory\ApplicationFactory::class,
SymfonyCli\Helper\ProcessHelper::class => Factory\ProcessHelperFactory::class,
PhpExecutableFinder::class => InvokableFactory::class,
GeolocationDbUpdater::class => ConfigAbstractFactory::class,
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
@@ -26,7 +35,7 @@ return [
Command\ShortUrl\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\UpdateDbCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateCharsetCommand::class => InvokableFactory::class,
@@ -40,10 +49,15 @@ return [
Command\Tag\CreateTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\DeleteTagsCommand::class => ConfigAbstractFactory::class,
Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
],
],
ConfigAbstractFactory::class => [
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, Locker::class],
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'],
@@ -51,10 +65,11 @@ return [
Command\ShortUrl\GeneratePreviewCommand::class => [Service\ShortUrlService::class, PreviewGenerator::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\ProcessVisitsCommand::class => [
Command\Visit\LocateVisitsCommand::class => [
Service\VisitService::class,
IpLocationResolverInterface::class,
Lock\Factory::class,
Locker::class,
GeolocationDbUpdater::class,
],
Command\Visit\UpdateDbCommand::class => [DbUpdater::class],
@@ -66,6 +81,19 @@ return [
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class],
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class],
Command\Db\CreateDatabaseCommand::class => [
Locker::class,
SymfonyCli\Helper\ProcessHelper::class,
PhpExecutableFinder::class,
Connection::class,
NoDbNameConnectionFactory::SERVICE_NAME,
],
Command\Db\MigrateDatabaseCommand::class => [
Locker::class,
SymfonyCli\Helper\ProcessHelper::class,
PhpExecutableFinder::class,
],
],
];

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\Factory as Locker;
use Symfony\Component\Process\PhpExecutableFinder;
use function array_unshift;
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
{
/** @var ProcessHelper */
private $processHelper;
/** @var string */
private $phpBinary;
public function __construct(Locker $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder)
{
parent::__construct($locker);
$this->processHelper = $processHelper;
$this->phpBinary = $phpFinder->find(false) ?: 'php';
}
protected function runPhpCommand(OutputInterface $output, array $command): void
{
array_unshift($command, $this->phpBinary);
$this->processHelper->run($output, $command, null, null, $output->getVerbosity());
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\Factory as Locker;
use Symfony\Component\Process\PhpExecutableFinder;
use function Functional\contains;
class CreateDatabaseCommand extends AbstractDatabaseCommand
{
public const NAME = 'db:create';
public const DOCTRINE_HELPER_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
public const DOCTRINE_HELPER_COMMAND = 'orm:schema-tool:create';
/** @var Connection */
private $regularConn;
/** @var Connection */
private $noDbNameConn;
public function __construct(
Locker $locker,
ProcessHelper $processHelper,
PhpExecutableFinder $phpFinder,
Connection $conn,
Connection $noDbNameConn
) {
parent::__construct($locker, $processHelper, $phpFinder);
$this->regularConn = $conn;
$this->noDbNameConn = $noDbNameConn;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription(
'Creates the database needed for shlink to work. It will do nothing if the database already exists'
);
}
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->checkDbExists();
if ($this->schemaExists()) {
$io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.');
return ExitCodes::EXIT_SUCCESS;
}
// Create database
$io->writeln('<fg=blue>Creating database tables...</>');
$this->runPhpCommand($output, [self::DOCTRINE_HELPER_SCRIPT, self::DOCTRINE_HELPER_COMMAND]);
$io->success('Database properly created!');
return ExitCodes::EXIT_SUCCESS;
}
private function checkDbExists(): void
{
if ($this->regularConn->getDatabasePlatform()->getName() === 'sqlite') {
return;
}
// In order to create the new database, we have to use a connection where the dbname was not set.
// Otherwise, it will fail to connect and will not be able to create the new database
$schemaManager = $this->noDbNameConn->getSchemaManager();
$databases = $schemaManager->listDatabases();
$shlinkDatabase = $this->regularConn->getDatabase();
if (! contains($databases, $shlinkDatabase)) {
$schemaManager->createDatabase($shlinkDatabase);
}
}
private function schemaExists(): bool
{
// If at least one of the shlink tables exist, we will consider the database exists somehow.
// Any inconsistency will be taken care by the migrations
$schemaManager = $this->regularConn->getSchemaManager();
return ! empty($schemaManager->listTableNames());
}
protected function getLockConfig(): LockedCommandConfig
{
return new LockedCommandConfig($this->getName(), true);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class MigrateDatabaseCommand extends AbstractDatabaseCommand
{
public const NAME = 'db:migrate';
public const DOCTRINE_HELPER_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
public const DOCTRINE_HELPER_COMMAND = 'migrations:migrate';
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Runs database migrations, which will ensure the shlink database is up to date.');
}
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->writeln('<fg=blue>Migrating database...</>');
$this->runPhpCommand($output, [self::DOCTRINE_HELPER_SCRIPT, self::DOCTRINE_HELPER_COMMAND]);
$io->success('Database properly migrated!');
return ExitCodes::EXIT_SUCCESS;
}
protected function getLockConfig(): LockedCommandConfig
{
return new LockedCommandConfig($this->getName(), true);
}
}

View File

@@ -17,6 +17,8 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Paginator\Paginator;
use function array_flip;
use function array_intersect_key;
use function array_values;
use function count;
use function explode;
@@ -29,6 +31,14 @@ class ListShortUrlsCommand extends Command
public const NAME = 'short-url:list';
private const ALIASES = ['shortcode:list', 'short-code:list'];
private const COLUMNS_WHITELIST = [
'shortCode',
'shortUrl',
'longUrl',
'dateCreated',
'visitsCount',
'tags',
];
/** @var ShortUrlServiceInterface */
private $shortUrlService;
@@ -125,8 +135,7 @@ class ListShortUrlsCommand extends Command
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = array_values($shortUrl);
$rows[] = array_values(array_intersect_key($shortUrl, array_flip(self::COLUMNS_WHITELIST)));
}
ShlinkTable::fromOutput($output)->render($headers, $rows, $this->formatCurrentPageMessage(

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\Factory as Locker;
use function sprintf;
abstract class AbstractLockedCommand extends Command
{
/** @var Locker */
private $locker;
public function __construct(Locker $locker)
{
parent::__construct();
$this->locker = $locker;
}
final protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$lockConfig = $this->getLockConfig();
$lock = $this->locker->createLock($lockConfig->lockName(), $lockConfig->ttl(), $lockConfig->isBlocking());
if (! $lock->acquire($lockConfig->isBlocking())) {
$output->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName())
);
return ExitCodes::EXIT_WARNING;
}
try {
return $this->lockedExecute($input, $output);
} finally {
$lock->release();
}
}
abstract protected function lockedExecute(InputInterface $input, OutputInterface $output): int;
abstract protected function getLockConfig(): LockedCommandConfig;
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
final class LockedCommandConfig
{
private const DEFAULT_TTL = 90.0; // 1.5 minutes
/** @var string */
private $lockName;
/** @var bool */
private $isBlocking;
/** @var float */
private $ttl;
public function __construct(string $lockName, bool $isBlocking = false, float $ttl = self::DEFAULT_TTL)
{
$this->lockName = $lockName;
$this->isBlocking = $isBlocking;
$this->ttl = $ttl;
}
public function lockName(): string
{
return $this->lockName;
}
public function isBlocking(): bool
{
return $this->isBlocking;
}
public function ttl(): float
{
return $this->ttl;
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Exception;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
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\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\Factory as Locker;
use Throwable;
use function sprintf;
class LocateVisitsCommand extends AbstractLockedCommand
{
public const NAME = 'visit:locate';
public const ALIASES = ['visit:process'];
/** @var VisitServiceInterface */
private $visitService;
/** @var IpLocationResolverInterface */
private $ipLocationResolver;
/** @var GeolocationDbUpdaterInterface */
private $dbUpdater;
/** @var SymfonyStyle */
private $io;
/** @var ProgressBar */
private $progressBar;
public function __construct(
VisitServiceInterface $visitService,
IpLocationResolverInterface $ipLocationResolver,
Locker $locker,
GeolocationDbUpdaterInterface $dbUpdater
) {
parent::__construct($locker);
$this->visitService = $visitService;
$this->ipLocationResolver = $ipLocationResolver;
$this->dbUpdater = $dbUpdater;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription('Resolves visits origin locations.');
}
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
{
$this->io = new SymfonyStyle($input, $output);
try {
$this->checkDbUpdate();
$this->visitService->locateUnlocatedVisits(
[$this, 'getGeolocationDataForVisit'],
static function (VisitLocation $location) use ($output) {
if (!$location->isEmpty()) {
$output->writeln(
sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName())
);
}
}
);
$this->io->success('Finished processing all IPs');
return ExitCodes::EXIT_SUCCESS;
} catch (Throwable $e) {
$this->io->error($e->getMessage());
if ($e instanceof Exception && $this->io->isVerbose()) {
$this->getApplication()->renderException($e, $this->io);
}
return ExitCodes::EXIT_FAILURE;
}
}
public function getGeolocationDataForVisit(Visit $visit): Location
{
if (! $visit->hasRemoteAddr()) {
$this->io->writeln(
'<comment>Ignored visit with no IP address</comment>',
OutputInterface::VERBOSITY_VERBOSE
);
throw IpCannotBeLocatedException::forEmptyAddress();
}
$ipAddr = $visit->getRemoteAddr();
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
throw IpCannotBeLocatedException::forLocalhost();
}
try {
return $this->ipLocationResolver->resolveIpLocation($ipAddr);
} catch (WrongIpException $e) {
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
if ($this->io->isVerbose()) {
$this->getApplication()->renderException($e, $this->io);
}
throw IpCannotBeLocatedException::forError($e);
}
}
private function checkDbUpdate(): void
{
try {
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) {
$this->io->writeln(
sprintf('<fg=blue>%s GeoLite2 database...</>', $olderDbExists ? 'Updating' : 'Downloading')
);
$this->progressBar = new ProgressBar($this->io);
}, function (int $total, int $downloaded) {
$this->progressBar->setMaxSteps($total);
$this->progressBar->setProgress($downloaded);
});
if ($this->progressBar !== null) {
$this->progressBar->finish();
$this->io->newLine();
}
} catch (GeolocationDbUpdateFailedException $e) {
if (! $e->olderDbExists()) {
$this->io->error('GeoLite2 database download failed. It is not possible to locate visits.');
throw $e;
}
$this->io->newLine();
$this->io->writeln(
'<fg=yellow;options=bold>[Warning] GeoLite2 database update failed. Proceeding with old version.</>'
);
}
}
protected function getLockConfig(): LockedCommandConfig
{
return new LockedCommandConfig($this->getName());
}
}

View File

@@ -1,112 +0,0 @@
<?php
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\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 Symfony\Component\Lock\Factory as Locker;
use function sprintf;
class ProcessVisitsCommand extends Command
{
public const NAME = 'visit:process';
/** @var VisitServiceInterface */
private $visitService;
/** @var IpLocationResolverInterface */
private $ipLocationResolver;
/** @var Locker */
private $locker;
/** @var OutputInterface */
private $output;
public function __construct(
VisitServiceInterface $visitService,
IpLocationResolverInterface $ipLocationResolver,
Locker $locker
) {
parent::__construct();
$this->visitService = $visitService;
$this->ipLocationResolver = $ipLocationResolver;
$this->locker = $locker;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Processes visits where location is not set yet');
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$this->output = $output;
$io = new SymfonyStyle($input, $output);
$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;
}
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

@@ -9,9 +9,13 @@ 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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
/** @deprecated */
class UpdateDbCommand extends Command
{
public const NAME = 'visit:update-db';
@@ -29,10 +33,16 @@ class UpdateDbCommand extends Command
{
$this
->setName(self::NAME)
->setDescription('Updates the GeoLite2 database file used to geolocate IP addresses')
->setDescription('[DEPRECATED] 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'
)
->addOption(
'ignoreErrors',
'i',
InputOption::VALUE_NONE,
'Makes the command success even iof the update fails.'
);
}
@@ -49,19 +59,32 @@ class UpdateDbCommand extends Command
});
$progressBar->finish();
$io->writeln('');
$io->newLine();
$io->success('GeoLite2 database properly updated');
return ExitCodes::EXIT_SUCCESS;
} catch (RuntimeException $e) {
$progressBar->finish();
$io->writeln('');
$io->newLine();
$io->error('An error occurred while updating GeoLite2 database');
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
return ExitCodes::EXIT_FAILURE;
return $this->handleError($e, $io, $input);
}
}
private function handleError(RuntimeException $e, SymfonyStyle $io, InputInterface $input): int
{
$ignoreErrors = $input->getOption('ignoreErrors');
$baseErrorMsg = 'An error occurred while updating GeoLite2 database';
if ($ignoreErrors) {
$io->warning(sprintf('%s, but it was ignored', $baseErrorMsg));
return ExitCodes::EXIT_SUCCESS;
}
$io->error($baseErrorMsg);
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $io);
}
return ExitCodes::EXIT_FAILURE;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Exception;
use Throwable;
interface ExceptionInterface extends Throwable
{
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Exception;
use RuntimeException;
use Throwable;
class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface
{
/** @var bool */
private $olderDbExists;
public function __construct(bool $olderDbExists, string $message = '', int $code = 0, ?Throwable $previous = null)
{
$this->olderDbExists = $olderDbExists;
parent::__construct($message, $code, $previous);
}
public static function create(bool $olderDbExists, ?Throwable $prev = null): self
{
return new self(
$olderDbExists,
'An error occurred while updating geolocation database, and an older version could not be found',
0,
$prev
);
}
public function olderDbExists(): bool
{
return $this->olderDbExists;
}
}

View File

@@ -4,32 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Psr\Container\ContainerExceptionInterface;
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\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class ApplicationFactory implements FactoryInterface
class ApplicationFactory
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return CliApp
* @throws NotFoundExceptionInterface
* @throws ContainerExceptionInterface
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): CliApp
public function __invoke(ContainerInterface $container): CliApp
{
$config = $container->get('config')['cli'];
$appOptions = $container->get(AppOptions::class);

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Factory;
use Symfony\Component\Console\Helper;
class ProcessHelperFactory
{
public function __invoke(): Helper\ProcessHelper
{
$processHelper = new Helper\ProcessHelper();
$processHelper->setHelperSet(new Helper\HelperSet([
new Helper\FormatterHelper(),
new Helper\DebugFormatterHelper(),
]));
return $processHelper;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock\Factory as Locker;
use Throwable;
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
private const LOCK_NAME = 'geolocation-db-update';
/** @var DbUpdaterInterface */
private $dbUpdater;
/** @var Reader */
private $geoLiteDbReader;
/** @var Locker */
private $locker;
public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, Locker $locker)
{
$this->dbUpdater = $dbUpdater;
$this->geoLiteDbReader = $geoLiteDbReader;
$this->locker = $locker;
}
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void
{
$lock = $this->locker->createLock(self::LOCK_NAME);
$lock->acquire(true); // Block until lock is released
try {
$this->downloadIfNeeded($mustBeUpdated, $handleProgress);
} catch (Throwable $e) {
throw $e;
} finally {
$lock->release();
}
}
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadIfNeeded(?callable $mustBeUpdated, ?callable $handleProgress): void
{
if (! $this->dbUpdater->databaseFileExists()) {
$this->downloadNewDb(false, $mustBeUpdated, $handleProgress);
return;
}
$meta = $this->geoLiteDbReader->metadata();
if ($this->buildIsTooOld($meta->__get('buildEpoch'))) {
$this->downloadNewDb(true, $mustBeUpdated, $handleProgress);
}
}
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadNewDb(bool $olderDbExists, ?callable $mustBeUpdated, ?callable $handleProgress): void
{
if ($mustBeUpdated !== null) {
$mustBeUpdated($olderDbExists);
}
try {
$this->dbUpdater->downloadFreshCopy($handleProgress);
} catch (RuntimeException $e) {
throw GeolocationDbUpdateFailedException::create($olderDbExists, $e);
}
}
private function buildIsTooOld(int $buildTimestamp): bool
{
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
$now = Chronos::now();
return $now->gt($buildDate->addDays(35));
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
interface GeolocationDbUpdaterInterface
{
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void;
}

View File

@@ -34,7 +34,6 @@ class DisableKeyCommandTest extends TestCase
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
@@ -49,7 +48,6 @@ class DisableKeyCommandTest extends TestCase
$disable = $this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class);
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();

View File

@@ -34,9 +34,7 @@ class GenerateKeyCommandTest extends TestCase
{
$create = $this->apiKeyService->create(null)->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Generated API key: ', $output);
@@ -49,7 +47,6 @@ class GenerateKeyCommandTest extends TestCase
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
'--expirationDate' => '2016-01-01',
]);
}

View File

@@ -31,9 +31,7 @@ class GenerateCharsetCommandTest extends TestCase
{
$prefix = 'Character set: ';
$this->commandTester->execute([
'command' => 'config:generate-charset',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
// Both default character set and the new one should have the same length

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\Factory as Locker;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder;
class CreateDatabaseCommandTest extends TestCase
{
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $processHelper;
/** @var ObjectProphecy */
private $regularConn;
/** @var ObjectProphecy */
private $noDbNameConn;
/** @var ObjectProphecy */
private $schemaManager;
/** @var ObjectProphecy */
private $databasePlatform;
public function setUp(): void
{
$locker = $this->prophesize(Locker::class);
$lock = $this->prophesize(LockInterface::class);
$lock->acquire(Argument::any())->willReturn(true);
$lock->release()->will(function () {
});
$locker->createLock(Argument::cetera())->willReturn($lock->reveal());
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
$this->processHelper = $this->prophesize(ProcessHelper::class);
$this->schemaManager = $this->prophesize(AbstractSchemaManager::class);
$this->databasePlatform = $this->prophesize(AbstractPlatform::class);
$this->regularConn = $this->prophesize(Connection::class);
$this->regularConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
$this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal());
$this->noDbNameConn = $this->prophesize(Connection::class);
$this->noDbNameConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
$command = new CreateDatabaseCommand(
$locker->reveal(),
$this->processHelper->reveal(),
$phpExecutableFinder->reveal(),
$this->regularConn->reveal(),
$this->noDbNameConn->reveal()
);
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/** @test */
public function successMessageIsPrintedIfDatabaseAlreadyExists(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function () {
});
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Database already exists. Run "db:migrate" command', $output);
$getDatabase->shouldHaveBeenCalledOnce();
$listDatabases->shouldHaveBeenCalledOnce();
$createDatabase->shouldNotHaveBeenCalled();
$listTables->shouldHaveBeenCalledOnce();
}
/** @test */
public function databaseIsCreatedIfItDoesNotExist(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function () {
});
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
$this->commandTester->execute([]);
$getDatabase->shouldHaveBeenCalledOnce();
$listDatabases->shouldHaveBeenCalledOnce();
$createDatabase->shouldHaveBeenCalledOnce();
$listTables->shouldHaveBeenCalledOnce();
}
/** @test */
public function tablesAreCreatedIfDatabaseIsEmpty(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function () {
});
$listTables = $this->schemaManager->listTableNames()->willReturn([]);
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
'/usr/local/bin/php',
CreateDatabaseCommand::DOCTRINE_HELPER_SCRIPT,
CreateDatabaseCommand::DOCTRINE_HELPER_COMMAND,
], Argument::cetera());
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Creating database tables...', $output);
$this->assertStringContainsString('Database properly created!', $output);
$getDatabase->shouldHaveBeenCalledOnce();
$listDatabases->shouldHaveBeenCalledOnce();
$createDatabase->shouldNotHaveBeenCalled();
$listTables->shouldHaveBeenCalledOnce();
$runCommand->shouldHaveBeenCalledOnce();
}
/** @test */
public function databaseCheckIsSkippedForSqlite(): void
{
$this->databasePlatform->getName()->willReturn('sqlite');
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function () {
});
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
$this->commandTester->execute([]);
$getDatabase->shouldNotHaveBeenCalled();
$listDatabases->shouldNotHaveBeenCalled();
$createDatabase->shouldNotHaveBeenCalled();
$listTables->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Db;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\Factory as Locker;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder;
class MigrateDatabaseCommandTest extends TestCase
{
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $processHelper;
public function setUp(): void
{
$locker = $this->prophesize(Locker::class);
$lock = $this->prophesize(LockInterface::class);
$lock->acquire(Argument::any())->willReturn(true);
$lock->release()->will(function () {
});
$locker->createLock(Argument::cetera())->willReturn($lock->reveal());
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
$this->processHelper = $this->prophesize(ProcessHelper::class);
$command = new MigrateDatabaseCommand(
$locker->reveal(),
$this->processHelper->reveal(),
$phpExecutableFinder->reveal()
);
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
* @dataProvider provideVerbosities
*/
public function migrationsCommandIsRunWithProperVerbosity(int $verbosity): void
{
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
'/usr/local/bin/php',
MigrateDatabaseCommand::DOCTRINE_HELPER_SCRIPT,
MigrateDatabaseCommand::DOCTRINE_HELPER_COMMAND,
], null, null, $verbosity);
$this->commandTester->execute([], [
'verbosity' => $verbosity,
]);
$output = $this->commandTester->getDisplay();
if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) {
$this->assertStringContainsString('Migrating database...', $output);
$this->assertStringContainsString('Database properly migrated!', $output);
}
$runCommand->shouldHaveBeenCalledOnce();
}
public function provideVerbosities(): iterable
{
yield 'debug' => [OutputInterface::VERBOSITY_DEBUG];
yield 'normal' => [OutputInterface::VERBOSITY_NORMAL];
yield 'quiet' => [OutputInterface::VERBOSITY_QUIET];
yield 'verbose' => [OutputInterface::VERBOSITY_VERBOSE];
yield 'very verbose' => [OutputInterface::VERBOSITY_VERY_VERBOSE];
}
}

View File

@@ -12,11 +12,11 @@ use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use const PHP_EOL;
use function array_pop;
use function sprintf;
use const PHP_EOL;
class DeleteShortUrlCommandTest extends TestCase
{
/** @var CommandTester */

View File

@@ -54,9 +54,7 @@ class GeneratePreviewCommandTest extends TestCase
$generatePreview2 = $this->previewGenerator->generatePreview('https://bar.com')->willReturn('');
$generatePreview3 = $this->previewGenerator->generatePreview('http://baz.com/something')->willReturn('');
$this->commandTester->execute([
'command' => 'shortcode:process-previews',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Processing URL http://foo.com', $output);
@@ -81,9 +79,7 @@ class GeneratePreviewCommandTest extends TestCase
$this->previewGenerator->generatePreview(Argument::any())->willThrow(PreviewGenerationException::class)
->shouldBeCalledTimes(count($items));
$this->commandTester->execute([
'command' => 'shortcode:process-previews',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(count($items), substr_count($output, 'Error'));
}

View File

@@ -42,7 +42,6 @@ class GenerateShortUrlCommandTest extends TestCase
);
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--maxVisits' => '3',
]);
@@ -58,10 +57,7 @@ class GenerateShortUrlCommandTest extends TestCase
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/invalid',
]);
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
'Provided URL "http://domain.com/invalid" is invalid.',
@@ -82,7 +78,6 @@ class GenerateShortUrlCommandTest extends TestCase
)->willReturn((new ShortUrl(''))->setShortCode('abc123'));
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--tags' => ['foo,bar', 'baz', 'boo,zar,baz'],
]);

View File

@@ -45,10 +45,7 @@ class GetVisitsCommandTest extends TestCase
new Paginator(new ArrayAdapter([]))
)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
}
/** @test */
@@ -65,7 +62,6 @@ class GetVisitsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
'--startDate' => $startDate,
'--endDate' => $endDate,
@@ -84,10 +80,7 @@ class GetVisitsCommandTest extends TestCase
]))
)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('foo', $output);
$this->assertStringContainsString('Spain', $output);

View File

@@ -37,7 +37,7 @@ class ListShortUrlsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$this->commandTester->execute([]);
}
/** @test */
@@ -54,7 +54,7 @@ class ListShortUrlsCommandTest extends TestCase
})->shouldBeCalledTimes(3);
$this->commandTester->setInputs(['y', 'y', 'n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Continue with page 2?', $output);
@@ -75,7 +75,7 @@ class ListShortUrlsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('url_1', $output);
@@ -95,10 +95,7 @@ class ListShortUrlsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--page' => $page,
]);
$this->commandTester->execute(['--page' => $page]);
}
/** @test */
@@ -108,10 +105,7 @@ class ListShortUrlsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--showTags' => true,
]);
$this->commandTester->execute(['--showTags' => true]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Tags', $output);
}

View File

@@ -41,10 +41,7 @@ class ResolveUrlCommandTest extends TestCase
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}
@@ -56,10 +53,7 @@ class ResolveUrlCommandTest extends TestCase
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Provided short code "' . $shortCode . '" could not be found.', $output);
}
@@ -71,10 +65,7 @@ class ResolveUrlCommandTest extends TestCase
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException())
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Provided short code "' . $shortCode . '" has an invalid format.', $output);
}

View File

@@ -6,7 +6,9 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
@@ -24,7 +26,7 @@ use Symfony\Component\Lock;
use function array_shift;
use function sprintf;
class ProcessVisitsCommandTest extends TestCase
class LocateVisitsCommandTest extends TestCase
{
/** @var CommandTester */
private $commandTester;
@@ -36,23 +38,27 @@ class ProcessVisitsCommandTest extends TestCase
private $locker;
/** @var ObjectProphecy */
private $lock;
/** @var ObjectProphecy */
private $dbUpdater;
public function setUp(): void
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->locker = $this->prophesize(Lock\Factory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire()->willReturn(true);
$this->lock->acquire(false)->willReturn(true);
$this->lock->release()->will(function () {
});
$this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$this->locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
$command = new ProcessVisitsCommand(
$command = new LocateVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
$this->locker->reveal()
$this->locker->reveal(),
$this->dbUpdater->reveal()
);
$app = new Application();
$app->add($command);
@@ -79,9 +85,7 @@ class ProcessVisitsCommandTest extends TestCase
Location::emptyInstance()
);
$this->commandTester->execute([
'command' => 'visit:process',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
@@ -111,9 +115,7 @@ class ProcessVisitsCommandTest extends TestCase
Location::emptyInstance()
);
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString($message, $output);
@@ -150,9 +152,7 @@ class ProcessVisitsCommandTest extends TestCase
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
@@ -162,24 +162,59 @@ class ProcessVisitsCommandTest extends TestCase
}
/** @test */
public function noActionIsPerformedIfLockIsAcquired()
public function noActionIsPerformedIfLockIsAcquired(): void
{
$this->lock->acquire()->willReturn(false);
$this->lock->acquire(false)->willReturn(false);
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function () {
});
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
sprintf('There is already an instance of the "%s" command', ProcessVisitsCommand::NAME),
sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME),
$output
);
$locateVisits->shouldNotHaveBeenCalled();
$resolveIpLocation->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideParams
*/
public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void
{
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function () {
});
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($olderDbExists) {
[$mustBeUpdated, $handleProgress] = $args;
$mustBeUpdated($olderDbExists);
$handleProgress(100, 50);
throw GeolocationDbUpdateFailedException::create($olderDbExists);
}
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'),
$output
);
$this->assertStringContainsString($expectedMessage, $output);
$locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
$checkDbUpdate->shouldHaveBeenCalledOnce();
}
public function provideParams(): iterable
{
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
}
}

View File

@@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\UpdateDbCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Console\Application;
@@ -31,27 +32,45 @@ class UpdateDbCommandTest extends TestCase
}
/** @test */
public function successMessageIsPrintedIfEverythingWorks()
public function successMessageIsPrintedIfEverythingWorks(): void
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->will(function () {
});
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
$this->assertStringContainsString('GeoLite2 database properly updated', $output);
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
$download->shouldHaveBeenCalledOnce();
}
/** @test */
public function errorMessageIsPrintedIfAnExceptionIsThrown()
public function errorMessageIsPrintedIfAnExceptionIsThrown(): void
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->willThrow(RuntimeException::class);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
$this->assertStringContainsString('An error occurred while updating GeoLite2 database', $output);
$this->assertEquals(ExitCodes::EXIT_FAILURE, $exitCode);
$download->shouldHaveBeenCalledOnce();
}
/** @test */
public function warningMessageIsPrintedIfAnExceptionIsThrownAndErrorsAreIgnored(): void
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->willThrow(RuntimeException::class);
$this->commandTester->execute(['--ignoreErrors' => true]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
$this->assertStringContainsString('ignored', $output);
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
$download->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Exception;
use Exception;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Throwable;
class GeolocationDbUpdateFailedExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideOlderDbExists
*/
public function constructCreatesExceptionWithDefaultArgs(bool $olderDbExists): void
{
$e = new GeolocationDbUpdateFailedException($olderDbExists);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals('', $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertNull($e->getPrevious());
}
public function provideOlderDbExists(): iterable
{
yield 'with older DB' => [true];
yield 'without older DB' => [false];
}
/**
* @test
* @dataProvider provideConstructorArgs
*/
public function constructCreatesException(bool $olderDbExists, string $message, int $code, ?Throwable $prev): void
{
$e = new GeolocationDbUpdateFailedException($olderDbExists, $message, $code, $prev);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals($message, $e->getMessage());
$this->assertEquals($code, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
}
public function provideConstructorArgs(): iterable
{
yield [true, 'This is a nice error message', 99, new Exception('prev')];
yield [false, 'Another message', 0, new RuntimeException('prev')];
yield [true, 'An yet another message', -50, null];
}
/**
* @test
* @dataProvider provideCreateArgs
*/
public function createBuildsException(bool $olderDbExists, ?Throwable $prev): void
{
$e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals(
'An error occurred while updating geolocation database, and an older version could not be found',
$e->getMessage()
);
$this->assertEquals(0, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
}
public function provideCreateArgs(): iterable
{
yield 'older DB and no prev' => [true, null];
yield 'older DB and prev' => [true, new RuntimeException('prev')];
yield 'no older DB and no prev' => [false, null];
yield 'no older DB and prev' => [false, new Exception('prev')];
}
}

View File

@@ -25,14 +25,7 @@ class ApplicationFactoryTest extends TestCase
}
/** @test */
public function serviceIsCreated()
{
$instance = ($this->factory)($this->createServiceManager(), '');
$this->assertInstanceOf(Application::class, $instance);
}
/** @test */
public function allCommandsWhichAreServicesAreAdded()
public function allCommandsWhichAreServicesAreAdded(): void
{
$sm = $this->createServiceManager([
'commands' => [
@@ -45,8 +38,7 @@ class ApplicationFactoryTest extends TestCase
$sm->setService('bar', $this->createCommandMock('bar')->reveal());
/** @var Application $instance */
$instance = ($this->factory)($sm, '');
$this->assertInstanceOf(Application::class, $instance);
$instance = ($this->factory)($sm);
$this->assertTrue($instance->has('foo'));
$this->assertTrue($instance->has('bar'));

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Factory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
class ProcessHelperFactoryTest extends TestCase
{
/** @var ProcessHelperFactory */
private $factory;
public function setUp(): void
{
$this->factory = new ProcessHelperFactory();
}
/** @test */
public function createsTheServiceWithTheProperSetOfHelpers(): void
{
$processHelper = ($this->factory)();
$helperSet = $processHelper->getHelperSet();
$this->assertCount(2, $helperSet);
$this->assertTrue($helperSet->has('formatter'));
$this->assertTrue($helperSet->has('debug_formatter'));
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Util;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock;
use Throwable;
use function Functional\map;
use function range;
class GeolocationDbUpdaterTest extends TestCase
{
/** @var GeolocationDbUpdater */
private $geolocationDbUpdater;
/** @var ObjectProphecy */
private $dbUpdater;
/** @var ObjectProphecy */
private $geoLiteDbReader;
/** @var ObjectProphecy */
private $locker;
/** @var ObjectProphecy */
private $lock;
public function setUp(): void
{
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$this->geoLiteDbReader = $this->prophesize(Reader::class);
$this->locker = $this->prophesize(Lock\Factory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire(true)->willReturn(true);
$this->lock->release()->will(function () {
});
$this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$this->geolocationDbUpdater = new GeolocationDbUpdater(
$this->dbUpdater->reveal(),
$this->geoLiteDbReader->reveal(),
$this->locker->reveal()
);
}
/** @test */
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
{
$mustBeUpdated = function () {
$this->assertTrue(true);
};
$prev = new RuntimeException('');
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false);
$getMeta = $this->geoLiteDbReader->metadata();
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
try {
$this->geolocationDbUpdater->checkDbUpdate($mustBeUpdated);
$this->assertTrue(false); // If this is reached, the test will fail
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
$this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
$this->assertSame($prev, $e->getPrevious());
$this->assertFalse($e->olderDbExists());
}
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldNotHaveBeenCalled();
$download->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideBigDays
*/
public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
'binary_format_major_version' => '',
'binary_format_minor_version' => '',
'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
'database_type' => '',
'languages' => '',
'description' => '',
'ip_version' => '',
'node_count' => 1,
'record_size' => 4,
]));
$prev = new RuntimeException('');
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
try {
$this->geolocationDbUpdater->checkDbUpdate();
$this->assertTrue(false); // If this is reached, the test will fail
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
$this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
$this->assertSame($prev, $e->getPrevious());
$this->assertTrue($e->olderDbExists());
}
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldHaveBeenCalledOnce();
}
public function provideBigDays(): iterable
{
yield [36];
yield [50];
yield [75];
yield [100];
}
/**
* @test
* @dataProvider provideSmallDays
*/
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(int $days): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
'binary_format_major_version' => '',
'binary_format_minor_version' => '',
'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
'database_type' => '',
'languages' => '',
'description' => '',
'ip_version' => '',
'node_count' => 1,
'record_size' => 4,
]));
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function () {
});
$this->geolocationDbUpdater->checkDbUpdate();
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldNotHaveBeenCalled();
}
public function provideSmallDays(): iterable
{
return map(range(0, 34), function (int $days) {
return [$days];
});
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use Doctrine\Common\Cache as DoctrineCache;
return [
'dependencies' => [
'factories' => [
DoctrineCache\Cache::class => Cache\CacheFactory::class,
Cache\RedisFactory::SERVICE_NAME => Cache\RedisFactory::class,
],
],
];

View File

@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use GeoIp2\Database\Reader;
use GuzzleHttp\Client as GuzzleClient;
use Monolog\Logger;
@@ -20,9 +18,7 @@ return [
'dependencies' => [
'factories' => [
EntityManager::class => Factory\EntityManagerFactory::class,
GuzzleClient::class => InvokableFactory::class,
Cache::class => Factory\CacheFactory::class,
Filesystem::class => InvokableFactory::class,
Reader::class => ConfigAbstractFactory::class,
@@ -45,7 +41,6 @@ return [
Service\PreviewGenerator::class => ConfigAbstractFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
'httpClient' => GuzzleClient::class,
'translator' => Translator::class,

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManager;
return [
'entity_manager' => [
'orm' => [
'types' => [
Type\ChronosDateTimeType::CHRONOS_DATETIME => Type\ChronosDateTimeType::class,
],
],
],
'dependencies' => [
'factories' => [
EntityManager::class => Doctrine\EntityManagerFactory::class,
Connection::class => Doctrine\ConnectionFactory::class,
Doctrine\NoDbNameConnectionFactory::SERVICE_NAME => Doctrine\NoDbNameConnectionFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
],
'delegators' => [
EntityManager::class => [
Doctrine\ReopeningEntityManagerDelegator::class,
],
],
],
];

View File

@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use const JSON_ERROR_NONE;
use function getenv;
use function json_decode as spl_json_decode;
use function json_last_error;
@@ -13,6 +11,8 @@ use function sprintf;
use function strtolower;
use function trim;
use const JSON_ERROR_NONE;
/**
* Gets the value of an environment variable. Supports boolean, empty and null.
* This is basically Laravel's env helper

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Cache;
use Doctrine\Common\Cache;
use Interop\Container\ContainerInterface;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\Factory\FactoryInterface;
use function Shlinkio\Shlink\Common\env;
class CacheFactory implements FactoryInterface
{
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): Cache\Cache
{
// TODO Make use of the redis cache via RedisFactory when possible
$appOptions = $container->get(AppOptions::class);
$adapter = env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
$adapter->setNamespace((string) $appOptions);
return $adapter;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Cache;
use Predis\Client as PredisClient;
use Psr\Container\ContainerInterface;
use function count;
use function explode;
use function is_string;
class RedisFactory
{
public const SERVICE_NAME = 'Shlinkio\Shlink\Common\Cache\Redis';
public function __invoke(ContainerInterface $container): PredisClient
{
$redisConfig = $container->get('config')['redis'] ?? [];
$servers = $redisConfig['servers'] ?? [];
$servers = is_string($servers) ? explode(',', $servers) : $servers;
$options = count($servers) <= 1 ? null : ['cluster' => 'redis'];
return new PredisClient($servers, $options);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Doctrine;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
class ConnectionFactory
{
public function __invoke(ContainerInterface $container): Connection
{
$em = $container->get(EntityManager::class);
return $em->getConnection();
}
}

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Factory;
namespace Shlinkio\Shlink\Common\Doctrine;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
@@ -11,36 +11,42 @@ use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Tools\Setup;
use Interop\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use Psr\Container\ContainerInterface;
class EntityManagerFactory implements FactoryInterface
class EntityManagerFactory
{
/**
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
* @throws ORMException
* @throws DBALException
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
public function __invoke(ContainerInterface $container): EntityManager
{
$globalConfig = $container->get('config');
$isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false;
$isDevMode = (bool) ($globalConfig['debug'] ?? false);
$cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache();
$emConfig = $globalConfig['entity_manager'] ?? [];
$connectionConfig = $emConfig['connection'] ?? [];
$ormConfig = $emConfig['orm'] ?? [];
if (! Type::hasType(ChronosDateTimeType::CHRONOS_DATETIME)) {
Type::addType(ChronosDateTimeType::CHRONOS_DATETIME, ChronosDateTimeType::class);
}
$this->registerTypes($ormConfig);
$config = Setup::createConfiguration($isDevMode, $ormConfig['proxies_dir'] ?? null, $cache);
$config->setMetadataDriverImpl(new PHPDriver($ormConfig['entities_mappings'] ?? []));
return EntityManager::create($connectionConfig, $config);
}
/**
* @throws DBALException
*/
private function registerTypes(array $ormConfig): void
{
$types = $ormConfig['types'] ?? [];
foreach ($types as $name => $className) {
if (! Type::hasType($name)) {
Type::addType($name, $className);
}
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Doctrine;
use Doctrine\DBAL\Connection;
use Psr\Container\ContainerInterface;
class NoDbNameConnectionFactory
{
public const SERVICE_NAME = 'Shlinkio\Shlink\Common\Doctrine\NoDbNameConnection';
public function __invoke(ContainerInterface $container): Connection
{
$conn = $container->get(Connection::class);
$params = $conn->getParams();
unset($params['dbname']);
return new Connection($params, $conn->getDriver(), $conn->getConfiguration(), $conn->getEventManager());
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Doctrine;
use Doctrine\ORM\Decorator\EntityManagerDecorator;
use Doctrine\ORM\EntityManagerInterface;
class ReopeningEntityManager extends EntityManagerDecorator
{
/** @var callable */
private $emFactory;
public function __construct(EntityManagerInterface $wrapped, callable $emFactory)
{
parent::__construct($wrapped);
$this->emFactory = $emFactory;
}
protected function getWrappedEntityManager(): EntityManagerInterface
{
if (! $this->wrapped->isOpen()) {
$this->wrapped = ($this->emFactory)(
$this->wrapped->getConnection(),
$this->wrapped->getConfiguration(),
$this->wrapped->getEventManager()
);
}
return $this->wrapped;
}
public function flush($entity = null): void
{
$this->getWrappedEntityManager()->flush($entity);
}
public function persist($object): void
{
$this->getWrappedEntityManager()->persist($object);
}
public function remove($object): void
{
$this->getWrappedEntityManager()->remove($object);
}
public function refresh($object): void
{
$this->getWrappedEntityManager()->refresh($object);
}
public function merge($object)
{
return $this->getWrappedEntityManager()->merge($object);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Doctrine;
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
class ReopeningEntityManagerDelegator
{
public function __invoke(ContainerInterface $container, string $name, callable $callback): ReopeningEntityManager
{
return new ReopeningEntityManager($callback(), [EntityManager::class, 'create']);
}
}

View File

@@ -20,6 +20,9 @@ abstract class AbstractEntity
return $this->id;
}
/**
* @internal
*/
public function setId(string $id): self
{
$this->id = $id;

View File

@@ -9,7 +9,7 @@ use function sprintf;
class WrongIpException extends RuntimeException
{
public static function fromIpAddress($ipAddress, Throwable $prev = null): self
public static function fromIpAddress($ipAddress, ?Throwable $prev = null): self
{
return new self(sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev);
}

View File

@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Memcached;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
use function sys_get_temp_dir;
class CacheFactory implements FactoryInterface
{
private const VALID_CACHE_ADAPTERS = [
Cache\ApcuCache::class,
Cache\ArrayCache::class,
Cache\FilesystemCache::class,
Cache\PhpFileCache::class,
Cache\MemcachedCache::class,
];
private const DEFAULT_MEMCACHED_PORT = 11211;
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$appOptions = $container->get(AppOptions::class);
$adapter = $this->getAdapter($container);
$adapter->setNamespace((string) $appOptions);
return $adapter;
}
private function getAdapter(ContainerInterface $container): Cache\CacheProvider
{
// Try to get the adapter from config
$config = $container->get('config');
if (isset($config['cache']['adapter']) && contains(self::VALID_CACHE_ADAPTERS, $config['cache']['adapter'])) {
return $this->resolveCacheAdapter($config['cache']);
}
// If the adapter has not been set in config, create one based on environment
return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
}
private function resolveCacheAdapter(array $cacheConfig): Cache\CacheProvider
{
switch ($cacheConfig['adapter']) {
case Cache\ArrayCache::class:
case Cache\ApcuCache::class:
return new $cacheConfig['adapter']();
case Cache\FilesystemCache::class:
case Cache\PhpFileCache::class:
return new $cacheConfig['adapter']($cacheConfig['options']['dir'] ?? sys_get_temp_dir());
case Cache\MemcachedCache::class:
$cache = new Cache\MemcachedCache();
$cache->setMemcached($this->buildMemcached($cacheConfig));
return $cache;
default:
return new Cache\ArrayCache();
}
}
private function buildMemcached(array $cacheConfig): Memcached
{
$memcached = new Memcached();
$servers = $cacheConfig['options']['servers'] ?? [];
foreach ($servers as $server) {
$this->addMemcachedServer($memcached, $server);
}
return $memcached;
}
private function addMemcachedServer(Memcached $memcached, array $server): void
{
if (! isset($server['host'])) {
return;
}
$port = (int) ($server['port'] ?? self::DEFAULT_MEMCACHED_PORT);
$memcached->addServer($server['host'], $port);
}
}

View File

@@ -44,7 +44,7 @@ class DottedAccessConfigAbstractFactory implements AbstractFactoryInterface
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
{
$parts = explode('.', $requestedName);
$serviceName = array_shift($parts);

View File

@@ -25,7 +25,7 @@ class EmptyResponseImplicitOptionsMiddlewareFactory implements FactoryInterface
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
{
return new ImplicitOptionsMiddleware(function () {
return new EmptyResponse();

View File

@@ -24,7 +24,7 @@ class TranslatorFactory implements FactoryInterface
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
{
$config = $container->get('config');
return Translator::factory($config['translator'] ?? []);

View File

@@ -24,7 +24,7 @@ class ImageBuilderFactory implements FactoryInterface
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
{
return new ImageBuilder($container, ['factories' => [
Image::class => ImageFactory::class,

View File

@@ -24,7 +24,7 @@ class ImageFactory implements FactoryInterface
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
{
$config = $container->get('config')['wkhtmltopdf'];
$image = new Image($config['images'] ?? null);

View File

@@ -37,7 +37,7 @@ class DbUpdater implements DbUpdaterInterface
/**
* @throws RuntimeException
*/
public function downloadFreshCopy(callable $handleProgress = null): void
public function downloadFreshCopy(?callable $handleProgress = null): void
{
$tempDir = $this->options->getTempDir();
$compressedFile = sprintf('%s/%s', $tempDir, self::DB_COMPRESSED_FILE);
@@ -48,7 +48,7 @@ class DbUpdater implements DbUpdaterInterface
$this->deleteTempFiles([$compressedFile, $tempFullPath]);
}
private function downloadDbFile(string $dest, callable $handleProgress = null): void
private function downloadDbFile(string $dest, ?callable $handleProgress = null): void
{
try {
$this->httpClient->request(RequestMethod::METHOD_GET, $this->options->getDownloadFrom(), [
@@ -98,4 +98,9 @@ class DbUpdater implements DbUpdaterInterface
// Ignore any error produced when trying to delete temp files
}
}
public function databaseFileExists(): bool
{
return $this->filesystem->exists($this->options->getDbLocation());
}
}

View File

@@ -7,8 +7,10 @@ use Shlinkio\Shlink\Common\Exception\RuntimeException;
interface DbUpdaterInterface
{
public function databaseFileExists(): bool;
/**
* @throws RuntimeException
*/
public function downloadFreshCopy(callable $handleProgress = null): void;
public function downloadFreshCopy(?callable $handleProgress = null): void;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Lock;
use Interop\Container\ContainerInterface;
use Symfony\Component\Lock\Store\RetryTillSaveStore;
use Symfony\Component\Lock\StoreInterface;
class RetryLockStoreDelegatorFactory
{
public function __invoke(ContainerInterface $container, $name, callable $callback): RetryTillSaveStore
{
/** @var StoreInterface $originalStore */
$originalStore = $callback();
return new RetryTillSaveStore($originalStore);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Logger;
use Psr\Container\ContainerInterface;
use Psr\Log;
class LoggerAwareDelegatorFactory
{
public function __invoke(ContainerInterface $container, $name, callable $callback)
{
$instance = $callback();
if ($instance instanceof Log\LoggerAwareInterface) {
$instance->setLogger($container->get(Log\LoggerInterface::class));
}
return $instance;
}
}

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Factory;
namespace Shlinkio\Shlink\Common\Logger;
use Cascade\Cascade;
use Interop\Container\ContainerInterface;
@@ -27,7 +27,7 @@ class LoggerFactory implements FactoryInterface
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
{
$config = $container->has('config') ? $container->get('config') : [];
Cascade::fileConfig($config['logger'] ?? ['loggers' => []]);

View File

@@ -3,11 +3,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Logger\Processor;
use const PHP_EOL;
use function str_replace;
use function strpos;
use const PHP_EOL;
final class ExceptionWithNewLineProcessor
{
private const EXCEPTION_PLACEHOLDER = '{e}';

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