Compare commits

...

84 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
163 changed files with 3351 additions and 363 deletions

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

@@ -4,6 +4,72 @@ 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

View File

@@ -188,7 +188,7 @@ 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:locate`
* **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.
@@ -204,7 +204,7 @@ Those tasks can be performed using shlink's CLI, so it should be easy to schedul
*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.
> 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

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 \
@@ -47,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

@@ -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-helpers": "^5.3",
"zendframework/zend-expressive-platesrenderer": "^2.1",
"zendframework/zend-expressive-swoole": "^2.4",
"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-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",
"phpunit/phpunit": "^8.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": {
@@ -110,15 +117,18 @@
"test:ci": [
"@test:unit:ci",
"@test:db",
"@test:db:mysql",
"@test:db:postgres",
"@test:api"
],
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/phpunit.junit.xml --testdox",
"test:db": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-db.xml --coverage-php build/coverage-db.cov --testdox",
"test:db:mysql": "DB_DRIVER=mysql composer test:db",
"test:db:postgres": "DB_DRIVER=postgres composer test:db",
"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": [
@@ -127,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"
@@ -145,7 +155,8 @@
"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 on a SQLite database</>",
"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</>",

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

@@ -58,8 +58,8 @@ return [
'dependencies' => [
'factories' => [
'Logger_Shlink' => Common\Factory\LoggerFactory::class,
'Logger_Access' => Common\Factory\LoggerFactory::class,
'Logger_Shlink' => Common\Logger\LoggerFactory::class,
'Logger_Access' => Common\Logger\LoggerFactory::class,
],
],

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

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

View File

@@ -70,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,
],
],
],

View File

@@ -84,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

@@ -24,6 +24,7 @@ services:
links:
- shlink_db
- shlink_db_postgres
- shlink_redis
shlink_swoole:
container_name: shlink_swoole
@@ -37,6 +38,7 @@ services:
links:
- shlink_db
- shlink_db_postgres
- shlink_redis
shlink_db:
container_name: shlink_db
@@ -62,3 +64,9 @@ services:
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
}
}
}
},

View File

@@ -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,15 +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;
@@ -19,7 +22,9 @@ 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,
@@ -44,11 +49,14 @@ 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],
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],
@@ -60,7 +68,7 @@ return [
Command\Visit\LocateVisitsCommand::class => [
Service\VisitService::class,
IpLocationResolverInterface::class,
Lock\Factory::class,
Locker::class,
GeolocationDbUpdater::class,
],
Command\Visit\UpdateDbCommand::class => [DbUpdater::class],
@@ -73,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

@@ -3,6 +3,9 @@ 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;
@@ -14,16 +17,16 @@ 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\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 Command
class LocateVisitsCommand extends AbstractLockedCommand
{
public const NAME = 'visit:locate';
public const ALIASES = ['visit:process'];
@@ -32,8 +35,6 @@ class LocateVisitsCommand extends Command
private $visitService;
/** @var IpLocationResolverInterface */
private $ipLocationResolver;
/** @var Locker */
private $locker;
/** @var GeolocationDbUpdaterInterface */
private $dbUpdater;
@@ -48,10 +49,9 @@ class LocateVisitsCommand extends Command
Locker $locker,
GeolocationDbUpdaterInterface $dbUpdater
) {
parent::__construct();
parent::__construct($locker);
$this->visitService = $visitService;
$this->ipLocationResolver = $ipLocationResolver;
$this->locker = $locker;
$this->dbUpdater = $dbUpdater;
}
@@ -63,23 +63,17 @@ class LocateVisitsCommand extends Command
->setDescription('Resolves visits origin locations.');
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
{
$this->io = new SymfonyStyle($input, $output);
$lock = $this->locker->createLock(self::NAME);
if (! $lock->acquire()) {
$this->io->warning(sprintf('There is already an instance of the "%s" command in execution', self::NAME));
return ExitCodes::EXIT_WARNING;
}
try {
$this->checkDbUpdate();
$this->visitService->locateUnlocatedVisits(
[$this, 'getGeolocationDataForVisit'],
function (VisitLocation $location) use ($output) {
if (! $location->isEmpty()) {
static function (VisitLocation $location) use ($output) {
if (!$location->isEmpty()) {
$output->writeln(
sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName())
);
@@ -88,9 +82,14 @@ class LocateVisitsCommand extends Command
);
$this->io->success('Finished processing all IPs');
} finally {
$lock->release();
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;
}
}
@@ -152,4 +151,9 @@ class LocateVisitsCommand extends Command
);
}
}
protected function getLockConfig(): LockedCommandConfig
{
return new LockedCommandConfig($this->getName());
}
}

View File

@@ -11,13 +11,13 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
/** @var bool */
private $olderDbExists;
public function __construct(bool $olderDbExists, string $message = '', int $code = 0, Throwable $previous = null)
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
public static function create(bool $olderDbExists, ?Throwable $prev = null): self
{
return new self(
$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

@@ -5,55 +5,68 @@ namespace Shlinkio\Shlink\CLI\Util;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use InvalidArgumentException;
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)
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
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 {
$meta = $this->geoLiteDbReader->metadata();
if ($this->buildIsTooOld($meta->__get('buildEpoch'))) {
$this->downloadNewDb(true, $mustBeUpdated, $handleProgress);
}
} catch (InvalidArgumentException $e) {
// This is the exception thrown by the reader when the database file does not exist
$this->downloadNewDb(false, $mustBeUpdated, $handleProgress);
$this->downloadIfNeeded($mustBeUpdated, $handleProgress);
} catch (Throwable $e) {
throw $e;
} finally {
$lock->release();
}
}
private function buildIsTooOld(int $buildTimestamp): bool
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadIfNeeded(?callable $mustBeUpdated, ?callable $handleProgress): void
{
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
$now = Chronos::now();
return $now->gt($buildDate->addDays(35));
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 = null,
callable $handleProgress = null
): void {
private function downloadNewDb(bool $olderDbExists, ?callable $mustBeUpdated, ?callable $handleProgress): void
{
if ($mustBeUpdated !== null) {
$mustBeUpdated($olderDbExists);
}
@@ -64,4 +77,11 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
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

@@ -10,5 +10,5 @@ interface GeolocationDbUpdaterInterface
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(callable $mustBeUpdated = null, callable $handleProgress = null): void;
public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void;
}

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

@@ -49,10 +49,10 @@ class LocateVisitsCommandTest extends TestCase
$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 LocateVisitsCommand(
$this->visitService->reveal(),
@@ -162,9 +162,9 @@ class LocateVisitsCommandTest 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 () {
});
@@ -174,7 +174,7 @@ class LocateVisitsCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
sprintf('There is already an instance of the "%s" command', LocateVisitsCommand::NAME),
sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME),
$output
);
$locateVisits->shouldNotHaveBeenCalled();

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

@@ -5,14 +5,15 @@ namespace ShlinkioTest\Shlink\CLI\Util;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use InvalidArgumentException;
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;
@@ -26,15 +27,27 @@ class GeolocationDbUpdaterTest extends TestCase
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->geoLiteDbReader->reveal(),
$this->locker->reveal()
);
}
@@ -44,8 +57,10 @@ class GeolocationDbUpdaterTest extends TestCase
$mustBeUpdated = function () {
$this->assertTrue(true);
};
$getMeta = $this->geoLiteDbReader->metadata()->willThrow(InvalidArgumentException::class);
$prev = new RuntimeException('');
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false);
$getMeta = $this->geoLiteDbReader->metadata();
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
try {
@@ -58,7 +73,8 @@ class GeolocationDbUpdaterTest extends TestCase
$this->assertFalse($e->olderDbExists());
}
$getMeta->shouldHaveBeenCalledOnce();
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldNotHaveBeenCalled();
$download->shouldHaveBeenCalledOnce();
}
@@ -68,6 +84,7 @@ class GeolocationDbUpdaterTest extends TestCase
*/
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' => '',
@@ -92,6 +109,7 @@ class GeolocationDbUpdaterTest extends TestCase
$this->assertTrue($e->olderDbExists());
}
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldHaveBeenCalledOnce();
}
@@ -110,6 +128,7 @@ class GeolocationDbUpdaterTest extends TestCase
*/
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' => '',
@@ -126,6 +145,7 @@ class GeolocationDbUpdaterTest extends TestCase
$this->geolocationDbUpdater->checkDbUpdate();
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldNotHaveBeenCalled();
}

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

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Factory;
namespace Shlinkio\Shlink\Common\Cache;
use Doctrine\Common\Cache;
use Interop\Container\ContainerInterface;
@@ -12,8 +12,10 @@ use function Shlinkio\Shlink\Common\env;
class CacheFactory implements FactoryInterface
{
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): Cache\Cache
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);

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

@@ -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}';

View File

@@ -19,16 +19,13 @@ class CloseDbConnectionMiddleware implements MiddlewareInterface
$this->em = $em;
}
/**
* Process an incoming server request and return a response, optionally delegating
* response creation to a handler.
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$handledRequest = $handler->handle($request);
$this->em->getConnection()->close();
$this->em->clear();
return $handledRequest;
try {
return $handler->handle($request);
} finally {
$this->em->getConnection()->close();
$this->em->clear();
}
}
}

View File

@@ -21,8 +21,10 @@ class IpAddressMiddlewareFactory implements FactoryInterface
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): IpAddress
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): IpAddress
{
return new IpAddress(true, [], Visitor::REMOTE_ADDRESS_ATTR);
$config = $container->get('config');
$headersToInspect = $config['ip_address_resolution']['headers_to_inspect'] ?? [];
return new IpAddress(true, [], Visitor::REMOTE_ADDRESS_ATTR, $headersToInspect);
}
}

View File

@@ -16,9 +16,9 @@ interface PaginableRepositoryInterface
* @return array
*/
public function findList(
int $limit = null,
int $offset = null,
string $searchTerm = null,
?int $limit = null,
?int $offset = null,
?string $searchTerm = null,
array $tags = [],
$orderBy = null
): array;
@@ -30,5 +30,5 @@ interface PaginableRepositoryInterface
* @param array $tags
* @return int
*/
public function countList(string $searchTerm = null, array $tags = []): int;
public function countList(?string $searchTerm = null, array $tags = []): int;
}

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Factory;
namespace ShlinkioTest\Shlink\Common\Cache;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Factory\CacheFactory;
use Shlinkio\Shlink\Common\Cache\CacheFactory;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\ServiceManager;

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Cache;
use PHPUnit\Framework\TestCase;
use Predis\Connection\Aggregate\PredisCluster;
use Predis\Connection\Aggregate\RedisCluster;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Cache\RedisFactory;
class RedisFactoryTest extends TestCase
{
/** @var RedisFactory */
private $factory;
/** @var ObjectProphecy */
private $container;
public function setUp(): void
{
$this->container = $this->prophesize(ContainerInterface::class);
$this->factory = new RedisFactory();
}
/**
* @test
* @dataProvider provideRedisConfig
*/
public function createsRedisClientBasedOnConfig(?array $config, string $expectedCluster): void
{
$getConfig = $this->container->get('config')->willReturn([
'redis' => $config,
]);
$client = ($this->factory)($this->container->reveal());
$getConfig->shouldHaveBeenCalledOnce();
$this->assertInstanceOf($expectedCluster, $client->getOptions()->cluster);
}
public function provideRedisConfig(): iterable
{
yield 'no config' => [null, PredisCluster::class];
yield 'single server as string' => [[
'servers' => 'tcp://127.0.0.1:6379',
], PredisCluster::class];
yield 'single server as array' => [[
'servers' => ['tcp://127.0.0.1:6379'],
], PredisCluster::class];
yield 'cluster of servers' => [[
'servers' => ['tcp://1.1.1.1:6379', 'tcp://2.2.2.2:6379'],
], RedisCluster::class];
yield 'empty cluster of servers' => [[
'servers' => [],
], PredisCluster::class];
yield 'cluster of servers as string' => [[
'servers' => 'tcp://1.1.1.1:6379,tcp://2.2.2.2:6379',
], RedisCluster::class];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Doctrine;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Doctrine\ConnectionFactory;
class ConnectionFactoryTest extends TestCase
{
/** @var ConnectionFactory */
private $factory;
/** @var ObjectProphecy */
private $container;
/** @var ObjectProphecy */
private $em;
public function setUp(): void
{
$this->container = $this->prophesize(ContainerInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->container->get(EntityManager::class)->willReturn($this->em->reveal());
$this->factory = new ConnectionFactory();
}
/** @test */
public function properServiceFallbackOccursWhenInvoked(): void
{
$connection = $this->prophesize(Connection::class)->reveal();
$getConnection = $this->em->getConnection()->willReturn($connection);
$result = ($this->factory)($this->container->reveal());
$this->assertSame($connection, $result);
$getConnection->shouldHaveBeenCalledOnce();
$this->container->get(EntityManager::class)->shouldHaveBeenCalledOnce();
}
}

View File

@@ -1,11 +1,12 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Factory;
namespace ShlinkioTest\Shlink\Common\Doctrine;
use Doctrine\ORM\EntityManager;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Factory\EntityManagerFactory;
use Shlinkio\Shlink\Common\Doctrine\EntityManagerFactory;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
use Zend\ServiceManager\ServiceManager;
class EntityManagerFactoryTest extends TestCase
@@ -19,12 +20,17 @@ class EntityManagerFactoryTest extends TestCase
}
/** @test */
public function serviceIsCreated()
public function serviceIsCreated(): void
{
$sm = new ServiceManager(['services' => [
'config' => [
'debug' => true,
'entity_manager' => [
'orm' => [
'types' => [
ChronosDateTimeType::CHRONOS_DATETIME => ChronosDateTimeType::class,
],
],
'connection' => [
'driver' => 'pdo_sqlite',
],
@@ -32,7 +38,7 @@ class EntityManagerFactoryTest extends TestCase
],
]]);
$em = $this->factory->__invoke($sm, EntityManager::class);
$em = ($this->factory)($sm, EntityManager::class);
$this->assertInstanceOf(EntityManager::class, $em);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Doctrine;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
class NoDbNameConnectionFactoryTest extends TestCase
{
/** @var NoDbNameConnectionFactory */
private $factory;
/** @var ObjectProphecy */
private $container;
/** @var ObjectProphecy */
private $originalConn;
public function setUp(): void
{
$this->container = $this->prophesize(ContainerInterface::class);
$this->originalConn = $this->prophesize(Connection::class);
$this->container->get(Connection::class)->willReturn($this->originalConn->reveal());
$this->factory = new NoDbNameConnectionFactory();
}
/** @test */
public function createsNewConnectionRemovingDbNameFromOriginalConnectionParams(): void
{
$params = [
'username' => 'foo',
'password' => 'bar',
'dbname' => 'something',
];
$getOriginalParams = $this->originalConn->getParams()->willReturn($params);
$getOriginalDriver = $this->originalConn->getDriver()->willReturn($this->prophesize(Driver::class)->reveal());
$getOriginalConfig = $this->originalConn->getConfiguration()->willReturn(null);
$getOriginalEvents = $this->originalConn->getEventManager()->willReturn(null);
$conn = ($this->factory)($this->container->reveal());
$this->assertEquals([
'username' => 'foo',
'password' => 'bar',
], $conn->getParams());
$getOriginalParams->shouldHaveBeenCalledOnce();
$getOriginalDriver->shouldHaveBeenCalledOnce();
$getOriginalConfig->shouldHaveBeenCalledOnce();
$getOriginalEvents->shouldHaveBeenCalledOnce();
$this->container->get(Connection::class)->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Doctrine;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use ReflectionObject;
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerDelegator;
use Zend\ServiceManager\ServiceManager;
class ReopeningEntityManagerDelegatorTest extends TestCase
{
/** @test */
public function decoratesEntityManagerFromCallback(): void
{
$em = $this->prophesize(EntityManagerInterface::class)->reveal();
$result = (new ReopeningEntityManagerDelegator())(new ServiceManager(), '', function () use ($em) {
return $em;
});
$ref = new ReflectionObject($result);
$prop = $ref->getProperty('wrapped');
$prop->setAccessible(true);
$this->assertSame($em, $prop->getValue($result));
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Doctrine;
use Doctrine\Common\EventManager;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManager;
use stdClass;
class ReopeningEntityManagerTest extends TestCase
{
/** @var ReopeningEntityManager */
private $decoratorEm;
/** @var ObjectProphecy */
private $wrapped;
public function setUp(): void
{
$this->wrapped = $this->prophesize(EntityManagerInterface::class);
$this->wrapped->getConnection()->willReturn($this->prophesize(Connection::class));
$this->wrapped->getConfiguration()->willReturn($this->prophesize(Configuration::class));
$this->wrapped->getEventManager()->willReturn($this->prophesize(EventManager::class));
$wrappedMock = $this->wrapped->reveal();
$this->decoratorEm = new ReopeningEntityManager($wrappedMock, function () use ($wrappedMock) {
return $wrappedMock;
});
}
/**
* @test
* @dataProvider provideMethodNames
*/
public function wrappedInstanceIsTransparentlyCalledWhenItIsNotClosed(string $methodName): void
{
$method = $this->wrapped->__call($methodName, [Argument::cetera()])->willReturnArgument();
$isOpen = $this->wrapped->isOpen()->willReturn(true);
$this->decoratorEm->{$methodName}(new stdClass());
$method->shouldHaveBeenCalledOnce();
$isOpen->shouldHaveBeenCalledOnce();
$this->wrapped->getConnection()->shouldNotHaveBeenCalled();
$this->wrapped->getConfiguration()->shouldNotHaveBeenCalled();
$this->wrapped->getEventManager()->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideMethodNames
*/
public function wrappedInstanceIsRecreatedWhenItIsClosed(string $methodName): void
{
$method = $this->wrapped->__call($methodName, [Argument::cetera()])->willReturnArgument();
$isOpen = $this->wrapped->isOpen()->willReturn(false);
$this->decoratorEm->{$methodName}(new stdClass());
$method->shouldHaveBeenCalledOnce();
$isOpen->shouldHaveBeenCalledOnce();
$this->wrapped->getConnection()->shouldHaveBeenCalledOnce();
$this->wrapped->getConfiguration()->shouldHaveBeenCalledOnce();
$this->wrapped->getEventManager()->shouldHaveBeenCalledOnce();
}
public function provideMethodNames(): iterable
{
yield 'flush' => ['flush'];
yield 'persist' => ['persist'];
yield 'remove' => ['remove'];
yield 'refresh' => ['refresh'];
yield 'merge' => ['merge'];
}
}

View File

@@ -32,7 +32,7 @@ class DbUpdaterTest extends TestCase
$this->filesystem = $this->prophesize(Filesystem::class);
$this->options = new GeoLite2Options([
'temp_dir' => __DIR__ . '/../../../test-resources',
'db_location' => '',
'db_location' => 'db_location',
'download_from' => '',
]);
@@ -110,4 +110,23 @@ class DbUpdaterTest extends TestCase
$copy->shouldHaveBeenCalledOnce();
$remove->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideExists
*/
public function databaseFileExistsChecksIfTheFilesExistsInTheFilesystem(bool $expected): void
{
$exists = $this->filesystem->exists('db_location')->willReturn($expected);
$result = $this->dbUpdater->databaseFileExists();
$this->assertEquals($expected, $result);
$exists->shouldHaveBeenCalledOnce();
}
public function provideExists(): iterable
{
return [[true], [false]];
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Lock;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use ReflectionObject;
use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
use Symfony\Component\Lock\StoreInterface;
use Zend\ServiceManager\ServiceManager;
class RetryLockStoreDelegatorFactoryTest extends TestCase
{
/** @var RetryLockStoreDelegatorFactory */
private $delegator;
/** @var ObjectProphecy */
private $originalStore;
public function setUp(): void
{
$this->originalStore = $this->prophesize(StoreInterface::class)->reveal();
$this->delegator = new RetryLockStoreDelegatorFactory();
}
/** @test */
public function originalStoreIsWrappedInRetryStore(): void
{
$callback = function () {
return $this->originalStore;
};
$result = ($this->delegator)(new ServiceManager(), '', $callback);
$ref = new ReflectionObject($result);
$prop = $ref->getProperty('decorated');
$prop->setAccessible(true);
$this->assertSame($this->originalStore, $prop->getValue($result));
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Logger;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Container\ContainerInterface;
use Psr\Log;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use stdClass;
class LoggerAwareDelegatorFactoryTest extends TestCase
{
/** @var LoggerAwareDelegatorFactory */
private $delegator;
/** @var ObjectProphecy */
private $container;
public function setUp(): void
{
$this->container = $this->prophesize(ContainerInterface::class);
$this->delegator = new LoggerAwareDelegatorFactory();
}
/**
* @test
* @dataProvider provideInstances
*/
public function injectsLoggerOnInstanceWhenImplementingLoggerAware($instance, int $expectedCalls): void
{
$callback = function () use ($instance) {
return $instance;
};
$getLogger = $this->container->get(Log\LoggerInterface::class)->willReturn(new Log\NullLogger());
$result = ($this->delegator)($this->container->reveal(), '', $callback);
$this->assertSame($instance, $result);
$getLogger->shouldHaveBeenCalledTimes($expectedCalls);
}
public function provideInstances(): iterable
{
yield 'no logger aware' => [new stdClass(), 0];
yield 'logger aware' => [new class implements Log\LoggerAwareInterface {
public function setLogger(LoggerInterface $logger): void
{
Assert::assertInstanceOf(Log\NullLogger::class, $logger);
}
}, 1];
}
}

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Factory;
namespace ShlinkioTest\Shlink\Common\Logger;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Factory\LoggerFactory;
use Shlinkio\Shlink\Common\Logger\LoggerFactory;
use Zend\ServiceManager\ServiceManager;
class LoggerFactoryTest extends TestCase

View File

@@ -7,11 +7,11 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Logger\Processor\ExceptionWithNewLineProcessor;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use const PHP_EOL;
use function Functional\map;
use function range;
use const PHP_EOL;
class ExceptionWithNewLineProcessorTest extends TestCase
{
use StringUtilsTrait;

View File

@@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface;
use RuntimeException;
use Shlinkio\Shlink\Common\Middleware\CloseDbConnectionMiddleware;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest;
@@ -20,35 +21,52 @@ class CloseDbConnectionMiddlewareTest extends TestCase
private $handler;
/** @var ObjectProphecy */
private $em;
/** @var ObjectProphecy */
private $conn;
public function setUp(): void
{
$this->handler = $this->prophesize(RequestHandlerInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->conn = $this->prophesize(Connection::class);
$this->conn->close()->will(function () {
});
$this->em->getConnection()->willReturn($this->conn->reveal());
$this->em->clear()->will(function () {
});
$this->middleware = new CloseDbConnectionMiddleware($this->em->reveal());
}
/** @test */
public function connectionIsClosedWhenMiddlewareIsProcessed()
public function connectionIsClosedWhenMiddlewareIsProcessed(): void
{
$req = new ServerRequest();
$resp = new Response();
$conn = $this->prophesize(Connection::class);
$closeConn = $conn->close()->will(function () {
});
$getConn = $this->em->getConnection()->willReturn($conn->reveal());
$clear = $this->em->clear()->will(function () {
});
$handle = $this->handler->handle($req)->willReturn($resp);
$result = $this->middleware->process($req, $this->handler->reveal());
$this->assertSame($result, $resp);
$getConn->shouldHaveBeenCalledOnce();
$closeConn->shouldHaveBeenCalledOnce();
$clear->shouldHaveBeenCalledOnce();
$this->em->getConnection()->shouldHaveBeenCalledOnce();
$this->conn->close()->shouldHaveBeenCalledOnce();
$this->em->clear()->shouldHaveBeenCalledOnce();
$handle->shouldHaveBeenCalledOnce();
}
/** @test */
public function connectionIsClosedEvenIfExceptionIsThrownOnInnerMiddlewares(): void
{
$req = new ServerRequest();
$expectedError = new RuntimeException();
$this->handler->handle($req)->willThrow($expectedError)
->shouldBeCalledOnce();
$this->em->getConnection()->shouldBeCalledOnce();
$this->conn->close()->shouldBeCalledOnce();
$this->em->clear()->shouldBeCalledOnce();
$this->expectExceptionObject($expectedError);
$this->middleware->process($req, $this->handler->reveal());
}
}

View File

@@ -18,10 +18,15 @@ class IpAddressMiddlewareFactoryTest extends TestCase
$this->factory = new IpAddressMiddlewareFactory();
}
/** @test */
public function returnedInstanceIsProperlyConfigured()
/**
* @test
* @dataProvider provideConfigs
*/
public function returnedInstanceIsProperlyConfigured(array $config, array $expectedHeadersToInspect): void
{
$instance = $this->factory->__invoke(new ServiceManager(), '');
$instance = ($this->factory)(new ServiceManager(['services' => [
'config' => $config,
]]), '');
$ref = new ReflectionObject($instance);
$checkProxyHeaders = $ref->getProperty('checkProxyHeaders');
@@ -30,9 +35,52 @@ class IpAddressMiddlewareFactoryTest extends TestCase
$trustedProxies->setAccessible(true);
$attributeName = $ref->getProperty('attributeName');
$attributeName->setAccessible(true);
$headersToInspect = $ref->getProperty('headersToInspect');
$headersToInspect->setAccessible(true);
$this->assertTrue($checkProxyHeaders->getValue($instance));
$this->assertEquals([], $trustedProxies->getValue($instance));
$this->assertEquals(Visitor::REMOTE_ADDRESS_ATTR, $attributeName->getValue($instance));
$this->assertEquals($expectedHeadersToInspect, $headersToInspect->getValue($instance));
}
public function provideConfigs(): iterable
{
$defaultHeadersToInspect = [
'Forwarded',
'X-Forwarded-For',
'X-Forwarded',
'X-Cluster-Client-Ip',
'Client-Ip',
];
yield 'no ip_address_resolution config' => [[], $defaultHeadersToInspect];
yield 'no headers_to_inspect config' => [['ip_address_resolution' => []], $defaultHeadersToInspect];
yield 'null headers_to_inspect' => [['ip_address_resolution' => [
'headers_to_inspect' => null,
]], $defaultHeadersToInspect];
yield 'empty headers_to_inspect' => [['ip_address_resolution' => [
'headers_to_inspect' => [],
]], $defaultHeadersToInspect];
yield 'some headers_to_inspect' => [['ip_address_resolution' => [
'headers_to_inspect' => [
'foo',
'bar',
'baz',
],
]], [
'foo',
'bar',
'baz',
]];
yield 'some other headers_to_inspect' => [['ip_address_resolution' => [
'headers_to_inspect' => [
'something',
'something_else',
],
]], [
'something',
'something_else',
]];
}
}

View File

@@ -14,7 +14,7 @@ class TestUtils
{
private static $prophet;
public static function createReqHandlerMock(ResponseInterface $response = null, RequestInterface $request = null)
public static function createReqHandlerMock(?ResponseInterface $response = null, ?RequestInterface $request = null)
{
$argument = $request ?: Argument::any();
$delegate = static::getProphet()->prophesize(RequestHandlerInterface::class);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Doctrine\Common\Cache\Cache;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Zend\Expressive\Router\RouterInterface;
@@ -46,7 +47,7 @@ return [
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Service\UrlShortener::class => ['httpClient', 'em', Options\UrlShortenerOptions::class],
Service\VisitsTracker::class => ['em'],
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
Service\ShortUrlService::class => ['em'],
Service\VisitService::class => ['em'],
Service\Tag\TagService::class => ['em'],

View File

@@ -5,7 +5,6 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
/** @var $metadata ClassMetadata */

View File

@@ -5,7 +5,6 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
/** @var $metadata ClassMetadata */
$builder = new ClassMetadataBuilder($metadata);

View File

@@ -5,7 +5,6 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
/** @var $metadata ClassMetadata */

View File

@@ -5,7 +5,6 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
/** @var $metadata ClassMetadata */
$builder = new ClassMetadataBuilder($metadata);

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
return [
'events' => [
'regular' => [],
'async' => [
EventDispatcher\ShortUrlVisited::class => [
EventDispatcher\LocateShortUrlVisit::class,
],
],
],
'dependencies' => [
'factories' => [
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
],
],
ConfigAbstractFactory::class => [
EventDispatcher\LocateShortUrlVisit::class => [
IpLocationResolverInterface::class,
'em',
'Logger_Shlink',
GeolocationDbUpdater::class,
],
],
];

View File

@@ -33,7 +33,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
UrlShortenerInterface $urlShortener,
VisitsTrackerInterface $visitTracker,
AppOptions $appOptions,
LoggerInterface $logger = null
?LoggerInterface $logger = null
) {
$this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker;

View File

@@ -32,7 +32,7 @@ class PreviewAction implements MiddlewareInterface
public function __construct(
PreviewGeneratorInterface $previewGenerator,
UrlShortenerInterface $urlShortener,
LoggerInterface $logger = null
?LoggerInterface $logger = null
) {
$this->previewGenerator = $previewGenerator;
$this->urlShortener = $urlShortener;

View File

@@ -36,7 +36,7 @@ class QrCodeAction implements MiddlewareInterface
public function __construct(
RouterInterface $router,
UrlShortenerInterface $urlShortener,
LoggerInterface $logger = null
?LoggerInterface $logger = null
) {
$this->router = $router;
$this->urlShortener = $urlShortener;

View File

@@ -25,7 +25,7 @@ class RedirectAction extends AbstractTrackingAction
VisitsTrackerInterface $visitTracker,
Options\AppOptions $appOptions,
Options\NotFoundShortUrlOptions $notFoundOptions,
LoggerInterface $logger = null
?LoggerInterface $logger = null
) {
parent::__construct($urlShortener, $visitTracker, $appOptions, $logger);
$this->notFoundOptions = $notFoundOptions;

View File

@@ -30,7 +30,7 @@ class ShortUrl extends AbstractEntity
/** @var integer|null */
private $maxVisits;
public function __construct(string $longUrl, ShortUrlMeta $meta = null)
public function __construct(string $longUrl, ?ShortUrlMeta $meta = null)
{
$meta = $meta ?? ShortUrlMeta::createEmpty();

View File

@@ -65,6 +65,11 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->visitLocation ?? new UnknownVisitLocation();
}
public function isLocatable(): bool
{
return $this->hasRemoteAddr() && $this->remoteAddr !== IpAddress::LOCALHOST;
}
public function locate(VisitLocation $visitLocation): self
{
$this->visitLocation = $visitLocation;

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
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\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use function sprintf;
class LocateShortUrlVisit
{
/** @var IpLocationResolverInterface */
private $ipLocationResolver;
/** @var EntityManagerInterface */
private $em;
/** @var LoggerInterface */
private $logger;
/** @var GeolocationDbUpdaterInterface */
private $dbUpdater;
public function __construct(
IpLocationResolverInterface $ipLocationResolver,
EntityManagerInterface $em,
LoggerInterface $logger,
GeolocationDbUpdaterInterface $dbUpdater
) {
$this->ipLocationResolver = $ipLocationResolver;
$this->em = $em;
$this->logger = $logger;
$this->dbUpdater = $dbUpdater;
}
public function __invoke(ShortUrlVisited $shortUrlVisited): void
{
$visitId = $shortUrlVisited->visitId();
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning(sprintf('Tried to locate visit with id "%s", but it does not exist.', $visitId));
return;
}
try {
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) {
$this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'));
});
} catch (GeolocationDbUpdateFailedException $e) {
if (! $e->olderDbExists()) {
$this->logger->error(
sprintf(
'GeoLite2 database download failed. It is not possible to locate visit with id %s. {e}',
$visitId
),
['e' => $e]
);
return;
}
$this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]);
}
try {
$location = $visit->isLocatable()
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
: Location::emptyInstance();
} catch (WrongIpException $e) {
$this->logger->warning(
sprintf('Tried to locate visit with id "%s", but its address seems to be wrong. {e}', $visitId),
['e' => $e]
);
return;
}
$visit->locate(new VisitLocation($location));
$this->em->flush($visit);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use JsonSerializable;
final class ShortUrlVisited implements JsonSerializable
{
/** @var string */
private $visitId;
public function __construct(string $visitId)
{
$this->visitId = $visitId;
}
public function visitId(): string
{
return $this->visitId;
}
public function jsonSerialize(): array
{
return ['visitId' => $this->visitId];
}
}

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