Compare commits

...

82 Commits

Author SHA1 Message Date
Alejandro Celaya
f60c217fae Merge pull request #136 from acelaya/feature/1.7.2
Feature/1.7.2
2018-03-26 18:13:48 +02:00
Alejandro Celaya
d3fc7d543a Updated changelog 2018-03-26 18:13:08 +02:00
Alejandro Celaya
4d0fc1da07 Fixed PathVersionMiddleware not being properly propagated 2018-03-26 17:53:22 +02:00
Alejandro Celaya
ee2233c6dd Updated PathVersionMiddleware to single-pass middleware 2018-03-26 17:36:58 +02:00
Alejandro Celaya
ea6e0d7c7f Merge branch 'develop' 2018-03-21 16:31:27 +01:00
Alejandro Celaya
d9d599eab4 Updated changelog 2018-03-21 16:31:00 +01:00
Alejandro Celaya
d1ba44e1b3 Merge pull request #128 from weirdan/upgrade-to-expressive-2.2
Upgrade to expressive 2.2
2018-03-21 16:27:09 +01:00
Bruce Weirdan
dff2ad3740 define property to please scrutinizer 2018-03-21 12:13:03 +02:00
Bruce Weirdan
f7e63710e4 updated tests to fix deprecations
also fixed cs errors in middleware-pipeline
2018-03-21 02:05:55 +02:00
Bruce Weirdan
d3b5cd5c57 fixed middleware deprecations 2018-03-21 01:46:26 +02:00
Alejandro Celaya
86ed83d25e Merge branch 'develop' 2018-02-03 10:23:18 +01:00
Alejandro Celaya
f96d0fe30a Merge pull request #124 from acelaya/feature/o-a-s-3
Feature/o a s 3
2018-02-03 10:14:32 +01:00
Alejandro Celaya
be406bd676 Removed no-longer used Authorization parameter 2018-02-03 10:13:10 +01:00
Alejandro Celaya
044278752b Fixed server 2018-02-03 10:09:42 +01:00
Alejandro Celaya
343d2ab44a Added domain 2018-02-03 10:07:37 +01:00
Alejandro Celaya
66992f644e Added default value for server 2018-02-03 10:06:04 +01:00
Alejandro Celaya
cf245524dd Added missing base path in server 2018-02-03 10:01:16 +01:00
Alejandro Celaya
ad520811a3 Fixed dynamic host 2018-02-03 09:55:53 +01:00
Alejandro Celaya
ee1e1d5688 Updated swagger docs to OAS3 2018-02-03 09:53:40 +01:00
Alejandro Celaya
8ef0e7c25b Merge pull request #121 from shlinkio/develop
Develop
2018-01-21 10:11:12 +01:00
Alejandro Celaya
c3d555ef3c Added missing null coalescing operator 2018-01-21 10:01:18 +01:00
Alejandro Celaya
bf8e14708b Merge pull request #116 from acelaya/feature/1.7.0
Feature/1.7.0
2018-01-21 10:00:11 +01:00
Alejandro Celaya
6ea59b1e4d Updated changelog 2018-01-21 09:43:01 +01:00
Alejandro Celaya
cf8b778711 Updated language files 2018-01-21 09:40:38 +01:00
Alejandro Celaya
1e79969c3b Made visits not to be tracked if query param has been provided 2018-01-14 09:24:33 +01:00
Alejandro Celaya
5fd34e03fc Added new app config param to allow disabling short URL visits tracking 2018-01-14 09:13:49 +01:00
Alejandro Celaya
ce9d6642d4 Fixed edit short code action not being properly registered 2018-01-07 21:13:06 +01:00
Alejandro Celaya
ecebdbbfa8 Updated API docs including new endpoint and updating params for short code creation 2018-01-07 20:54:02 +01:00
Alejandro Celaya
6f7ce709ca Fixed PhpStan error 2018-01-07 20:46:28 +01:00
Alejandro Celaya
84094a51a2 Implemented EditShortCodeAction 2018-01-07 20:45:05 +01:00
Alejandro Celaya
7ba9eb8e2c Fixed coding styles 2018-01-07 20:08:07 +01:00
Alejandro Celaya
e8a0c5484c Added test for ShortUrlMeta 2018-01-07 20:07:12 +01:00
Alejandro Celaya
0521227127 Tested new method to update short URLs metadata 2018-01-07 20:00:21 +01:00
Alejandro Celaya
fac9455a1e Created method to updated already created short URLs 2018-01-07 19:51:25 +01:00
Alejandro Celaya
3243ade4fd Improved error message when installation fails 2017-12-31 19:31:35 +01:00
Alejandro Celaya
da21eb4a5c Removed return type incompatible with PHP 7.0 2017-12-31 19:24:22 +01:00
Alejandro Celaya
5ec6d538db Improved and simplified ProcessVisitsCommand thanks to SymfonyStyle 2017-12-31 19:13:42 +01:00
Alejandro Celaya
08228d9d98 Improved and simplified RenameTagCommand thanks to SymfonyStyle 2017-12-31 19:10:27 +01:00
Alejandro Celaya
7856d64299 Improved and simplified ListTagsCommand thanks to SymfonyStyle 2017-12-31 19:08:10 +01:00
Alejandro Celaya
057bbae729 Improved and simplified DeleteTagCommand thanks to SymfonyStyle 2017-12-31 19:06:04 +01:00
Alejandro Celaya
09b161304c Improved and simplified CreateTagCommand thanks to SymfonyStyle 2017-12-31 19:03:41 +01:00
Alejandro Celaya
a60c45ca4d Simplified and improved ResolveUrlCommand with SymfonyStyle 2017-12-31 18:58:11 +01:00
Alejandro Celaya
89ed84ce28 Removed unused use statements 2017-12-31 18:38:25 +01:00
Alejandro Celaya
a6c547c4da Improved and simplified ListShortcodesCommand with SymfonyStyle 2017-12-31 18:37:39 +01:00
Alejandro Celaya
3e2c5abaa4 Improved GetVisitsCommand by using SymfonyStyle 2017-12-31 18:17:58 +01:00
Alejandro Celaya
c202b3e518 Improved GenerateShortcodeCommand by using SymfonyStyle 2017-12-31 18:12:43 +01:00
Alejandro Celaya
e15b67b5dc Improved GeneratePreviewCommand using SymfonyStyle 2017-12-31 18:04:11 +01:00
Alejandro Celaya
7ddc180487 Simplified InstallCommand 2017-12-31 17:59:50 +01:00
Alejandro Celaya
f3fbfc3692 Fixed phpstan error 2017-12-31 17:54:01 +01:00
Alejandro Celaya
b289e3bac2 Applied more improvements on InstallCommand with SymfonyStyle 2017-12-31 17:52:17 +01:00
Alejandro Celaya
4d4aafa6db Fixed config customizer tests 2017-12-31 17:45:27 +01:00
Alejandro Celaya
2705070063 Renamed tests 2017-12-31 17:22:25 +01:00
Alejandro Celaya
5e3770c105 Renamed ConfigCustomizerPluginManager to CongigCustomizerManager 2017-12-31 17:20:03 +01:00
Alejandro Celaya
0f0213aa87 Removed plugin suffix on config ustomizers 2017-12-31 17:18:54 +01:00
Alejandro Celaya
0e2ad0dbca Updated ConfigCustomizer api to expect a SymfonyStyle object instead of a set of input and output 2017-12-31 17:14:01 +01:00
Alejandro Celaya
d275316acd Applied SymfonyStyle to all installation config customizers 2017-12-31 17:07:39 +01:00
Alejandro Celaya
0a681f0efa Simplified UrlShortenerConfigCustomizerPlugin thanks to SymfonyStyle 2017-12-31 17:00:26 +01:00
Alejandro Celaya
b17f96043a Simplified and standardized DatabaseConfigCustomizerPlugin thanks to SymfonyStyle 2017-12-31 16:53:18 +01:00
Alejandro Celaya
6f9b727673 Merge branch 'feature/1.7.0' of github.com:acelaya/shlink into feature/1.7.0 2017-12-31 16:29:55 +01:00
Alejandro Celaya
79427d08d7 Added phpstan config file to export-ignore list 2017-12-30 21:39:18 +01:00
Alejandro Celaya
2ec807ba70 Increased phpstan required level 2017-12-30 21:36:33 +01:00
Alejandro Celaya
ede4525332 Refactored exceptions to properly use package exceptions 2017-12-30 21:35:26 +01:00
Alejandro Celaya
4dffc9f0c1 Improved and simplified all installation process thanks to symfony style 2017-12-28 15:52:10 +01:00
Alejandro Celaya
5de845c258 Improved GenerateSecretCommand by using SymfonyStyle 2017-12-28 15:17:12 +01:00
Alejandro Celaya
745ff51150 Ensured phpstan config is properly loaded in Ci envs 2017-12-28 15:07:14 +01:00
Alejandro Celaya
88b9f9fc56 Fixed GenerateCharsetCommandTest 2017-12-28 14:59:52 +01:00
Alejandro Celaya
fdbe076bf2 Added phpstan config file excluding a file that fails 2017-12-28 14:55:55 +01:00
Alejandro Celaya
0760550767 Removed unnecessary type hints 2017-12-28 09:48:34 +01:00
Alejandro Celaya
1b94083188 Improved GenerateCharsetCommand by using SymfonyStyle 2017-12-28 09:48:17 +01:00
Alejandro Celaya
1993d01110 Dimplified GenerateKeyCommand by using SymfonyStyle 2017-12-27 17:36:07 +01:00
Alejandro Celaya
37fb7e76d9 Simplified DisableKeyCommand using SymfonyStyle 2017-12-27 17:32:39 +01:00
Alejandro Celaya
cc3362837b Simplified ListKeysCommand using SymfonyStyle 2017-12-27 17:28:51 +01:00
Alejandro Celaya
2012cc453c Fixed PHPStan errors due to API inconsistency in EntityManager and EntityManagerInterface 2017-12-27 17:22:51 +01:00
Alejandro Celaya
ea80b6d48a Replaced vlucas/phpdotenv package by symfony/dotenv 2017-12-27 16:33:06 +01:00
Alejandro Celaya
db956a1f40 Fixed all possible PHPStan errors 2017-12-27 16:23:54 +01:00
Alejandro Celaya
4f3995ea80 Fixed phpstan errors in ListKeysCommand 2017-12-27 15:56:26 +01:00
Alejandro Celaya
e024ba5d94 Added phpstan to build matrix 2017-12-27 15:43:59 +01:00
Alejandro Celaya
af0ff0f65b Console commands are now lazy loaded 2017-12-27 15:37:26 +01:00
Alejandro Celaya
a9094dc0f6 Updated dependency constraints 2017-12-27 15:25:59 +01:00
Alejandro Celaya
aca90ef907 Merge pull request #111 from shlinkio/develop
Develop
2017-10-25 16:25:39 +02:00
Alejandro Celaya
ddfccea901 Updated changelog 2017-10-25 16:21:34 +02:00
Alejandro Celaya
6c6cfb4fc3 Fixed typo 2017-10-25 16:19:08 +02:00
131 changed files with 2340 additions and 1449 deletions

1
.gitattributes vendored
View File

@@ -22,3 +22,4 @@ indocker export-ignore
phpcs.xml export-ignore phpcs.xml export-ignore
phpunit.xml.dist export-ignore phpunit.xml.dist export-ignore
phpunit-func.xml export-ignore phpunit-func.xml export-ignore
phpstan.neon

View File

@@ -2,8 +2,7 @@ language: php
branches: branches:
only: only:
- master - /.*/
- develop
php: php:
- 7 - 7
@@ -17,10 +16,12 @@ before_install:
before_script: before_script:
- composer self-update - composer self-update
- composer install --no-interaction - composer install --no-interaction
- if [[ $TRAVIS_PHP_VERSION = 7.1 ]] || [[ $TRAVIS_PHP_VERSION = 7.2 ]]; then composer global require --dev phpstan/phpstan:0.9.*; fi
script: script:
- mkdir build - mkdir build
- composer check - composer check
- if [[ $TRAVIS_PHP_VERSION = 7.1 ]] || [[ $TRAVIS_PHP_VERSION = 7.2 ]]; then ~/.composer/vendor/bin/phpstan analyse module/*/src/ --level=6 -c phpstan.neon; fi
after_script: after_script:
- vendor/bin/phpcov merge build --clover build/clover.xml - vendor/bin/phpcov merge build --clover build/clover.xml

View File

@@ -1,8 +1,50 @@
## CHANGELOG ## CHANGELOG
### 1.7.2
**Bugs:**
* [135: Fix PathVersionMiddleware being ignored when using expressive 2.2](https://github.com/shlinkio/shlink/issues/135)
### 1.7.1
**Enhancements:**
* [128: Upgrade to expressive 2.2](https://github.com/shlinkio/shlink/issues/128)
**Bugs**
* [126: Expressive 2.2 causes failures by triggering E_USER_DEPRECATED errors](https://github.com/shlinkio/shlink/issues/126)
### 1.7.0
**Features**
* [88: Allow to disable tracking of the short URL by including a configurable query param](https://github.com/shlinkio/shlink/issues/88)
* [108: Allow to edit metadata in created shortcodes](https://github.com/shlinkio/shlink/issues/108)
**Enhancements:**
* [113: Update CLI commands to use SymfonyStyle](https://github.com/shlinkio/shlink/issues/113)
* [112: Configure cli commands lazy loading](https://github.com/shlinkio/shlink/issues/112)
**Tasks**
* [117: Make every module which throws exceptions have its own ExceptionInterface, and make them all extend Throwable](https://github.com/shlinkio/shlink/issues/117)
* [115: Add phpstan to build matrix on PHP >=7.1 envs](https://github.com/shlinkio/shlink/issues/115)
* [114: Replace vlucas/phpdotenv dev requirement by symfony/env](https://github.com/shlinkio/shlink/issues/114)
### 1.6.2
**Bugs**
* [109: Fix installation error due to typo in latest migration](https://github.com/shlinkio/shlink/issues/109)
### 1.6.1 ### 1.6.1
* Added gitattributes file to avoid files not needed in production to be included in distribution **Tasks**
* [110: Create gitattributes file to define files to be excluded from distributable package](https://github.com/shlinkio/shlink/issues/110)
### 1.6.0 ### 1.6.0

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
declare(strict_types=1);
use Interop\Container\ContainerInterface; use Interop\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp; use Symfony\Component\Console\Application as CliApp;

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory; use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizerPlugin; use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory; use Zend\ServiceManager\Factory\InvokableFactory;
@@ -17,12 +17,11 @@ $container = new ServiceManager([
'factories' => [ 'factories' => [
Application::class => InstallApplicationFactory::class, Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class, Filesystem::class => InvokableFactory::class,
QuestionHelper::class => InvokableFactory::class,
], ],
'services' => [ 'services' => [
'config' => [ 'config' => [
ConfigAbstractFactory::class => [ ConfigAbstractFactory::class => [
DatabaseConfigCustomizerPlugin::class => [QuestionHelper::class, Filesystem::class] DatabaseConfigCustomizer::class => [Filesystem::class]
], ],
], ],
], ],

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory; use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizerPlugin; use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory; use Zend\ServiceManager\Factory\InvokableFactory;
@@ -17,12 +17,11 @@ $container = new ServiceManager([
'factories' => [ 'factories' => [
Application::class => InstallApplicationFactory::class, Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class, Filesystem::class => InvokableFactory::class,
QuestionHelper::class => InvokableFactory::class,
], ],
'services' => [ 'services' => [
'config' => [ 'config' => [
ConfigAbstractFactory::class => [ ConfigAbstractFactory::class => [
DatabaseConfigCustomizerPlugin::class => [QuestionHelper::class, Filesystem::class] DatabaseConfigCustomizer::class => [Filesystem::class]
], ],
], ],
], ],

View File

@@ -15,13 +15,13 @@
"php": "^7.0", "php": "^7.0",
"acelaya/ze-content-based-error-handler": "^2.0", "acelaya/ze-content-based-error-handler": "^2.0",
"cocur/slugify": "^3.0", "cocur/slugify": "^3.0",
"doctrine/annotations": "^1.4 <1.5", "doctrine/annotations": "^1.4",
"doctrine/cache": "^1.6 <1.7", "doctrine/cache": "^1.6",
"doctrine/collections": "^1.4 <1.5", "doctrine/collections": "^1.4",
"doctrine/common": "^2.7 <2.8", "doctrine/common": "^2.7",
"doctrine/dbal": "^2.5 <2.6", "doctrine/dbal": "^2.5",
"doctrine/migrations": "^1.4", "doctrine/migrations": "^1.4",
"doctrine/orm": "^2.5 <2.6", "doctrine/orm": "^2.5",
"endroid/qrcode": "^1.7", "endroid/qrcode": "^1.7",
"firebase/php-jwt": "^4.0", "firebase/php-jwt": "^4.0",
"guzzlehttp/guzzle": "^6.2", "guzzlehttp/guzzle": "^6.2",
@@ -29,7 +29,7 @@
"mikehaertl/phpwkhtmltopdf": "^2.2", "mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21", "monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"symfony/console": "^3.0", "symfony/console": "^3.4",
"symfony/filesystem": "^3.0", "symfony/filesystem": "^3.0",
"symfony/process": "^3.0", "symfony/process": "^3.0",
"theorchard/monolog-cascade": "^0.4", "theorchard/monolog-cascade": "^0.4",
@@ -40,8 +40,9 @@
"zendframework/zend-expressive-helpers": "^4.2", "zendframework/zend-expressive-helpers": "^4.2",
"zendframework/zend-expressive-platesrenderer": "^1.3", "zendframework/zend-expressive-platesrenderer": "^1.3",
"zendframework/zend-i18n": "^2.7", "zendframework/zend-i18n": "^2.7",
"zendframework/zend-inputfilter": "^2.8",
"zendframework/zend-paginator": "^2.6", "zendframework/zend-paginator": "^2.6",
"zendframework/zend-servicemanager": "^3.0", "zendframework/zend-servicemanager": "^3.2",
"zendframework/zend-stdlib": "^3.0" "zendframework/zend-stdlib": "^3.0"
}, },
"require-dev": { "require-dev": {
@@ -50,9 +51,9 @@
"phpunit/phpcov": "^4.0", "phpunit/phpcov": "^4.0",
"phpunit/phpunit": "^6.0", "phpunit/phpunit": "^6.0",
"slevomat/coding-standard": "^4.0", "slevomat/coding-standard": "^4.0",
"squizlabs/php_codesniffer": "^3.1", "squizlabs/php_codesniffer": "^3.1 <3.2",
"symfony/dotenv": "^3.4",
"symfony/var-dumper": "^3.0", "symfony/var-dumper": "^3.0",
"vlucas/phpdotenv": "^2.2",
"zendframework/zend-expressive-tooling": "^0.4" "zendframework/zend-expressive-tooling": "^0.4"
}, },
"autoload": { "autoload": {
@@ -102,7 +103,7 @@
"process-timeout": 0, "process-timeout": 0,
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "7.0" "php": "7.0.8"
} }
} }
} }

View File

@@ -1,14 +1,14 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use Shlinkio\Shlink\Common; use function Shlinkio\Shlink\Common\env;
return [ return [
'app_options' => [ 'app_options' => [
'name' => 'Shlink', 'name' => 'Shlink',
'version' => '1.2.0', 'version' => '1.7.0',
'secret_key' => Common\env('SECRET_KEY'), 'secret_key' => env('SECRET_KEY'),
], ],
]; ];

View File

@@ -8,6 +8,7 @@ use Zend\Expressive\Helper;
use Zend\Expressive\Middleware; use Zend\Expressive\Middleware;
use Zend\Expressive\Plates; use Zend\Expressive\Plates;
use Zend\Expressive\Router; use Zend\Expressive\Router;
use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
use Zend\Expressive\Template; use Zend\Expressive\Template;
use Zend\ServiceManager\Factory\InvokableFactory; use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\Stratigility\Middleware\ErrorHandler; use Zend\Stratigility\Middleware\ErrorHandler;
@@ -20,11 +21,15 @@ return [
Template\TemplateRendererInterface::class => Plates\PlatesRendererFactory::class, Template\TemplateRendererInterface::class => Plates\PlatesRendererFactory::class,
Router\RouterInterface::class => Router\FastRouteRouterFactory::class, Router\RouterInterface::class => Router\FastRouteRouterFactory::class,
ErrorHandler::class => Container\ErrorHandlerFactory::class, ErrorHandler::class => Container\ErrorHandlerFactory::class,
Middleware\ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class, ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class,
Helper\UrlHelper::class => Helper\UrlHelperFactory::class, Helper\UrlHelper::class => Helper\UrlHelperFactory::class,
Helper\ServerUrlHelper::class => InvokableFactory::class, Helper\ServerUrlHelper::class => InvokableFactory::class,
], ],
'aliases' => [
Middleware\ImplicitOptionsMiddleware::class => ImplicitOptionsMiddleware::class,
],
], ],
]; ];

View File

@@ -20,7 +20,7 @@ return [
'priority' => 11, 'priority' => 11,
], ],
'pre-routing-rest' => [ 'pre-routing-rest' => [
'path' => '/rest', // 'path' => '/rest',
'middleware' => [ 'middleware' => [
PathVersionMiddleware::class, PathVersionMiddleware::class,
], ],
@@ -29,7 +29,7 @@ return [
'routing' => [ 'routing' => [
'middleware' => [ 'middleware' => [
Expressive\Application::ROUTING_MIDDLEWARE, Expressive\Router\Middleware\RouteMiddleware::class,
], ],
'priority' => 10, 'priority' => 10,
], ],
@@ -38,7 +38,7 @@ return [
'path' => '/rest', 'path' => '/rest',
'middleware' => [ 'middleware' => [
CrossDomainMiddleware::class, CrossDomainMiddleware::class,
Expressive\Middleware\ImplicitOptionsMiddleware::class, Expressive\Router\Middleware\ImplicitOptionsMiddleware::class,
BodyParserMiddleware::class, BodyParserMiddleware::class,
CheckAuthenticationMiddleware::class, CheckAuthenticationMiddleware::class,
], ],
@@ -47,7 +47,7 @@ return [
'post-routing' => [ 'post-routing' => [
'middleware' => [ 'middleware' => [
Expressive\Application::DISPATCH_MIDDLEWARE, Expressive\Router\Middleware\DispatchMiddleware::class,
], ],
'priority' => 1, 'priority' => 1,
], ],

View File

@@ -18,6 +18,8 @@ use Zend\ConfigAggregator;
*/ */
return (new ConfigAggregator\ConfigAggregator([ return (new ConfigAggregator\ConfigAggregator([
Zend\Expressive\ConfigProvider::class,
Zend\Expressive\Router\ConfigProvider::class,
ExpressiveErrorHandler\ConfigProvider::class, ExpressiveErrorHandler\ConfigProvider::class,
Common\ConfigProvider::class, Common\ConfigProvider::class,
Core\ConfigProvider::class, Core\ConfigProvider::class,

View File

@@ -1,7 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use Dotenv\Dotenv; use Symfony\Component\Dotenv\Dotenv;
use Zend\ServiceManager\ServiceManager; use Zend\ServiceManager\ServiceManager;
chdir(dirname(__DIR__)); chdir(dirname(__DIR__));
@@ -12,8 +12,8 @@ require 'vendor/autoload.php';
if (class_exists(Dotenv::class)) { if (class_exists(Dotenv::class)) {
error_reporting(E_ALL); error_reporting(E_ALL);
ini_set('display_errors', '1'); ini_set('display_errors', '1');
$dotenv = new Dotenv(__DIR__ . '/..'); $dotenv = new Dotenv();
$dotenv->load(); $dotenv->load(__DIR__ . '/../.env');
} }
// Build container // Build container

View File

@@ -20,7 +20,7 @@ class Version20171021093246 extends AbstractMigration
public function up(Schema $schema) public function up(Schema $schema)
{ {
$shortUrls = $schema->getTable('short_urls'); $shortUrls = $schema->getTable('short_urls');
if ($shortUrls->hasColumn('value_since')) { if ($shortUrls->hasColumn('valid_since')) {
return; return;
} }
@@ -39,7 +39,7 @@ class Version20171021093246 extends AbstractMigration
public function down(Schema $schema) public function down(Schema $schema)
{ {
$shortUrls = $schema->getTable('short_urls'); $shortUrls = $schema->getTable('short_urls');
if (! $shortUrls->hasColumn('value_since')) { if (! $shortUrls->hasColumn('valid_since')) {
return; return;
} }

View File

@@ -1,7 +0,0 @@
{
"name": "Authorization",
"in": "header",
"description": "The authorization token with Bearer type",
"required": true,
"type": "string"
}

View File

@@ -5,24 +5,39 @@
], ],
"summary": "Perform authentication", "summary": "Perform authentication",
"description": "Performs an authentication", "description": "Performs an authentication",
"parameters": [ "requestBody": {
{ "description": "Request body.",
"name": "apiKey", "required": true,
"in": "formData", "content": {
"description": "The API key to authenticate with", "application/json": {
"required": true, "schema": {
"type": "string" "type": "object",
"required": [
"apiKey"
],
"properties": {
"apiKey": {
"description": "The API key to authenticate with",
"type": "string"
}
}
}
}
} }
], },
"responses": { "responses": {
"200": { "200": {
"description": "The authentication worked.", "description": "The authentication worked.",
"schema": { "content": {
"type": "object", "application/json": {
"properties": { "schema": {
"token": { "type": "object",
"type": "string", "properties": {
"description": "The authentication token that needs to be sent in the Authorization header" "token": {
"type": "string",
"description": "The authentication token that needs to be sent in the Authorization header"
}
}
} }
} }
}, },
@@ -34,20 +49,32 @@
}, },
"400": { "400": {
"description": "An API key was not provided.", "description": "An API key was not provided.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"401": { "401": {
"description": "The API key is incorrect, is disabled or has expired.", "description": "The API key is incorrect, is disabled or has expired.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }

View File

@@ -11,59 +11,73 @@
"in": "query", "in": "query",
"description": "The page to be displayed. Defaults to 1", "description": "The page to be displayed. Defaults to 1",
"required": false, "required": false,
"type": "integer" "schema": {
"type": "integer"
}
}, },
{ {
"name": "searchTerm", "name": "searchTerm",
"in": "query", "in": "query",
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)", "description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
"required": false, "required": false,
"type": "string" "schema": {
"type": "string"
}
}, },
{ {
"name": "tags", "name": "tags",
"in": "query", "in": "query",
"description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)", "description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
"required": false, "required": false,
"type": "array", "schema": {
"items": { "type": "array",
"type": "string" "items": {
"type": "string"
}
} }
}, },
{ {
"name": "orderBy", "name": "orderBy",
"in": "query", "in": "query",
"description": "The field from which you want to order the result. (Since v1.3.0)", "description": "The field from which you want to order the result. (Since v1.3.0)",
"enum": [
"originalUrl",
"shortCode",
"dateCreated",
"visits"
],
"required": false, "required": false,
"type": "string" "schema": {
}, "type": "string",
"enum": [
"originalUrl",
"shortCode",
"dateCreated",
"visits"
]
}
}
],
"security": [
{ {
"$ref": "../parameters/Authorization.json" "Bearer": []
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "The list of short URLs", "description": "The list of short URLs",
"schema": { "content": {
"type": "object", "application/json": {
"properties": { "schema": {
"shortUrls": {
"type": "object", "type": "object",
"properties": { "properties": {
"data": { "shortUrls": {
"type": "array", "type": "object",
"items": { "properties": {
"$ref": "../definitions/ShortUrl.json" "data": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrl.json"
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}
} }
},
"pagination": {
"$ref": "../definitions/Pagination.json"
} }
} }
} }
@@ -110,71 +124,114 @@
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }
}, },
"post": { "post": {
"tags": [ "tags": [
"ShortCodes" "ShortCodes"
], ],
"summary": "Create short URL", "summary": "Create short URL",
"description": "Creates a new short code", "description": "Creates a new short code",
"parameters": [ "security": [
{ {
"name": "longUrl", "Bearer": []
"in": "formData",
"description": "The URL to parse",
"required": true,
"type": "string"
},
{
"name": "tags",
"in": "formData",
"description": "The URL to parse",
"required": false,
"type": "array",
"items": {
"type": "string"
}
},
{
"$ref": "../parameters/Authorization.json"
} }
], ],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"longUrl"
],
"properties": {
"longUrl": {
"description": "The URL to parse",
"type": "string"
},
"tags": {
"description": "The URL to parse",
"type": "array",
"items": {
"type": "string"
}
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string"
},
"customSlug": {
"description": "A unique custom slug to be used instead of the generated short code",
"type": "string"
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
}
}
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "The result of parsing the long URL", "description": "The result of parsing the long URL",
"schema": { "content": {
"type": "object", "application/json": {
"properties": { "schema": {
"longUrl": { "type": "object",
"type": "string", "properties": {
"description": "The original long URL that has been parsed" "longUrl": {
}, "type": "string",
"shortUrl": { "description": "The original long URL that has been parsed"
"type": "string", },
"description": "The generated short URL" "shortUrl": {
}, "type": "string",
"shortCode": { "description": "The generated short URL"
"type": "string", },
"description": "the short code that is being used in the short URL" "shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
}
}
} }
} }
} }
}, },
"400": { "400": {
"description": "The long URL was not provided or is invalid.", "description": "The long URL was not provided or is invalid.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }

View File

@@ -9,23 +9,31 @@
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "in": "path",
"type": "string",
"description": "The short code to resolve.", "description": "The short code to resolve.",
"required": true "required": true,
}, "schema": {
"type": "string"
}
}
],
"security": [
{ {
"$ref": "../parameters/Authorization.json" "Bearer": []
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "The long URL behind a short code.", "description": "The long URL behind a short code.",
"schema": { "content": {
"type": "object", "application/json": {
"properties": { "schema": {
"longUrl": { "type": "object",
"type": "string", "properties": {
"description": "The original long URL behind the short code." "longUrl": {
"type": "string",
"description": "The original long URL behind the short code."
}
}
} }
} }
}, },
@@ -37,20 +45,116 @@
}, },
"400": { "400": {
"description": "Provided shortCode does not match the character set currently used by the app to generate short codes.", "description": "Provided shortCode does not match the character set currently used by the app to generate short codes.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"404": { "404": {
"description": "No URL was found for provided short code.", "description": "No URL was found for provided short code.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
},
"put": {
"tags": [
"ShortCodes"
],
"summary": "Edit short code",
"description": "Update certain meta arguments from an existing short URL.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to edit.",
"required": true,
"schema": { "schema": {
"$ref": "../definitions/Error.json" "type": "string"
}
}
],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string"
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
}
}
}
}
}
},
"security": [
{
"Bearer": []
}
],
"responses": {
"204": {
"description": "The short code has been properly updated."
},
"400": {
"description": "Provided meta arguments are invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }

View File

@@ -10,34 +10,55 @@
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "in": "path",
"type": "string",
"description": "The shortCode in which we want to edit tags.", "description": "The shortCode in which we want to edit tags.",
"required": true "required": true,
}, "schema": {
{
"name": "tags",
"in": "formData",
"type": "array",
"items": {
"type": "string" "type": "string"
}, }
"description": "The list of tags to set to the short URL.", }
"required": true ],
}, "requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"tags"
],
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "The list of tags to set to the short URL."
}
}
}
}
}
},
"security": [
{ {
"$ref": "../parameters/Authorization.json" "Bearer": []
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "List of tags.", "description": "List of tags.",
"schema": { "content": {
"type": "object", "application/json": {
"properties": { "schema": {
"tags": { "type": "object",
"type": "array", "properties": {
"items": { "tags": {
"type": "string" "type": "array",
"items": {
"type": "string"
}
}
} }
} }
} }
@@ -53,20 +74,32 @@
}, },
"400": { "400": {
"description": "The request body does not contain a \"tags\" param with array type.", "description": "The request body does not contain a \"tags\" param with array type.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"404": { "404": {
"description": "No short URL was found for provided short code.", "description": "No short URL was found for provided short code.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }

View File

@@ -10,27 +10,35 @@
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "in": "path",
"type": "string",
"description": "The shortCode from which we want to get the visits.", "description": "The shortCode from which we want to get the visits.",
"required": true "required": true,
}, "schema": {
"type": "string"
}
}
],
"security": [
{ {
"$ref": "../parameters/Authorization.json" "Bearer": []
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "List of visits.", "description": "List of visits.",
"schema": { "content": {
"type": "object", "application/json": {
"properties": { "schema": {
"visits": {
"type": "object", "type": "object",
"properties": { "properties": {
"data": { "visits": {
"type": "array", "type": "object",
"items": { "properties": {
"$ref": "../definitions/Visit.json" "data": {
"type": "array",
"items": {
"$ref": "../definitions/Visit.json"
}
}
} }
} }
} }
@@ -66,14 +74,22 @@
}, },
"404": { "404": {
"description": "The short code does not belong to any short URL.", "description": "The short code does not belong to any short URL.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }

View File

@@ -5,24 +5,28 @@
], ],
"summary": "List existing tags", "summary": "List existing tags",
"description": "Returns the list of all tags used in any short URL, ordered by name", "description": "Returns the list of all tags used in any short URL, ordered by name",
"parameters": [ "security": [
{ {
"$ref": "../parameters/Authorization.json" "Bearer": []
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "The list of tags", "description": "The list of tags",
"schema": { "content": {
"type": "object", "application/json": {
"properties": { "schema": {
"tags": {
"type": "object", "type": "object",
"properties": { "properties": {
"data": { "tags": {
"type": "array", "type": "object",
"items": { "properties": {
"type": "string" "data": {
"type": "array",
"items": {
"type": "string"
}
}
} }
} }
} }
@@ -44,8 +48,12 @@
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }
@@ -57,31 +65,51 @@
], ],
"summary": "Create tags", "summary": "Create tags",
"description": "Provided a list of tags, creates all that do not yet exist", "description": "Provided a list of tags, creates all that do not yet exist",
"parameters": [ "security": [
{ {
"$ref": "../parameters/Authorization.json" "Bearer": []
},
{
"name": "tags[]",
"in": "formData",
"description": "The list of tag names to create",
"required": true,
"type": "array"
} }
], ],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"tags"
],
"properties": {
"tags": {
"description": "The list of tag names to create",
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "The list of tags", "description": "The list of tags",
"schema": { "content": {
"type": "object", "application/json": {
"properties": { "schema": {
"tags": {
"type": "object", "type": "object",
"properties": { "properties": {
"data": { "tags": {
"type": "array", "type": "object",
"items": { "properties": {
"type": "string" "data": {
"type": "array",
"items": {
"type": "string"
}
}
} }
} }
} }
@@ -103,8 +131,12 @@
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }
@@ -116,45 +148,68 @@
], ],
"summary": "Rename tag", "summary": "Rename tag",
"description": "Renames one existing tag", "description": "Renames one existing tag",
"parameters": [ "security": [
{ {
"$ref": "../parameters/Authorization.json" "Bearer": []
},
{
"name": "oldName",
"in": "formData",
"description": "Current name of the tag",
"required": true,
"type": "string"
},
{
"name": "newName",
"in": "formData",
"description": "New name of the tag",
"required": true,
"type": "string"
} }
], ],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"oldName",
"newName"
],
"properties": {
"oldName": {
"description": "Current name of the tag",
"type": "string"
},
"newName": {
"description": "New name of the tag",
"type": "string"
}
}
}
}
}
},
"responses": { "responses": {
"204": { "204": {
"description": "The tag has been properly renamed" "description": "The tag has been properly renamed"
}, },
"400": { "400": {
"description": "You have not provided either the oldName or the newName params.", "description": "You have not provided either the oldName or the newName params.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"404": { "404": {
"description": "There's no tag found with the name provided in oldName param.", "description": "There's no tag found with the name provided in oldName param.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }
@@ -167,15 +222,22 @@
"summary": "Delete tags", "summary": "Delete tags",
"description": "Deletes provided list of tags", "description": "Deletes provided list of tags",
"parameters": [ "parameters": [
{
"$ref": "../parameters/Authorization.json"
},
{ {
"name": "tags[]", "name": "tags[]",
"in": "query", "in": "query",
"description": "The names of the tags to delete", "description": "The names of the tags to delete",
"required": true, "required": true,
"type": "array" "schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
],
"security": [
{
"Bearer": []
} }
], ],
"responses": { "responses": {
@@ -184,8 +246,12 @@
}, },
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"schema": { "content": {
"$ref": "../definitions/Error.json" "application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
} }
} }
} }

View File

@@ -1,23 +1,37 @@
{ {
"swagger": "2.0", "openapi": "3.0.0",
"info": { "info": {
"title": "Shlink", "title": "Shlink",
"description": "Shlink, the self-hosted URL shortener", "description": "Shlink, the self-hosted URL shortener",
"version": "1.0" "version": "1.0"
}, },
"schemes": [
"http", "servers": [
"https" {
], "url": "{schema}://{server}/rest",
"basePath": "/rest", "variables": {
"produces": [ "schema": {
"application/json" "default": "https",
], "enum": ["https", "http"]
"consumes": [ },
"application/x-www-form-urlencoded", "server": {
"application/json" "default": ""
}
}
}
], ],
"components": {
"securitySchemes": {
"Bearer": {
"description": "The JWT identifying a previously logged API key",
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"paths": { "paths": {
"/v1/authenticate": { "/v1/authenticate": {
"$ref": "paths/v1_authenticate.json" "$ref": "paths/v1_authenticate.json"

View File

@@ -9,21 +9,25 @@ return [
'cli' => [ 'cli' => [
'locale' => Common\env('CLI_LOCALE', 'en'), 'locale' => Common\env('CLI_LOCALE', 'en'),
'commands' => [ 'commands' => [
Command\Shortcode\GenerateShortcodeCommand::class, Command\Shortcode\GenerateShortcodeCommand::NAME => Command\Shortcode\GenerateShortcodeCommand::class,
Command\Shortcode\ResolveUrlCommand::class, Command\Shortcode\ResolveUrlCommand::NAME => Command\Shortcode\ResolveUrlCommand::class,
Command\Shortcode\ListShortcodesCommand::class, Command\Shortcode\ListShortcodesCommand::NAME => Command\Shortcode\ListShortcodesCommand::class,
Command\Shortcode\GetVisitsCommand::class, Command\Shortcode\GetVisitsCommand::NAME => Command\Shortcode\GetVisitsCommand::class,
Command\Shortcode\GeneratePreviewCommand::class, Command\Shortcode\GeneratePreviewCommand::NAME => Command\Shortcode\GeneratePreviewCommand::class,
Command\Visit\ProcessVisitsCommand::class,
Command\Config\GenerateCharsetCommand::class, Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
Command\Config\GenerateSecretCommand::class,
Command\Api\GenerateKeyCommand::class, Command\Config\GenerateCharsetCommand::NAME => Command\Config\GenerateCharsetCommand::class,
Command\Api\DisableKeyCommand::class, Command\Config\GenerateSecretCommand::NAME => Command\Config\GenerateSecretCommand::class,
Command\Api\ListKeysCommand::class,
Command\Tag\ListTagsCommand::class, Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Tag\CreateTagCommand::class, Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
Command\Tag\RenameTagCommand::class, Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
Command\Tag\DeleteTagsCommand::class,
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
Command\Tag\CreateTagCommand::NAME => Command\Tag\CreateTagCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
], ],
], ],

Binary file not shown.

View File

@@ -1,15 +1,15 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Shlink 1.0\n" "Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2017-10-21 20:17+0200\n" "POT-Creation-Date: 2018-01-21 09:36+0100\n"
"PO-Revision-Date: 2017-10-21 20:19+0200\n" "PO-Revision-Date: 2018-01-21 09:39+0100\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n" "Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n" "Language-Team: \n"
"Language: es_ES\n" "Language: es_ES\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.1\n" "X-Generator: Poedit 2.0.4\n"
"X-Poedit-Basepath: ..\n" "X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n" "X-Poedit-SourceCharset: UTF-8\n"
@@ -24,8 +24,8 @@ msgid "The API key to disable"
msgstr "La clave de API a deshabilitar" msgstr "La clave de API a deshabilitar"
#, php-format #, php-format
msgid "API key %s properly disabled" msgid "API key \"%s\" properly disabled"
msgstr "Clave de API %s deshabilitada correctamente" msgstr "Clave de API \"%s\" deshabilitada correctamente"
#, php-format #, php-format
msgid "API key \"%s\" does not exist." msgid "API key \"%s\" does not exist."
@@ -39,8 +39,9 @@ msgstr ""
"La fecha en la que la clave de API debe expirar. Utiliza cualquier valor " "La fecha en la que la clave de API debe expirar. Utiliza cualquier valor "
"válido en PHP." "válido en PHP."
msgid "Generated API key" #, php-format
msgstr "Generada clave de API" msgid "Generated API key: \"%s\""
msgstr "Generada clave de API. \"%s\""
msgid "Lists all the available API keys." msgid "Lists all the available API keys."
msgstr "Lista todas las claves de API disponibles." msgstr "Lista todas las claves de API disponibles."
@@ -51,12 +52,12 @@ msgstr "Define si sólo las claves de API habilitadas deben ser devueltas."
msgid "Key" msgid "Key"
msgstr "Clave" msgstr "Clave"
msgid "Expiration date"
msgstr "Fecha de caducidad"
msgid "Is enabled" msgid "Is enabled"
msgstr "Está habilitada" msgstr "Está habilitada"
msgid "Expiration date"
msgstr "Fecha de caducidad"
#, php-format #, php-format
msgid "" msgid ""
"Generates a character set sample just by shuffling the default one, \"%s\". " "Generates a character set sample just by shuffling the default one, \"%s\". "
@@ -65,8 +66,9 @@ msgstr ""
"Genera un grupo de caracteres simplemente mexclando el grupo por defecto \"%s" "Genera un grupo de caracteres simplemente mexclando el grupo por defecto \"%s"
"\". Después puede ser utilizado en la variable de entrono SHORTCODE_CHARS" "\". Después puede ser utilizado en la variable de entrono SHORTCODE_CHARS"
msgid "Character set:" #, php-format
msgstr "Grupo de caracteres:" msgid "Character set: \"%s\""
msgstr "Grupo de caracteres: \"%s\""
msgid "" msgid ""
"Generates a random secret string that can be used for JWT token encryption" "Generates a random secret string that can be used for JWT token encryption"
@@ -74,8 +76,9 @@ msgstr ""
"Genera una cadena de caracteres aleatoria que puede ser usada para cifrar " "Genera una cadena de caracteres aleatoria que puede ser usada para cifrar "
"tokens JWT" "tokens JWT"
msgid "Secret key:" #, php-format
msgstr "Clave secreta:" msgid "Secret key: \"%s\""
msgstr "Clave secreta: \"%s\""
msgid "" msgid ""
"Processes and generates the previews for every URL, improving performance " "Processes and generates the previews for every URL, improving performance "
@@ -125,17 +128,22 @@ msgid "If provided, this slug will be used instead of generating a short code"
msgstr "" msgstr ""
"Si se proporciona, este slug será usado en vez de generar un código corto" "Si se proporciona, este slug será usado en vez de generar un código corto"
msgid "A long URL was not provided. Which URL do you want to shorten?:" msgid "This will limit the number of visits for this short URL."
msgstr "Esto limitará el número de visitas a esta URL acortada."
#, fuzzy
#| msgid "A long URL was not provided. Which URL do you want to shorten?:"
msgid "A long URL was not provided. Which URL do you want to be shortened?"
msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?" msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?"
msgid "A URL was not provided!" msgid "A URL was not provided!"
msgstr "¡No se ha proporcionado una URL!" msgstr "¡No se ha proporcionado una URL!"
msgid "Processed URL:" msgid "Processed long URL:"
msgstr "URL procesada:" msgstr "URL larga procesada:"
msgid "Generated URL:" msgid "Generated short URL:"
msgstr "URL generada:" msgstr "URL corta generada:"
#, php-format #, php-format
msgid "Provided URL \"%s\" is invalid. Try with a different one." msgid "Provided URL \"%s\" is invalid. Try with a different one."
@@ -166,8 +174,8 @@ msgid "Allows to filter visits, returning only those newer than end date"
msgstr "" msgstr ""
"Permite filtrar las visitas, devolviendo sólo aquellas más nuevas que endDate" "Permite filtrar las visitas, devolviendo sólo aquellas más nuevas que endDate"
msgid "A short code was not provided. Which short code do you want to use?:" msgid "A short code was not provided. Which short code do you want to use?"
msgstr "No se prporcionó un código corto. ¿Qué código corto deseas usar?:" msgstr "No se proporcionó un código corto. ¿Qué código corto deseas usar?"
msgid "Referer" msgid "Referer"
msgstr "Origen" msgstr "Origen"
@@ -222,8 +230,8 @@ msgstr "Número de visitas"
msgid "Tags" msgid "Tags"
msgstr "Etiquetas" msgstr "Etiquetas"
msgid "You have reached last page" msgid "Short codes properly listed"
msgstr "Has alcanzado la última página" msgstr "Códigos cortos correctamente listados"
msgid "Continue with page" msgid "Continue with page"
msgstr "Continuar con la página" msgstr "Continuar con la página"
@@ -234,13 +242,9 @@ msgstr "Devuelve la URL larga detrás de un código corto"
msgid "The short code to parse" msgid "The short code to parse"
msgstr "El código corto a convertir" msgstr "El código corto a convertir"
msgid "A short code was not provided. Which short code do you want to parse?:" msgid "A short code was not provided. Which short code do you want to parse?"
msgstr "" msgstr ""
"No se proporcionó un código corto. ¿Qué código corto quieres convertir?:" "No se proporcionó un código corto. ¿Qué código corto quieres convertir?"
#, php-format
msgid "No URL found for short code \"%s\""
msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
msgid "Long URL:" msgid "Long URL:"
msgstr "URL larga:" msgstr "URL larga:"
@@ -262,8 +266,8 @@ msgstr "El nombre de las etiquetas a crear"
msgid "You have to provide at least one tag name" msgid "You have to provide at least one tag name"
msgstr "Debes proporcionar al menos un nombre de etiqueta" msgstr "Debes proporcionar al menos un nombre de etiqueta"
msgid "Created tags" msgid "Tags properly created"
msgstr "Etiquetas creadas" msgstr "Etiquetas correctamente creadas"
msgid "Deletes one or more tags." msgid "Deletes one or more tags."
msgstr "Elimina una o más etiquetas." msgstr "Elimina una o más etiquetas."
@@ -271,8 +275,8 @@ msgstr "Elimina una o más etiquetas."
msgid "The name of the tags to delete" msgid "The name of the tags to delete"
msgstr "El nombre de las etiquetas a eliminar" msgstr "El nombre de las etiquetas a eliminar"
msgid "Deleted tags" msgid "Tags properly deleted"
msgstr "Etiquetas eliminadas" msgstr "Etiquetas correctamente eliminadas"
msgid "Lists existing tags." msgid "Lists existing tags."
msgstr "Lista las etiquetas existentes." msgstr "Lista las etiquetas existentes."
@@ -315,3 +319,15 @@ msgstr "Dirección localizada en \"%s\""
msgid "Finished processing all IPs" msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs" msgstr "Finalizado el procesado de todas las IPs"
#~ msgid "You have reached last page"
#~ msgstr "Has alcanzado la última página"
#~ msgid "No URL found for short code \"%s\""
#~ msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
#~ msgid "Created tags"
#~ msgstr "Etiquetas creadas"
#~ msgid "Deleted tags"
#~ msgstr "Etiquetas eliminadas"

View File

@@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class DisableKeyCommand extends Command class DisableKeyCommand extends Command
{ {
const NAME = 'api-key:disable';
/** /**
* @var ApiKeyServiceInterface * @var ApiKeyServiceInterface
*/ */
@@ -30,7 +33,7 @@ class DisableKeyCommand extends Command
public function configure() public function configure()
{ {
$this->setName('api-key:disable') $this->setName(self::NAME)
->setDescription($this->translator->translate('Disables an API key.')) ->setDescription($this->translator->translate('Disables an API key.'))
->addArgument('apiKey', InputArgument::REQUIRED, $this->translator->translate('The API key to disable')); ->addArgument('apiKey', InputArgument::REQUIRED, $this->translator->translate('The API key to disable'));
} }
@@ -38,18 +41,13 @@ class DisableKeyCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$apiKey = $input->getArgument('apiKey'); $apiKey = $input->getArgument('apiKey');
$io = new SymfonyStyle($input, $output);
try { try {
$this->apiKeyService->disable($apiKey); $this->apiKeyService->disable($apiKey);
$output->writeln(sprintf( $io->success(sprintf($this->translator->translate('API key "%s" properly disabled'), $apiKey));
$this->translator->translate('API key %s properly disabled'),
'<info>' . $apiKey . '</info>'
));
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
$output->writeln(sprintf( $io->error(sprintf($this->translator->translate('API key "%s" does not exist.'), $apiKey));
'<error>' . $this->translator->translate('API key "%s" does not exist.') . '</error>',
$apiKey
));
} }
} }
} }

View File

@@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class GenerateKeyCommand extends Command class GenerateKeyCommand extends Command
{ {
const NAME = 'api-key:generate';
/** /**
* @var ApiKeyServiceInterface * @var ApiKeyServiceInterface
*/ */
@@ -30,7 +33,7 @@ class GenerateKeyCommand extends Command
public function configure() public function configure()
{ {
$this->setName('api-key:generate') $this->setName(self::NAME)
->setDescription($this->translator->translate('Generates a new valid API key.')) ->setDescription($this->translator->translate('Generates a new valid API key.'))
->addOption( ->addOption(
'expirationDate', 'expirationDate',
@@ -44,6 +47,9 @@ class GenerateKeyCommand extends Command
{ {
$expirationDate = $input->getOption('expirationDate'); $expirationDate = $input->getOption('expirationDate');
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? new \DateTime($expirationDate) : null); $apiKey = $this->apiKeyService->create(isset($expirationDate) ? new \DateTime($expirationDate) : null);
$output->writeln($this->translator->translate('Generated API key') . sprintf(': <info>%s</info>', $apiKey));
(new SymfonyStyle($input, $output))->success(
sprintf($this->translator->translate('Generated API key: "%s"'), $apiKey)
);
} }
} }

View File

@@ -6,14 +6,16 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class ListKeysCommand extends Command class ListKeysCommand extends Command
{ {
const NAME = 'api-key:list';
/** /**
* @var ApiKeyServiceInterface * @var ApiKeyServiceInterface
*/ */
@@ -32,7 +34,7 @@ class ListKeysCommand extends Command
public function configure() public function configure()
{ {
$this->setName('api-key:list') $this->setName(self::NAME)
->setDescription($this->translator->translate('Lists all the available API keys.')) ->setDescription($this->translator->translate('Lists all the available API keys.'))
->addOption( ->addOption(
'enabledOnly', 'enabledOnly',
@@ -44,78 +46,75 @@ class ListKeysCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$enabledOnly = $input->getOption('enabledOnly'); $enabledOnly = $input->getOption('enabledOnly');
$list = $this->apiKeyService->listKeys($enabledOnly); $list = $this->apiKeyService->listKeys($enabledOnly);
$rows = [];
$table = new Table($output);
if ($enabledOnly) {
$table->setHeaders([
$this->translator->translate('Key'),
$this->translator->translate('Expiration date'),
]);
} else {
$table->setHeaders([
$this->translator->translate('Key'),
$this->translator->translate('Is enabled'),
$this->translator->translate('Expiration date'),
]);
}
/** @var ApiKey $row */ /** @var ApiKey $row */
foreach ($list as $row) { foreach ($list as $row) {
$key = $row->getKey(); $key = $row->getKey();
$expiration = $row->getExpirationDate(); $expiration = $row->getExpirationDate();
$rowData = []; $formatMethod = $this->determineFormatMethod($row);
$formatMethod = ! $row->isEnabled()
? 'getErrorString'
: ($row->isExpired() ? 'getWarningString' : 'getSuccessString');
if ($enabledOnly) { // Set columns for this row
$rowData[] = $this->{$formatMethod}($key); $rowData = [$formatMethod($key)];
} else { if (! $enabledOnly) {
$rowData[] = $this->{$formatMethod}($key); $rowData[] = $formatMethod($this->getEnabledSymbol($row));
$rowData[] = $this->{$formatMethod}($this->getEnabledSymbol($row));
} }
$rowData[] = $expiration !== null ? $expiration->format(\DateTime::ATOM) : '-';
$rowData[] = isset($expiration) ? $expiration->format(\DateTime::ATOM) : '-'; $rows[] = $rowData;
$table->addRow($rowData);
} }
$table->render(); $io->table(array_filter([
$this->translator->translate('Key'),
! $enabledOnly ? $this->translator->translate('Is enabled') : null,
$this->translator->translate('Expiration date'),
]), $rows);
}
private function determineFormatMethod(ApiKey $apiKey): callable
{
if (! $apiKey->isEnabled()) {
return [$this, 'getErrorString'];
}
return $apiKey->isExpired() ? [$this, 'getWarningString'] : [$this, 'getSuccessString'];
} }
/** /**
* @param string $string * @param string $value
* @return string * @return string
*/ */
protected function getErrorString($string) private function getErrorString(string $value): string
{ {
return sprintf('<fg=red>%s</>', $string); return sprintf('<fg=red>%s</>', $value);
} }
/** /**
* @param string $string * @param string $value
* @return string * @return string
*/ */
protected function getSuccessString($string) private function getSuccessString(string $value): string
{ {
return sprintf('<info>%s</info>', $string); return sprintf('<info>%s</info>', $value);
} }
/** /**
* @param $string * @param string $value
* @return string * @return string
*/ */
protected function getWarningString($string) private function getWarningString(string $value): string
{ {
return sprintf('<comment>%s</comment>', $string); return sprintf('<comment>%s</comment>', $value);
} }
/** /**
* @param ApiKey $apiKey * @param ApiKey $apiKey
* @return string * @return string
*/ */
protected function getEnabledSymbol(ApiKey $apiKey) private function getEnabledSymbol(ApiKey $apiKey): string
{ {
return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++'; return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++';
} }

View File

@@ -7,10 +7,13 @@ use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class GenerateCharsetCommand extends Command class GenerateCharsetCommand extends Command
{ {
const NAME = 'config:generate-charset';
/** /**
* @var TranslatorInterface * @var TranslatorInterface
*/ */
@@ -24,7 +27,7 @@ class GenerateCharsetCommand extends Command
public function configure() public function configure()
{ {
$this->setName('config:generate-charset') $this->setName(self::NAME)
->setDescription(sprintf($this->translator->translate( ->setDescription(sprintf($this->translator->translate(
'Generates a character set sample just by shuffling the default one, "%s". ' 'Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable' . 'Then it can be set in the SHORTCODE_CHARS environment variable'
@@ -34,6 +37,8 @@ class GenerateCharsetCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS); $charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
$output->writeln($this->translator->translate('Character set:') . sprintf(' <info>%s</info>', $charSet)); (new SymfonyStyle($input, $output))->success(
\sprintf($this->translator->translate('Character set: "%s"'), $charSet)
);
} }
} }

View File

@@ -7,12 +7,15 @@ use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class GenerateSecretCommand extends Command class GenerateSecretCommand extends Command
{ {
use StringUtilsTrait; use StringUtilsTrait;
const NAME = 'config:generate-secret';
/** /**
* @var TranslatorInterface * @var TranslatorInterface
*/ */
@@ -26,7 +29,7 @@ class GenerateSecretCommand extends Command
public function configure() public function configure()
{ {
$this->setName('config:generate-secret') $this->setName(self::NAME)
->setDescription($this->translator->translate( ->setDescription($this->translator->translate(
'Generates a random secret string that can be used for JWT token encryption' 'Generates a random secret string that can be used for JWT token encryption'
)); ));
@@ -35,6 +38,8 @@ class GenerateSecretCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$secret = $this->generateRandomString(32); $secret = $this->generateRandomString(32);
$output->writeln($this->translator->translate('Secret key:') . sprintf(' <info>%s</info>', $secret)); (new SymfonyStyle($input, $output))->success(
sprintf($this->translator->translate('Secret key: "%s"'), $secret)
);
} }
} }

View File

@@ -3,18 +3,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Install; namespace Shlinkio\Shlink\CLI\Command\Install;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManagerInterface; use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\CLI\Install\Plugin; use Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig; use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Zend\Config\Writer\WriterInterface; use Zend\Config\Writer\WriterInterface;
@@ -24,17 +25,9 @@ class InstallCommand extends Command
const GENERATED_CONFIG_PATH = 'config/params/generated_config.php'; const GENERATED_CONFIG_PATH = 'config/params/generated_config.php';
/** /**
* @var InputInterface * @var SymfonyStyle
*/ */
private $input; private $io;
/**
* @var OutputInterface
*/
private $output;
/**
* @var QuestionHelper
*/
private $questionHelper;
/** /**
* @var ProcessHelper * @var ProcessHelper
*/ */
@@ -48,7 +41,7 @@ class InstallCommand extends Command
*/ */
private $filesystem; private $filesystem;
/** /**
* @var ConfigCustomizerPluginManagerInterface * @var ConfigCustomizerManagerInterface
*/ */
private $configCustomizers; private $configCustomizers;
/** /**
@@ -60,13 +53,14 @@ class InstallCommand extends Command
* InstallCommand constructor. * InstallCommand constructor.
* @param WriterInterface $configWriter * @param WriterInterface $configWriter
* @param Filesystem $filesystem * @param Filesystem $filesystem
* @param ConfigCustomizerManagerInterface $configCustomizers
* @param bool $isUpdate * @param bool $isUpdate
* @throws LogicException * @throws LogicException
*/ */
public function __construct( public function __construct(
WriterInterface $configWriter, WriterInterface $configWriter,
Filesystem $filesystem, Filesystem $filesystem,
ConfigCustomizerPluginManagerInterface $configCustomizers, ConfigCustomizerManagerInterface $configCustomizers,
$isUpdate = false $isUpdate = false
) { ) {
parent::__construct(); parent::__construct();
@@ -83,30 +77,34 @@ class InstallCommand extends Command
->setDescription('Installs or updates Shlink'); ->setDescription('Installs or updates Shlink');
} }
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return void
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$this->input = $input; $this->io = new SymfonyStyle($input, $output);
$this->output = $output;
$this->questionHelper = $this->getHelper('question');
$this->processHelper = $this->getHelper('process');
$output->writeln([ $this->io->writeln([
'<info>Welcome to Shlink!!</info>', '<info>Welcome to Shlink!!</info>',
'This will guide you through the installation process.', 'This will guide you through the installation process.',
]); ]);
// Check if a cached config file exists and drop it if so // Check if a cached config file exists and drop it if so
if ($this->filesystem->exists('data/cache/app_config.php')) { if ($this->filesystem->exists('data/cache/app_config.php')) {
$output->write('Deleting old cached config...'); $this->io->write('Deleting old cached config...');
try { try {
$this->filesystem->remove('data/cache/app_config.php'); $this->filesystem->remove('data/cache/app_config.php');
$output->writeln(' <info>Success</info>'); $this->io->writeln(' <info>Success</info>');
} catch (IOException $e) { } catch (IOException $e) {
$output->writeln( $this->io->error(
' <error>Failed!</error> You will have to manually delete the data/cache/app_config.php file to get' 'Failed! You will have to manually delete the data/cache/app_config.php file to'
. ' new config applied.' . ' get new config applied.'
); );
if ($output->isVerbose()) { if ($this->io->isVerbose()) {
$this->getApplication()->renderException($e, $output); $this->getApplication()->renderException($e, $output);
} }
return; return;
@@ -118,56 +116,65 @@ class InstallCommand extends Command
// Ask for custom config params // Ask for custom config params
foreach ([ foreach ([
Plugin\DatabaseConfigCustomizerPlugin::class, Plugin\DatabaseConfigCustomizer::class,
Plugin\UrlShortenerConfigCustomizerPlugin::class, Plugin\UrlShortenerConfigCustomizer::class,
Plugin\LanguageConfigCustomizerPlugin::class, Plugin\LanguageConfigCustomizer::class,
Plugin\ApplicationConfigCustomizerPlugin::class, Plugin\ApplicationConfigCustomizer::class,
] as $pluginName) { ] as $pluginName) {
/** @var Plugin\ConfigCustomizerPluginInterface $configCustomizer */ /** @var Plugin\ConfigCustomizerInterface $configCustomizer */
$configCustomizer = $this->configCustomizers->get($pluginName); $configCustomizer = $this->configCustomizers->get($pluginName);
$configCustomizer->process($input, $output, $config); $configCustomizer->process($this->io, $config);
} }
// Generate config params files // Generate config params files
$this->configWriter->toFile(self::GENERATED_CONFIG_PATH, $config->getArrayCopy(), false); $this->configWriter->toFile(self::GENERATED_CONFIG_PATH, $config->getArrayCopy(), false);
$output->writeln(['<info>Custom configuration properly generated!</info>', '']); $this->io->writeln(['<info>Custom configuration properly generated!</info>', '']);
// If current command is not update, generate database // If current command is not update, generate database
if (! $this->isUpdate) { if (! $this->isUpdate) {
$this->output->writeln('Initializing database...'); $this->io->write('Initializing database...');
if (! $this->runCommand( if (! $this->runCommand(
'php vendor/bin/doctrine.php orm:schema-tool:create', 'php vendor/bin/doctrine.php orm:schema-tool:create',
'Error generating database.' 'Error generating database.',
$output
)) { )) {
return; return;
} }
} }
// Run database migrations // Run database migrations
$output->writeln('Updating database...'); $this->io->write('Updating database...');
if (! $this->runCommand('php vendor/bin/doctrine-migrations migrations:migrate', 'Error updating database.')) { if (! $this->runCommand(
'php vendor/bin/doctrine-migrations migrations:migrate',
'Error updating database.',
$output
)) {
return; return;
} }
// Generate proxies // Generate proxies
$output->writeln('Generating proxies...'); $this->io->write('Generating proxies...');
if (! $this->runCommand('php vendor/bin/doctrine.php orm:generate-proxies', 'Error generating proxies.')) { if (! $this->runCommand(
'php vendor/bin/doctrine.php orm:generate-proxies',
'Error generating proxies.',
$output
)) {
return; return;
} }
$this->io->success('Installation complete!');
} }
/** /**
* @return CustomizableAppConfig * @return CustomizableAppConfig
* @throws RuntimeException * @throws RuntimeException
*/ */
private function importConfig() private function importConfig(): CustomizableAppConfig
{ {
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
// Ask the user if he/she wants to import an older configuration // Ask the user if he/she wants to import an older configuration
$importConfig = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion( $importConfig = $this->io->confirm('Do you want to import configuration from previous installation?');
'<question>Do you want to import previous configuration? (Y/n):</question> '
));
if (! $importConfig) { if (! $importConfig) {
return $config; return $config;
} }
@@ -175,17 +182,16 @@ class InstallCommand extends Command
// Ask the user for the older shlink path // Ask the user for the older shlink path
$keepAsking = true; $keepAsking = true;
do { do {
$config->setImportedInstallationPath($this->ask( $config->setImportedInstallationPath($this->io->ask(
'Previous shlink installation path from which to import config' 'Previous shlink installation path from which to import config'
)); ));
$configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH; $configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH;
$configExists = $this->filesystem->exists($configFile); $configExists = $this->filesystem->exists($configFile);
if (! $configExists) { if (! $configExists) {
$keepAsking = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion( $keepAsking = $this->io->confirm(
'Provided path does not seem to be a valid shlink root path. ' 'Provided path does not seem to be a valid shlink root path. Do you want to try another path?'
. '<question>Do you want to try another path? (Y/n):</question> ' );
));
} }
} while (! $configExists && $keepAsking); } while (! $configExists && $keepAsking);
@@ -199,51 +205,31 @@ class InstallCommand extends Command
return $config; return $config;
} }
/**
* @param string $text
* @param string|null $default
* @param bool $allowEmpty
* @return string
* @throws RuntimeException
*/
private function ask($text, $default = null, $allowEmpty = false)
{
if ($default !== null) {
$text .= ' (defaults to ' . $default . ')';
}
do {
$value = $this->questionHelper->ask($this->input, $this->output, new Question(
'<question>' . $text . ':</question> ',
$default
));
if (empty($value) && ! $allowEmpty) {
$this->output->writeln('<error>Value can\'t be empty</error>');
}
} while (empty($value) && $default === null && ! $allowEmpty);
return $value;
}
/** /**
* @param string $command * @param string $command
* @param string $errorMessage * @param string $errorMessage
* @param OutputInterface $output
* @return bool * @return bool
* @throws LogicException
* @throws InvalidArgumentException
*/ */
private function runCommand($command, $errorMessage) private function runCommand($command, $errorMessage, OutputInterface $output): bool
{ {
$process = $this->processHelper->run($this->output, $command); if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process');
}
$process = $this->processHelper->run($output, $command);
if ($process->isSuccessful()) { if ($process->isSuccessful()) {
$this->output->writeln(' <info>Success!</info>'); $this->io->writeln(' <info>Success!</info>');
return true; return true;
} }
if ($this->output->isVerbose()) { if ($this->io->isVerbose()) {
return false; return false;
} }
$this->output->writeln( $this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
' <error>' . $errorMessage . '</error> Run this command with -vvv to see specific error info.'
);
return false; return false;
} }
} }

View File

@@ -9,10 +9,13 @@ use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class GeneratePreviewCommand extends Command class GeneratePreviewCommand extends Command
{ {
const NAME = 'shortcode:process-previews';
/** /**
* @var PreviewGeneratorInterface * @var PreviewGeneratorInterface
*/ */
@@ -39,7 +42,7 @@ class GeneratePreviewCommand extends Command
public function configure() public function configure()
{ {
$this->setName('shortcode:process-previews') $this->setName(self::NAME)
->setDescription( ->setDescription(
$this->translator->translate( $this->translator->translate(
'Processes and generates the previews for every URL, improving performance for later web requests.' 'Processes and generates the previews for every URL, improving performance for later web requests.'
@@ -59,22 +62,20 @@ class GeneratePreviewCommand extends Command
} }
} while ($page <= $shortUrls->count()); } while ($page <= $shortUrls->count());
$output->writeln('<info>' . $this->translator->translate('Finished processing all URLs') . '</info>'); (new SymfonyStyle($input, $output))->success($this->translator->translate('Finished processing all URLs'));
} }
protected function processUrl($url, OutputInterface $output) protected function processUrl($url, OutputInterface $output)
{ {
try { try {
$output->write(sprintf($this->translator->translate('Processing URL %s...'), $url)); $output->write(\sprintf($this->translator->translate('Processing URL %s...'), $url));
$this->previewGenerator->generatePreview($url); $this->previewGenerator->generatePreview($url);
$output->writeln($this->translator->translate(' <info>Success!</info>')); $output->writeln($this->translator->translate(' <info>Success!</info>'));
} catch (PreviewGenerationException $e) { } catch (PreviewGenerationException $e) {
$messages = [' <error>' . $this->translator->translate('Error') . '</error>']; $output->writeln(' <error>' . $this->translator->translate('Error') . '</error>');
if ($output->isVerbose()) { if ($output->isVerbose()) {
$messages[] = '<error>' . $e->__toString() . '</error>'; $this->getApplication()->renderException($e, $output);
} }
$output->writeln($messages);
} }
} }
} }

View File

@@ -7,17 +7,18 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Diactoros\Uri; use Zend\Diactoros\Uri;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class GenerateShortcodeCommand extends Command class GenerateShortcodeCommand extends Command
{ {
const NAME = 'shortcode:generate';
/** /**
* @var UrlShortenerInterface * @var UrlShortenerInterface
*/ */
@@ -44,7 +45,7 @@ class GenerateShortcodeCommand extends Command
public function configure() public function configure()
{ {
$this->setName('shortcode:generate') $this->setName(self::NAME)
->setDescription( ->setDescription(
$this->translator->translate('Generates a short code for provided URL and returns the short URL') $this->translator->translate('Generates a short code for provided URL and returns the short URL')
) )
@@ -73,19 +74,15 @@ class GenerateShortcodeCommand extends Command
public function interact(InputInterface $input, OutputInterface $output) public function interact(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl'); $longUrl = $input->getArgument('longUrl');
if (! empty($longUrl)) { if (! empty($longUrl)) {
return; return;
} }
/** @var QuestionHelper $helper */ $longUrl = $io->ask(
$helper = $this->getHelper('question'); $this->translator->translate('A long URL was not provided. Which URL do you want to be shortened?')
$question = new Question(sprintf( );
'<question>%s</question> ',
$this->translator->translate('A long URL was not provided. Which URL do you want to shorten?:')
));
$longUrl = $helper->ask($input, $output, $question);
if (! empty($longUrl)) { if (! empty($longUrl)) {
$input->setArgument('longUrl', $longUrl); $input->setArgument('longUrl', $longUrl);
} }
@@ -93,23 +90,24 @@ class GenerateShortcodeCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl'); $longUrl = $input->getArgument('longUrl');
if (empty($longUrl)) {
$io->error($this->translator->translate('A URL was not provided!'));
return;
}
$tags = $input->getOption('tags'); $tags = $input->getOption('tags');
$processedTags = []; $processedTags = [];
foreach ($tags as $key => $tag) { foreach ($tags as $key => $tag) {
$explodedTags = explode(',', $tag); $explodedTags = \explode(',', $tag);
$processedTags = array_merge($processedTags, $explodedTags); $processedTags = \array_merge($processedTags, $explodedTags);
} }
$tags = $processedTags; $tags = $processedTags;
$customSlug = $input->getOption('customSlug'); $customSlug = $input->getOption('customSlug');
$maxVisits = $input->getOption('maxVisits'); $maxVisits = $input->getOption('maxVisits');
try { try {
if (! isset($longUrl)) {
$output->writeln(sprintf('<error>%s</error>', $this->translator->translate('A URL was not provided!')));
return;
}
$shortCode = $this->urlShortener->urlToShortCode( $shortCode = $this->urlShortener->urlToShortCode(
new Uri($longUrl), new Uri($longUrl),
$tags, $tags,
@@ -122,22 +120,20 @@ class GenerateShortcodeCommand extends Command
->withScheme($this->domainConfig['schema']) ->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']); ->withHost($this->domainConfig['hostname']);
$output->writeln([ $io->writeln([
sprintf('%s <info>%s</info>', $this->translator->translate('Processed URL:'), $longUrl), \sprintf('%s <info>%s</info>', $this->translator->translate('Processed long URL:'), $longUrl),
sprintf('%s <info>%s</info>', $this->translator->translate('Generated URL:'), $shortUrl), \sprintf('%s <info>%s</info>', $this->translator->translate('Generated short URL:'), $shortUrl),
]); ]);
} catch (InvalidUrlException $e) { } catch (InvalidUrlException $e) {
$output->writeln(sprintf( $io->error(\sprintf(
'<error>' . $this->translator->translate( $this->translator->translate('Provided URL "%s" is invalid. Try with a different one.'),
'Provided URL "%s" is invalid. Try with a different one.'
) . '</error>',
$longUrl $longUrl
)); ));
} catch (NonUniqueSlugException $e) { } catch (NonUniqueSlugException $e) {
$output->writeln(sprintf( $io->error(\sprintf(
'<error>' . $this->translator->translate( $this->translator->translate(
'Provided slug "%s" is already in use by another URL. Try with a different one.' 'Provided slug "%s" is already in use by another URL. Try with a different one.'
) . '</error>', ),
$customSlug $customSlug
)); ));
} }

View File

@@ -6,17 +6,17 @@ namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class GetVisitsCommand extends Command class GetVisitsCommand extends Command
{ {
const NAME = 'shortcode:visits';
/** /**
* @var VisitsTrackerInterface * @var VisitsTrackerInterface
*/ */
@@ -30,12 +30,12 @@ class GetVisitsCommand extends Command
{ {
$this->visitsTracker = $visitsTracker; $this->visitsTracker = $visitsTracker;
$this->translator = $translator; $this->translator = $translator;
parent::__construct(null); parent::__construct();
} }
public function configure() public function configure()
{ {
$this->setName('shortcode:visits') $this->setName(self::NAME)
->setDescription( ->setDescription(
$this->translator->translate('Returns the detailed visits information for provided short code') $this->translator->translate('Returns the detailed visits information for provided short code')
) )
@@ -65,14 +65,10 @@ class GetVisitsCommand extends Command
return; return;
} }
/** @var QuestionHelper $helper */ $io = new SymfonyStyle($input, $output);
$helper = $this->getHelper('question'); $shortCode = $io->ask(
$question = new Question(sprintf( $this->translator->translate('A short code was not provided. Which short code do you want to use?')
'<question>%s</question> ', );
$this->translator->translate('A short code was not provided. Which short code do you want to use?:')
));
$shortCode = $helper->ask($input, $output, $question);
if (! empty($shortCode)) { if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode); $input->setArgument('shortCode', $shortCode);
} }
@@ -80,33 +76,32 @@ class GetVisitsCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode'); $shortCode = $input->getArgument('shortCode');
$startDate = $this->getDateOption($input, 'startDate'); $startDate = $this->getDateOption($input, 'startDate');
$endDate = $this->getDateOption($input, 'endDate'); $endDate = $this->getDateOption($input, 'endDate');
$visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate)); $visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate));
$table = new Table($output); $rows = [];
$table->setHeaders([
$this->translator->translate('Referer'),
$this->translator->translate('Date'),
$this->translator->translate('Remote Address'),
$this->translator->translate('User agent'),
]);
foreach ($visits as $row) { foreach ($visits as $row) {
$rowData = $row->jsonSerialize(); $rowData = $row->jsonSerialize();
// Unset location info // Unset location info
unset($rowData['visitLocation']); unset($rowData['visitLocation']);
$table->addRow(array_values($rowData)); $rows[] = \array_values($rowData);
} }
$table->render(); $io->table([
$this->translator->translate('Referer'),
$this->translator->translate('Date'),
$this->translator->translate('Remote Address'),
$this->translator->translate('User agent'),
], $rows);
} }
protected function getDateOption(InputInterface $input, $key) protected function getDateOption(InputInterface $input, $key)
{ {
$value = $input->getOption($key); $value = $input->getOption($key);
if (isset($value)) { if (! empty($value)) {
$value = new \DateTime($value); $value = new \DateTime($value);
} }

View File

@@ -7,18 +7,18 @@ use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class ListShortcodesCommand extends Command class ListShortcodesCommand extends Command
{ {
use PaginatorUtilsTrait; use PaginatorUtilsTrait;
const NAME = 'shortcode:list';
/** /**
* @var ShortUrlServiceInterface * @var ShortUrlServiceInterface
*/ */
@@ -32,12 +32,12 @@ class ListShortcodesCommand extends Command
{ {
$this->shortUrlService = $shortUrlService; $this->shortUrlService = $shortUrlService;
$this->translator = $translator; $this->translator = $translator;
parent::__construct(null); parent::__construct();
} }
public function configure() public function configure()
{ {
$this->setName('shortcode:list') $this->setName(self::NAME)
->setDescription($this->translator->translate('List all short URLs')) ->setDescription($this->translator->translate('List all short URLs'))
->addOption( ->addOption(
'page', 'page',
@@ -81,19 +81,16 @@ class ListShortcodesCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page'); $page = (int) $input->getOption('page');
$searchTerm = $input->getOption('searchTerm'); $searchTerm = $input->getOption('searchTerm');
$tags = $input->getOption('tags'); $tags = $input->getOption('tags');
$tags = ! empty($tags) ? explode(',', $tags) : []; $tags = ! empty($tags) ? \explode(',', $tags) : [];
$showTags = $input->getOption('showTags'); $showTags = $input->getOption('showTags');
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
do { do {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input)); $result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$page++; $page++;
$table = new Table($output);
$headers = [ $headers = [
$this->translator->translate('Short code'), $this->translator->translate('Short code'),
@@ -104,8 +101,8 @@ class ListShortcodesCommand extends Command
if ($showTags) { if ($showTags) {
$headers[] = $this->translator->translate('Tags'); $headers[] = $this->translator->translate('Tags');
} }
$table->setHeaders($headers);
$rows = [];
foreach ($result as $row) { foreach ($result as $row) {
$shortUrl = $row->jsonSerialize(); $shortUrl = $row->jsonSerialize();
if ($showTags) { if ($showTags) {
@@ -118,27 +115,23 @@ class ListShortcodesCommand extends Command
unset($shortUrl['tags']); unset($shortUrl['tags']);
} }
$table->addRow(array_values($shortUrl)); $rows[] = \array_values($shortUrl);
} }
$table->render(); $io->table($headers, $rows);
if ($this->isLastPage($result)) { if ($this->isLastPage($result)) {
$continue = false; $continue = false;
$output->writeln( $io->success($this->translator->translate('Short codes properly listed'));
sprintf('<info>%s</info>', $this->translator->translate('You have reached last page'))
);
} else { } else {
$continue = $helper->ask($input, $output, new ConfirmationQuestion( $continue = $io->confirm(
sprintf('<question>' . $this->translator->translate( \sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),
'Continue with page'
) . ' <bg=cyan;options=bold>%s</>? (y/N)</question> ', $page),
false false
)); );
} }
} while ($continue); } while ($continue);
} }
protected function processOrderBy(InputInterface $input) private function processOrderBy(InputInterface $input)
{ {
$orderBy = $input->getOption('orderBy'); $orderBy = $input->getOption('orderBy');
if (empty($orderBy)) { if (empty($orderBy)) {

View File

@@ -7,15 +7,16 @@ use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class ResolveUrlCommand extends Command class ResolveUrlCommand extends Command
{ {
const NAME = 'shortcode:parse';
/** /**
* @var UrlShortenerInterface * @var UrlShortenerInterface
*/ */
@@ -34,7 +35,7 @@ class ResolveUrlCommand extends Command
public function configure() public function configure()
{ {
$this->setName('shortcode:parse') $this->setName(self::NAME)
->setDescription($this->translator->translate('Returns the long URL behind a short code')) ->setDescription($this->translator->translate('Returns the long URL behind a short code'))
->addArgument( ->addArgument(
'shortCode', 'shortCode',
@@ -50,14 +51,10 @@ class ResolveUrlCommand extends Command
return; return;
} }
/** @var QuestionHelper $helper */ $io = new SymfonyStyle($input, $output);
$helper = $this->getHelper('question'); $shortCode = $io->ask(
$question = new Question(sprintf( $this->translator->translate('A short code was not provided. Which short code do you want to parse?')
'<question>%s</question> ', );
$this->translator->translate('A short code was not provided. Which short code do you want to parse?:')
));
$shortCode = $helper->ask($input, $output, $question);
if (! empty($shortCode)) { if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode); $input->setArgument('shortCode', $shortCode);
} }
@@ -65,27 +62,20 @@ class ResolveUrlCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode'); $shortCode = $input->getArgument('shortCode');
try { try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode); $longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
if (! isset($longUrl)) { $output->writeln(\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $longUrl));
$output->writeln(sprintf(
'<error>' . $this->translator->translate('No URL found for short code "%s"') . '</error>',
$shortCode
));
return;
}
$output->writeln(sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $longUrl));
} catch (InvalidShortCodeException $e) { } catch (InvalidShortCodeException $e) {
$output->writeln(sprintf('<error>' . $this->translator->translate( $io->error(
'Provided short code "%s" has an invalid format.' \sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode)
) . '</error>', $shortCode)); );
} catch (EntityDoesNotExistException $e) { } catch (EntityDoesNotExistException $e) {
$output->writeln(sprintf('<error>' . $this->translator->translate( $io->error(
'Provided short code "%s" could not be found.' \sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
) . '</error>', $shortCode)); );
} }
} }
} }

View File

@@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class CreateTagCommand extends Command class CreateTagCommand extends Command
{ {
const NAME = 'tag:create';
/** /**
* @var TagServiceInterface * @var TagServiceInterface
*/ */
@@ -31,7 +34,7 @@ class CreateTagCommand extends Command
protected function configure() protected function configure()
{ {
$this $this
->setName('tag:create') ->setName(self::NAME)
->setDescription($this->translator->translate('Creates one or more tags.')) ->setDescription($this->translator->translate('Creates one or more tags.'))
->addOption( ->addOption(
'name', 'name',
@@ -43,19 +46,15 @@ class CreateTagCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name'); $tagNames = $input->getOption('name');
if (empty($tagNames)) { if (empty($tagNames)) {
$output->writeln(sprintf( $io->warning($this->translator->translate('You have to provide at least one tag name'));
'<comment>%s</comment>',
$this->translator->translate('You have to provide at least one tag name')
));
return; return;
} }
$this->tagService->createTags($tagNames); $this->tagService->createTags($tagNames);
$output->writeln($this->translator->translate('Created tags') . sprintf(': ["<info>%s</info>"]', implode( $io->success($this->translator->translate('Tags properly created'));
'</info>", "<info>',
$tagNames
)));
} }
} }

View File

@@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class DeleteTagsCommand extends Command class DeleteTagsCommand extends Command
{ {
const NAME = 'tag:delete';
/** /**
* @var TagServiceInterface * @var TagServiceInterface
*/ */
@@ -31,7 +34,7 @@ class DeleteTagsCommand extends Command
protected function configure() protected function configure()
{ {
$this $this
->setName('tag:delete') ->setName(self::NAME)
->setDescription($this->translator->translate('Deletes one or more tags.')) ->setDescription($this->translator->translate('Deletes one or more tags.'))
->addOption( ->addOption(
'name', 'name',
@@ -43,19 +46,15 @@ class DeleteTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name'); $tagNames = $input->getOption('name');
if (empty($tagNames)) { if (empty($tagNames)) {
$output->writeln(sprintf( $io->warning($this->translator->translate('You have to provide at least one tag name'));
'<comment>%s</comment>',
$this->translator->translate('You have to provide at least one tag name')
));
return; return;
} }
$this->tagService->deleteTags($tagNames); $this->tagService->deleteTags($tagNames);
$output->writeln($this->translator->translate('Deleted tags') . sprintf(': ["<info>%s</info>"]', implode( $io->success($this->translator->translate('Tags properly deleted'));
'</info>", "<info>',
$tagNames
)));
} }
} }

View File

@@ -6,13 +6,15 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class ListTagsCommand extends Command class ListTagsCommand extends Command
{ {
const NAME = 'tag:list';
/** /**
* @var TagServiceInterface * @var TagServiceInterface
*/ */
@@ -32,17 +34,14 @@ class ListTagsCommand extends Command
protected function configure() protected function configure()
{ {
$this $this
->setName('tag:list') ->setName(self::NAME)
->setDescription($this->translator->translate('Lists existing tags.')); ->setDescription($this->translator->translate('Lists existing tags.'));
} }
protected function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output)
{ {
$table = new Table($output); $io = new SymfonyStyle($input, $output);
$table->setHeaders([$this->translator->translate('Name')]) $io->table([$this->translator->translate('Name')], $this->getTagsRows());
->setRows($this->getTagsRows());
$table->render();
} }
private function getTagsRows() private function getTagsRows()
@@ -52,7 +51,7 @@ class ListTagsCommand extends Command
return [[$this->translator->translate('No tags yet')]]; return [[$this->translator->translate('No tags yet')]];
} }
return array_map(function (Tag $tag) { return \array_map(function (Tag $tag) {
return [$tag->getName()]; return [$tag->getName()];
}, $tags); }, $tags);
} }

View File

@@ -9,10 +9,13 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class RenameTagCommand extends Command class RenameTagCommand extends Command
{ {
const NAME = 'tag:rename';
/** /**
* @var TagServiceInterface * @var TagServiceInterface
*/ */
@@ -32,7 +35,7 @@ class RenameTagCommand extends Command
protected function configure() protected function configure()
{ {
$this $this
->setName('tag:rename') ->setName(self::NAME)
->setDescription($this->translator->translate('Renames one existing tag.')) ->setDescription($this->translator->translate('Renames one existing tag.'))
->addArgument('oldName', InputArgument::REQUIRED, $this->translator->translate('Current name of the tag.')) ->addArgument('oldName', InputArgument::REQUIRED, $this->translator->translate('Current name of the tag.'))
->addArgument('newName', InputArgument::REQUIRED, $this->translator->translate('New name of the tag.')); ->addArgument('newName', InputArgument::REQUIRED, $this->translator->translate('New name of the tag.'));
@@ -40,16 +43,15 @@ class RenameTagCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName'); $oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName'); $newName = $input->getArgument('newName');
try { try {
$this->tagService->renameTag($oldName, $newName); $this->tagService->renameTag($oldName, $newName);
$output->writeln(sprintf('<info>%s</info>', $this->translator->translate('Tag properly renamed.'))); $io->success($this->translator->translate('Tag properly renamed.'));
} catch (EntityDoesNotExistException $e) { } catch (EntityDoesNotExistException $e) {
$output->writeln('<error>' . sprintf($this->translator->translate( $io->error(\sprintf($this->translator->translate('A tag with name "%s" was not found'), $oldName));
'A tag with name "%s" was not found'
), $oldName) . '</error>');
} }
} }
} }

View File

@@ -10,11 +10,13 @@ use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface; use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command class ProcessVisitsCommand extends Command
{ {
const LOCALHOST = '127.0.0.1'; const LOCALHOST = '127.0.0.1';
const NAME = 'visit:process';
/** /**
* @var VisitServiceInterface * @var VisitServiceInterface
@@ -42,7 +44,7 @@ class ProcessVisitsCommand extends Command
public function configure() public function configure()
{ {
$this->setName('visit:process') $this->setName(self::NAME)
->setDescription( ->setDescription(
$this->translator->translate('Processes visits where location is not set yet') $this->translator->translate('Processes visits where location is not set yet')
); );
@@ -50,13 +52,14 @@ class ProcessVisitsCommand extends Command
public function execute(InputInterface $input, OutputInterface $output) public function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits(); $visits = $this->visitService->getUnlocatedVisits();
foreach ($visits as $visit) { foreach ($visits as $visit) {
$ipAddr = $visit->getRemoteAddr(); $ipAddr = $visit->getRemoteAddr();
$output->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr)); $io->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === self::LOCALHOST) { if ($ipAddr === self::LOCALHOST) {
$output->writeln( $io->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address')) sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
); );
continue; continue;
@@ -64,11 +67,13 @@ class ProcessVisitsCommand extends Command
try { try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr); $result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
$location = new VisitLocation(); $location = new VisitLocation();
$location->exchangeArray($result); $location->exchangeArray($result);
$visit->setVisitLocation($location); $visit->setVisitLocation($location);
$this->visitService->saveVisit($visit); $this->visitService->saveVisit($visit);
$output->writeln(sprintf(
$io->writeln(sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')', ' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName() $location->getCityName()
)); ));
@@ -77,6 +82,6 @@ class ProcessVisitsCommand extends Command
} }
} }
$output->writeln($this->translator->translate('Finished processing all IPs')); $io->success($this->translator->translate('Finished processing all IPs'));
} }
} }

View File

@@ -5,8 +5,11 @@ namespace Shlinkio\Shlink\CLI\Factory;
use Interop\Container\ContainerInterface; use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException; use Interop\Container\Exception\ContainerException;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Symfony\Component\Console\Application as CliApp; use Symfony\Component\Console\Application as CliApp;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
use Zend\I18n\Translator\Translator; use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Exception\ServiceNotCreatedException; use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException; use Zend\ServiceManager\Exception\ServiceNotFoundException;
@@ -20,28 +23,23 @@ class ApplicationFactory implements FactoryInterface
* @param ContainerInterface $container * @param ContainerInterface $container
* @param string $requestedName * @param string $requestedName
* @param null|array $options * @param null|array $options
* @return object * @return CliApp
* @throws NotFoundExceptionInterface
* @throws ContainerExceptionInterface
* @throws ServiceNotFoundException if unable to resolve the service. * @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when * @throws ServiceNotCreatedException if an exception is raised when creating a service.
* creating a service.
* @throws ContainerException if any other error occurs * @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): CliApp
{ {
$config = $container->get('config')['cli']; $config = $container->get('config')['cli'];
$appOptions = $container->get(AppOptions::class); $appOptions = $container->get(AppOptions::class);
$translator = $container->get(Translator::class); $translator = $container->get(Translator::class);
$translator->setLocale($config['locale']); $translator->setLocale($config['locale']);
$commands = isset($config['commands']) ? $config['commands'] : []; $commands = $config['commands'] ?? [];
$app = new CliApp($appOptions->getName(), $appOptions->getVersion()); $app = new CliApp($appOptions->getName(), $appOptions->getVersion());
foreach ($commands as $command) { $app->setCommandLoader(new ContainerCommandLoader($container, $commands));
if (! $container->has($command)) {
continue;
}
$app->add($container->get($command));
}
return $app; return $app;
} }

View File

@@ -6,9 +6,8 @@ namespace Shlinkio\Shlink\CLI\Factory;
use Interop\Container\ContainerInterface; use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException; use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand; use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManager; use Shlinkio\Shlink\CLI\Install\ConfigCustomizerManager;
use Shlinkio\Shlink\CLI\Install\Plugin; use Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Install\Plugin\Factory\DefaultConfigCustomizerPluginFactory;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
@@ -17,6 +16,7 @@ use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Exception\ServiceNotCreatedException; use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException; use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface; use Zend\ServiceManager\Factory\FactoryInterface;
use Zend\ServiceManager\Factory\InvokableFactory;
class InstallApplicationFactory implements FactoryInterface class InstallApplicationFactory implements FactoryInterface
{ {
@@ -41,11 +41,11 @@ class InstallApplicationFactory implements FactoryInterface
$command = new InstallCommand( $command = new InstallCommand(
new PhpArray(), new PhpArray(),
$container->get(Filesystem::class), $container->get(Filesystem::class),
new ConfigCustomizerPluginManager($container, ['factories' => [ new ConfigCustomizerManager($container, ['factories' => [
Plugin\DatabaseConfigCustomizerPlugin::class => ConfigAbstractFactory::class, Plugin\DatabaseConfigCustomizer::class => ConfigAbstractFactory::class,
Plugin\UrlShortenerConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, Plugin\UrlShortenerConfigCustomizer::class => InvokableFactory::class,
Plugin\LanguageConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, Plugin\LanguageConfigCustomizer::class => InvokableFactory::class,
Plugin\ApplicationConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, Plugin\ApplicationConfigCustomizer::class => InvokableFactory::class,
]]), ]]),
$isUpdate $isUpdate
); );

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install;
use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerInterface;
use Zend\ServiceManager\AbstractPluginManager;
class ConfigCustomizerManager extends AbstractPluginManager implements ConfigCustomizerManagerInterface
{
protected $instanceOf = ConfigCustomizerInterface::class;
}

View File

@@ -5,6 +5,6 @@ namespace Shlinkio\Shlink\CLI\Install;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
interface ConfigCustomizerPluginManagerInterface extends ContainerInterface interface ConfigCustomizerManagerInterface extends ContainerInterface
{ {
} }

View File

@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install;
use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerPluginInterface;
use Zend\ServiceManager\AbstractPluginManager;
class ConfigCustomizerPluginManager extends AbstractPluginManager implements ConfigCustomizerPluginManagerInterface
{
protected $instanceOf = ConfigCustomizerPluginInterface::class;
}

View File

@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
abstract class AbstractConfigCustomizerPlugin implements ConfigCustomizerPluginInterface
{
/**
* @var QuestionHelper
*/
protected $questionHelper;
public function __construct(QuestionHelper $questionHelper)
{
$this->questionHelper = $questionHelper;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param string $text
* @param string|null $default
* @param bool $allowEmpty
* @return string
* @throws RuntimeException
*/
protected function ask(InputInterface $input, OutputInterface $output, $text, $default = null, $allowEmpty = false)
{
if ($default !== null) {
$text .= ' (defaults to ' . $default . ')';
}
do {
$value = $this->questionHelper->ask($input, $output, new Question(
'<question>' . $text . ':</question> ',
$default
));
if (empty($value) && ! $allowEmpty) {
$output->writeln('<error>Value can\'t be empty</error>');
}
} while (empty($value) && $default === null && ! $allowEmpty);
return $value;
}
/**
* @param OutputInterface $output
* @param string $text
*/
protected function printTitle(OutputInterface $output, $text)
{
$text = trim($text);
$length = strlen($text) + 4;
$header = str_repeat('*', $length);
$output->writeln([
'',
'<info>' . $header . '</info>',
'<info>* ' . strtoupper($text) . ' *</info>',
'<info>' . $header . '</info>',
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
class ApplicationConfigCustomizer implements ConfigCustomizerInterface
{
use StringUtilsTrait;
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
{
$io->title('APPLICATION');
if ($appConfig->hasApp() && $io->confirm('Do you want to keep imported application config?')) {
return;
}
$validator = function ($value) {
return $value;
};
$appConfig->setApp([
'SECRET' => $io->ask(
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)',
null,
$validator
) ?: $this->generateRandomString(32),
'DISABLE_TRACK_PARAM' => $io->ask(
'Provide a parameter name that you will be able to use to disable tracking on specific request to '
. 'short URLs (leave empty and this feature won\'t be enabled)',
null,
$validator
),
]);
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class ApplicationConfigCustomizerPlugin extends AbstractConfigCustomizerPlugin
{
use StringUtilsTrait;
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
* @throws \Symfony\Component\Console\Exception\RuntimeException
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig)
{
$this->printTitle($output, 'APPLICATION');
if ($appConfig->hasApp() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion(
'<question>Do you want to keep imported application config? (Y/n):</question> '
))) {
return;
}
$appConfig->setApp([
'SECRET' => $this->ask(
$input,
$output,
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)',
null,
true
) ?: $this->generateRandomString(32),
]);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
interface ConfigCustomizerInterface
{
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig);
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
interface ConfigCustomizerPluginInterface
{
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig);
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizer implements ConfigCustomizerInterface
{
const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
'SQLite' => 'pdo_sqlite',
];
/**
* @var Filesystem
*/
private $filesystem;
public function __construct(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
}
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
* @throws IOException
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
{
$io->title('DATABASE');
if ($appConfig->hasDatabase() && $io->confirm('Do you want to keep imported database config?')) {
// If the user selected to keep DB config and is configured to use sqlite, copy DB file
if ($appConfig->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) {
try {
$this->filesystem->copy(
$appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH,
CustomizableAppConfig::SQLITE_DB_PATH
);
} catch (IOException $e) {
$io->error('It wasn\'t possible to import the SQLite database');
throw $e;
}
}
return;
}
// Select database type
$params = [];
$databases = \array_keys(self::DATABASE_DRIVERS);
$dbType = $io->choice('Select database type', $databases, $databases[0]);
$params['DRIVER'] = self::DATABASE_DRIVERS[$dbType];
// Ask for connection params if database is not SQLite
if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) {
$params['NAME'] = $io->ask('Database name', 'shlink');
$params['USER'] = $io->ask('Database username');
$params['PASSWORD'] = $io->ask('Database password');
$params['HOST'] = $io->ask('Database host', 'localhost');
$params['PORT'] = $io->ask('Database port', $this->getDefaultDbPort($params['DRIVER']));
}
$appConfig->setDatabase($params);
}
private function getDefaultDbPort(string $driver): string
{
return $driver === 'pdo_mysql' ? '3306' : '5432';
}
}

View File

@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizerPlugin extends AbstractConfigCustomizerPlugin
{
const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
'SQLite' => 'pdo_sqlite',
];
/**
* @var Filesystem
*/
private $filesystem;
/**
* DatabaseConfigCustomizerPlugin constructor.
* @param QuestionHelper $questionHelper
* @param Filesystem $filesystem
*
* @DI\Inject({QuestionHelper::class, Filesystem::class})
*/
public function __construct(QuestionHelper $questionHelper, Filesystem $filesystem)
{
parent::__construct($questionHelper);
$this->filesystem = $filesystem;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
* @throws IOException
* @throws RuntimeException
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig)
{
$this->printTitle($output, 'DATABASE');
if ($appConfig->hasDatabase() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion(
'<question>Do you want to keep imported database config? (Y/n):</question> '
))) {
// If the user selected to keep DB config and is configured to use sqlite, copy DB file
if ($appConfig->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) {
try {
$this->filesystem->copy(
$appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH,
CustomizableAppConfig::SQLITE_DB_PATH
);
} catch (IOException $e) {
$output->writeln('<error>It wasn\'t possible to import the SQLite database</error>');
throw $e;
}
}
return;
}
// Select database type
$params = [];
$databases = array_keys(self::DATABASE_DRIVERS);
$dbType = $this->questionHelper->ask($input, $output, new ChoiceQuestion(
'<question>Select database type (defaults to ' . $databases[0] . '):</question>',
$databases,
0
));
$params['DRIVER'] = self::DATABASE_DRIVERS[$dbType];
// Ask for connection params if database is not SQLite
if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) {
$params['NAME'] = $this->ask($input, $output, 'Database name', 'shlink');
$params['USER'] = $this->ask($input, $output, 'Database username');
$params['PASSWORD'] = $this->ask($input, $output, 'Database password');
$params['HOST'] = $this->ask($input, $output, 'Database host', 'localhost');
$params['PORT'] = $this->ask($input, $output, 'Database port', $this->getDefaultDbPort($params['DRIVER']));
}
$appConfig->setDatabase($params);
}
private function getDefaultDbPort($driver)
{
return $driver === 'pdo_mysql' ? '3306' : '5432';
}
}

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Symfony\Component\Console\Helper\QuestionHelper;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class DefaultConfigCustomizerPluginFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new $requestedName($container->get(QuestionHelper::class));
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
class LanguageConfigCustomizer implements ConfigCustomizerInterface
{
const SUPPORTED_LANGUAGES = ['en', 'es'];
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
{
$io->title('LANGUAGE');
if ($appConfig->hasLanguage() && $io->confirm('Do you want to keep imported language?')) {
return;
}
$appConfig->setLanguage([
'DEFAULT' => $this->chooseLanguage('Select default language for the application in general', $io),
'CLI' => $this->chooseLanguage('Select default language for CLI executions', $io),
]);
}
private function chooseLanguage(string $message, SymfonyStyle $io): string
{
return $io->choice($message, self::SUPPORTED_LANGUAGES, self::SUPPORTED_LANGUAGES[0]);
}
}

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class LanguageConfigCustomizerPlugin extends AbstractConfigCustomizerPlugin
{
const SUPPORTED_LANGUAGES = ['en', 'es'];
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
* @throws RuntimeException
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig)
{
$this->printTitle($output, 'LANGUAGE');
if ($appConfig->hasLanguage() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion(
'<question>Do you want to keep imported language? (Y/n):</question> '
))) {
return;
}
$appConfig->setLanguage([
'DEFAULT' => $this->questionHelper->ask($input, $output, new ChoiceQuestion(
'<question>Select default language for the application in general (defaults to '
. self::SUPPORTED_LANGUAGES[0] . '):</question>',
self::SUPPORTED_LANGUAGES,
0
)),
'CLI' => $this->questionHelper->ask($input, $output, new ChoiceQuestion(
'<question>Select default language for CLI executions (defaults to '
. self::SUPPORTED_LANGUAGES[0] . '):</question>',
self::SUPPORTED_LANGUAGES,
0
)),
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Style\SymfonyStyle;
class UrlShortenerConfigCustomizer implements ConfigCustomizerInterface
{
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
{
$io->title('URL SHORTENER');
if ($appConfig->hasUrlShortener() && $io->confirm('Do you want to keep imported URL shortener config?')) {
return;
}
// Ask for URL shortener params
$appConfig->setUrlShortener([
'SCHEMA' => $io->choice(
'Select schema for generated short URLs',
['http', 'https'],
'http'
),
'HOSTNAME' => $io->ask('Hostname for generated URLs'),
'CHARS' => $io->ask(
'Character set for generated short codes (leave empty to autogenerate one)',
null,
function ($value) {
return $value;
}
) ?: \str_shuffle(UrlShortener::DEFAULT_CHARS),
'VALIDATE_URL' => $io->confirm('Do you want to validate long urls by 200 HTTP status code on response'),
]);
}
}

View File

@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class UrlShortenerConfigCustomizerPlugin extends AbstractConfigCustomizerPlugin
{
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
* @throws RuntimeException
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig)
{
$this->printTitle($output, 'URL SHORTENER');
if ($appConfig->hasUrlShortener() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion(
'<question>Do you want to keep imported URL shortener config? (Y/n):</question> '
))) {
return;
}
// Ask for URL shortener params
$appConfig->setUrlShortener([
'SCHEMA' => $this->questionHelper->ask($input, $output, new ChoiceQuestion(
'<question>Select schema for generated short URLs (defaults to http):</question>',
['http', 'https'],
0
)),
'HOSTNAME' => $this->ask($input, $output, 'Hostname for generated URLs'),
'CHARS' => $this->ask(
$input,
$output,
'Character set for generated short codes (leave empty to autogenerate one)',
null,
true
) ?: str_shuffle(UrlShortener::DEFAULT_CHARS),
'VALIDATE_URL' => $this->questionHelper->ask(
$input,
$output,
new ConfirmationQuestion(
'<question>Do you want to validate long urls by 200 HTTP status code on response (Y/n):</question>'
)
),
]);
}
}

View File

@@ -225,6 +225,7 @@ final class CustomizableAppConfig implements ArraySerializableInterface
$config = [ $config = [
'app_options' => [ 'app_options' => [
'secret_key' => $this->app['SECRET'], 'secret_key' => $this->app['SECRET'],
'disable_track_param' => $this->app['DISABLE_TRACK_PARAM'] ?? null,
], ],
'entity_manager' => [ 'entity_manager' => [
'connection' => [ 'connection' => [

View File

@@ -59,6 +59,6 @@ class DisableKeyCommandTest extends TestCase
'apiKey' => $apiKey, 'apiKey' => $apiKey,
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertEquals('API key "abcd1234" does not exist.' . PHP_EOL, $output); $this->assertContains('API key "abcd1234" does not exist.', $output);
} }
} }

View File

@@ -5,7 +5,6 @@ namespace ShlinkioTest\Shlink\CLI\Command\Config;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Config\GenerateCharsetCommand; use Shlinkio\Shlink\CLI\Command\Config\GenerateCharsetCommand;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator; use Zend\I18n\Translator\Translator;
@@ -32,7 +31,6 @@ class GenerateCharsetCommandTest extends TestCase
public function charactersAreGeneratedFromDefault() public function charactersAreGeneratedFromDefault()
{ {
$prefix = 'Character set: '; $prefix = 'Character set: ';
$prefixLength = strlen($prefix);
$this->commandTester->execute([ $this->commandTester->execute([
'command' => 'config:generate-charset', 'command' => 'config:generate-charset',
@@ -40,13 +38,7 @@ class GenerateCharsetCommandTest extends TestCase
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
// Both default character set and the new one should have the same length // Both default character set and the new one should have the same length
$this->assertEquals($prefixLength + strlen(UrlShortener::DEFAULT_CHARS) + 1, strlen($output)); $this->assertContains($prefix, $output);
// Both default character set and the new one should have the same characters
$charset = substr($output, $prefixLength, strlen(UrlShortener::DEFAULT_CHARS));
$orderedDefault = $this->orderStringLetters(UrlShortener::DEFAULT_CHARS);
$orderedCharset = $this->orderStringLetters($charset);
$this->assertEquals($orderedDefault, $orderedCharset);
} }
protected function orderStringLetters($string) protected function orderStringLetters($string)

View File

@@ -8,8 +8,8 @@ use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand; use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManagerInterface; use Shlinkio\Shlink\CLI\Install\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerPluginInterface; use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerInterface;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
@@ -51,8 +51,8 @@ class InstallCommandTest extends TestCase
$this->configWriter = $this->prophesize(WriterInterface::class); $this->configWriter = $this->prophesize(WriterInterface::class);
$configCustomizer = $this->prophesize(ConfigCustomizerPluginInterface::class); $configCustomizer = $this->prophesize(ConfigCustomizerInterface::class);
$configCustomizers = $this->prophesize(ConfigCustomizerPluginManagerInterface::class); $configCustomizers = $this->prophesize(ConfigCustomizerManagerInterface::class);
$configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal()); $configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal());
$app = new Application(); $app = new Application();

View File

@@ -65,8 +65,9 @@ class GenerateShortcodeCommandTest extends TestCase
'longUrl' => 'http://domain.com/invalid', 'longUrl' => 'http://domain.com/invalid',
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertTrue( $this->assertContains(
strpos($output, 'Provided URL "http://domain.com/invalid" is invalid. Try with a different one.') === 0 'Provided URL "http://domain.com/invalid" is invalid.',
$output
); );
} }
} }

View File

@@ -10,7 +10,6 @@ use Shlinkio\Shlink\CLI\Command\Shortcode\ListShortcodesCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator; use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter; use Zend\Paginator\Adapter\ArrayAdapter;
@@ -22,10 +21,6 @@ class ListShortcodesCommandTest extends TestCase
* @var CommandTester * @var CommandTester
*/ */
protected $commandTester; protected $commandTester;
/**
* @var QuestionHelper
*/
protected $questionHelper;
/** /**
* @var ObjectProphecy * @var ObjectProphecy
*/ */
@@ -37,8 +32,6 @@ class ListShortcodesCommandTest extends TestCase
$app = new Application(); $app = new Application();
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([])); $command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]));
$app->add($command); $app->add($command);
$this->questionHelper = $command->getHelper('question');
$this->commandTester = new CommandTester($command); $this->commandTester = new CommandTester($command);
} }
@@ -47,10 +40,10 @@ class ListShortcodesCommandTest extends TestCase
*/ */
public function noInputCallsListJustOnce() public function noInputCallsListJustOnce()
{ {
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) $this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1); ->shouldBeCalledTimes(1);
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']); $this->commandTester->execute(['command' => 'shortcode:list']);
} }
@@ -61,22 +54,15 @@ class ListShortcodesCommandTest extends TestCase
{ {
// The paginator will return more than one page for the first 3 times // The paginator will return more than one page for the first 3 times
$data = []; $data = [];
for ($i = 0; $i < 30; $i++) { for ($i = 0; $i < 50; $i++) {
$data[] = new ShortUrl(); $data[] = new ShortUrl();
} }
$data = array_chunk($data, 11);
$questionHelper = $this->questionHelper; $this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (&$data) {
$that = $this; return new Paginator(new ArrayAdapter($data));
$this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (
&$data,
$questionHelper,
$that
) {
$questionHelper->setInputStream($that->getInputStream('y'));
return new Paginator(new ArrayAdapter(array_shift($data)));
})->shouldBeCalledTimes(3); })->shouldBeCalledTimes(3);
$this->commandTester->setInputs(['y', 'y', 'n']);
$this->commandTester->execute(['command' => 'shortcode:list']); $this->commandTester->execute(['command' => 'shortcode:list']);
} }
@@ -91,10 +77,10 @@ class ListShortcodesCommandTest extends TestCase
$data[] = new ShortUrl(); $data[] = new ShortUrl();
} }
$this->questionHelper->setInputStream($this->getInputStream('n'));
$this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data))) $this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledTimes(1); ->shouldBeCalledTimes(1);
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']); $this->commandTester->execute(['command' => 'shortcode:list']);
} }
@@ -104,10 +90,10 @@ class ListShortcodesCommandTest extends TestCase
public function passingPageWillMakeListStartOnThatPage() public function passingPageWillMakeListStartOnThatPage()
{ {
$page = 5; $page = 5;
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) $this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1); ->shouldBeCalledTimes(1);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([ $this->commandTester->execute([
'command' => 'shortcode:list', 'command' => 'shortcode:list',
'--page' => $page, '--page' => $page,
@@ -119,24 +105,15 @@ class ListShortcodesCommandTest extends TestCase
*/ */
public function ifTagsFlagIsProvidedTagsColumnIsIncluded() public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{ {
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) $this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1); ->shouldBeCalledTimes(1);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([ $this->commandTester->execute([
'command' => 'shortcode:list', 'command' => 'shortcode:list',
'--showTags' => true, '--showTags' => true,
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Tags') > 0); $this->assertContains('Tags', $output);
}
protected function getInputStream($inputData)
{
$stream = fopen('php://memory', 'r+', false);
fputs($stream, $inputData);
rewind($stream);
return $stream;
} }
} }

View File

@@ -66,7 +66,7 @@ class ResolveUrlCommandTest extends TestCase
'shortCode' => $shortCode, 'shortCode' => $shortCode,
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertEquals('Provided short code "' . $shortCode . '" could not be found.' . PHP_EOL, $output); $this->assertContains('Provided short code "' . $shortCode . '" could not be found.', $output);
} }
/** /**
@@ -83,6 +83,6 @@ class ResolveUrlCommandTest extends TestCase
'shortCode' => $shortCode, 'shortCode' => $shortCode,
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertEquals('Provided short code "' . $shortCode . '" has an invalid format.' . PHP_EOL, $output); $this->assertContains('Provided short code "' . $shortCode . '" has an invalid format.', $output);
} }
} }

View File

@@ -14,10 +14,6 @@ use Zend\I18n\Translator\Translator;
class CreateTagCommandTest extends TestCase class CreateTagCommandTest extends TestCase
{ {
/**
* @var CreateTagCommand
*/
private $command;
/** /**
* @var CommandTester * @var CommandTester
*/ */
@@ -63,7 +59,7 @@ class CreateTagCommandTest extends TestCase
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Created tags: ["%s"]', implode('", "', $tagNames)), $output); $this->assertContains('Tags properly created', $output);
$createTags->shouldHaveBeenCalled(); $createTags->shouldHaveBeenCalled();
} }
} }

View File

@@ -64,7 +64,7 @@ class DeleteTagsCommandTest extends TestCase
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Deleted tags: ["%s"]', implode('", "', $tagNames)), $output); $this->assertContains('Tags properly deleted', $output);
$deleteTags->shouldHaveBeenCalled(); $deleteTags->shouldHaveBeenCalled();
} }
} }

View File

@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\ApplicationConfigCustomizerPlugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class ApplicationConfigCustomizerPluginTest extends TestCase
{
/**
* @var ApplicationConfigCustomizerPlugin
*/
private $plugin;
/**
* @var ObjectProphecy
*/
private $questionHelper;
public function setUp()
{
$this->questionHelper = $this->prophesize(QuestionHelper::class);
$this->plugin = new ApplicationConfigCustomizerPlugin($this->questionHelper->reveal());
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
/** @var MethodProphecy $askSecret */
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('the_secret');
$config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertTrue($config->hasApp());
$this->assertEquals([
'SECRET' => 'the_secret',
], $config->getApp());
$askSecret->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) {
$last = array_pop($args);
return $last instanceof ConfirmationQuestion ? false : 'the_new_secret';
});
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'SECRET' => 'the_new_secret',
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'SECRET' => 'foo',
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(1);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\ApplicationConfigCustomizer;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
class ApplicationConfigCustomizerTest extends TestCase
{
/**
* @var ApplicationConfigCustomizer
*/
private $plugin;
/**
* @var ObjectProphecy
*/
private $io;
public function setUp()
{
$this->io = $this->prophesize(SymfonyStyle::class);
$this->io->title(Argument::any())->willReturn(null);
$this->plugin = new ApplicationConfigCustomizer();
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('the_secret');
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasApp());
$this->assertEquals([
'SECRET' => 'the_secret',
'DISABLE_TRACK_PARAM' => 'the_secret',
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('the_new_secret');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SECRET' => 'the_new_secret',
'DISABLE_TRACK_PARAM' => 'the_new_secret',
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
{
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SECRET' => 'foo',
], $config->getApp());
$confirm->shouldHaveBeenCalledTimes(1);
}
}

View File

@@ -5,26 +5,22 @@ namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizerPlugin; use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig; use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizerPluginTest extends TestCase class DatabaseConfigCustomizerTest extends TestCase
{ {
/** /**
* @var DatabaseConfigCustomizerPlugin * @var DatabaseConfigCustomizer
*/ */
private $plugin; private $plugin;
/** /**
* @var ObjectProphecy * @var ObjectProphecy
*/ */
private $questionHelper; private $io;
/** /**
* @var ObjectProphecy * @var ObjectProphecy
*/ */
@@ -32,13 +28,11 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
public function setUp() public function setUp()
{ {
$this->questionHelper = $this->prophesize(QuestionHelper::class); $this->io = $this->prophesize(SymfonyStyle::class);
$this->io->title(Argument::any())->willReturn(null);
$this->filesystem = $this->prophesize(Filesystem::class); $this->filesystem = $this->prophesize(Filesystem::class);
$this->plugin = new DatabaseConfigCustomizerPlugin( $this->plugin = new DatabaseConfigCustomizer($this->filesystem->reveal());
$this->questionHelper->reveal(),
$this->filesystem->reveal()
);
} }
/** /**
@@ -46,22 +40,23 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
*/ */
public function configIsRequestedToTheUser() public function configIsRequestedToTheUser()
{ {
/** @var MethodProphecy $askSecret */ $choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('MySQL'); $ask = $this->io->ask(Argument::cetera())->willReturn('param');
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasDatabase()); $this->assertTrue($config->hasDatabase());
$this->assertEquals([ $this->assertEquals([
'DRIVER' => 'pdo_mysql', 'DRIVER' => 'pdo_mysql',
'NAME' => 'MySQL', 'NAME' => 'param',
'USER' => 'MySQL', 'USER' => 'param',
'PASSWORD' => 'MySQL', 'PASSWORD' => 'param',
'HOST' => 'MySQL', 'HOST' => 'param',
'PORT' => 'MySQL', 'PORT' => 'param',
], $config->getDatabase()); ], $config->getDatabase());
$askSecret->shouldHaveBeenCalledTimes(6); $choice->shouldHaveBeenCalledTimes(1);
$ask->shouldHaveBeenCalledTimes(5);
} }
/** /**
@@ -69,11 +64,9 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
*/ */
public function overwriteIsRequestedIfValueIsAlreadySet() public function overwriteIsRequestedIfValueIsAlreadySet()
{ {
/** @var MethodProphecy $ask */ $choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) { $confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$last = array_pop($args); $ask = $this->io->ask(Argument::cetera())->willReturn('MySQL');
return $last instanceof ConfirmationQuestion ? false : 'MySQL';
});
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$config->setDatabase([ $config->setDatabase([
'DRIVER' => 'pdo_pgsql', 'DRIVER' => 'pdo_pgsql',
@@ -84,7 +77,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'PORT' => 'MySQL', 'PORT' => 'MySQL',
]); ]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([ $this->assertEquals([
'DRIVER' => 'pdo_mysql', 'DRIVER' => 'pdo_mysql',
@@ -94,7 +87,9 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'HOST' => 'MySQL', 'HOST' => 'MySQL',
'PORT' => 'MySQL', 'PORT' => 'MySQL',
], $config->getDatabase()); ], $config->getDatabase());
$ask->shouldHaveBeenCalledTimes(7); $confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldHaveBeenCalledTimes(1);
$ask->shouldHaveBeenCalledTimes(5);
} }
/** /**
@@ -102,8 +97,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
*/ */
public function existingValueIsKeptIfRequested() public function existingValueIsKeptIfRequested()
{ {
/** @var MethodProphecy $ask */ $confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$config->setDatabase([ $config->setDatabase([
@@ -115,7 +109,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'PORT' => 'MySQL', 'PORT' => 'MySQL',
]); ]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([ $this->assertEquals([
'DRIVER' => 'pdo_pgsql', 'DRIVER' => 'pdo_pgsql',
@@ -125,7 +119,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'HOST' => 'MySQL', 'HOST' => 'MySQL',
'PORT' => 'MySQL', 'PORT' => 'MySQL',
], $config->getDatabase()); ], $config->getDatabase());
$ask->shouldHaveBeenCalledTimes(1); $confirm->shouldHaveBeenCalledTimes(1);
} }
/** /**
@@ -133,9 +127,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
*/ */
public function sqliteDatabaseIsImportedWhenRequested() public function sqliteDatabaseIsImportedWhenRequested()
{ {
/** @var MethodProphecy $ask */ $confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
/** @var MethodProphecy $copy */
$copy = $this->filesystem->copy(Argument::cetera())->willReturn(null); $copy = $this->filesystem->copy(Argument::cetera())->willReturn(null);
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
@@ -143,12 +135,12 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'DRIVER' => 'pdo_sqlite', 'DRIVER' => 'pdo_sqlite',
]); ]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([ $this->assertEquals([
'DRIVER' => 'pdo_sqlite', 'DRIVER' => 'pdo_sqlite',
], $config->getDatabase()); ], $config->getDatabase());
$ask->shouldHaveBeenCalledTimes(1); $confirm->shouldHaveBeenCalledTimes(1);
$copy->shouldHaveBeenCalledTimes(1); $copy->shouldHaveBeenCalledTimes(1);
} }
} }

View File

@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Install\Plugin\Factory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Install\Plugin\ApplicationConfigCustomizerPlugin;
use Shlinkio\Shlink\CLI\Install\Plugin\Factory\DefaultConfigCustomizerPluginFactory;
use Shlinkio\Shlink\CLI\Install\Plugin\LanguageConfigCustomizerPlugin;
use Symfony\Component\Console\Helper\QuestionHelper;
use Zend\ServiceManager\ServiceManager;
class DefaultConfigCustomizerPluginFactoryTest extends TestCase
{
/**
* @var DefaultConfigCustomizerPluginFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new DefaultConfigCustomizerPluginFactory();
}
/**
* @test
*/
public function createsProperService()
{
$instance = $this->factory->__invoke(new ServiceManager(['services' => [
QuestionHelper::class => $this->prophesize(QuestionHelper::class)->reveal(),
]]), ApplicationConfigCustomizerPlugin::class);
$this->assertInstanceOf(ApplicationConfigCustomizerPlugin::class, $instance);
$instance = $this->factory->__invoke(new ServiceManager(['services' => [
QuestionHelper::class => $this->prophesize(QuestionHelper::class)->reveal(),
]]), LanguageConfigCustomizerPlugin::class);
$this->assertInstanceOf(LanguageConfigCustomizerPlugin::class, $instance);
}
}

View File

@@ -5,30 +5,27 @@ namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\LanguageConfigCustomizerPlugin; use Shlinkio\Shlink\CLI\Install\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig; use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class LanguageConfigCustomizerPluginTest extends TestCase class LanguageConfigCustomizerTest extends TestCase
{ {
/** /**
* @var LanguageConfigCustomizerPlugin * @var LanguageConfigCustomizer
*/ */
protected $plugin; protected $plugin;
/** /**
* @var ObjectProphecy * @var ObjectProphecy
*/ */
protected $questionHelper; protected $io;
public function setUp() public function setUp()
{ {
$this->questionHelper = $this->prophesize(QuestionHelper::class); $this->io = $this->prophesize(SymfonyStyle::class);
$this->plugin = new LanguageConfigCustomizerPlugin($this->questionHelper->reveal()); $this->io->title(Argument::any())->willReturn(null);
$this->plugin = new LanguageConfigCustomizer();
} }
/** /**
@@ -36,18 +33,17 @@ class LanguageConfigCustomizerPluginTest extends TestCase
*/ */
public function configIsRequestedToTheUser() public function configIsRequestedToTheUser()
{ {
/** @var MethodProphecy $askSecret */ $ask = $this->io->choice(Argument::cetera())->willReturn('en');
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasLanguage()); $this->assertTrue($config->hasLanguage());
$this->assertEquals([ $this->assertEquals([
'DEFAULT' => 'en', 'DEFAULT' => 'en',
'CLI' => 'en', 'CLI' => 'en',
], $config->getLanguage()); ], $config->getLanguage());
$askSecret->shouldHaveBeenCalledTimes(2); $ask->shouldHaveBeenCalledTimes(2);
} }
/** /**
@@ -55,24 +51,22 @@ class LanguageConfigCustomizerPluginTest extends TestCase
*/ */
public function overwriteIsRequestedIfValueIsAlreadySet() public function overwriteIsRequestedIfValueIsAlreadySet()
{ {
/** @var MethodProphecy $ask */ $choice = $this->io->choice(Argument::cetera())->willReturn('es');
$ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) { $confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$last = array_pop($args);
return $last instanceof ConfirmationQuestion ? false : 'es';
});
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$config->setLanguage([ $config->setLanguage([
'DEFAULT' => 'en', 'DEFAULT' => 'en',
'CLI' => 'en', 'CLI' => 'en',
]); ]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([ $this->assertEquals([
'DEFAULT' => 'es', 'DEFAULT' => 'es',
'CLI' => 'es', 'CLI' => 'es',
], $config->getLanguage()); ], $config->getLanguage());
$ask->shouldHaveBeenCalledTimes(3); $choice->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledTimes(1);
} }
/** /**
@@ -80,8 +74,7 @@ class LanguageConfigCustomizerPluginTest extends TestCase
*/ */
public function existingValueIsKeptIfRequested() public function existingValueIsKeptIfRequested()
{ {
/** @var MethodProphecy $ask */ $ask = $this->io->confirm(Argument::cetera())->willReturn(true);
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$config->setLanguage([ $config->setLanguage([
@@ -89,7 +82,7 @@ class LanguageConfigCustomizerPluginTest extends TestCase
'CLI' => 'es', 'CLI' => 'es',
]); ]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([ $this->assertEquals([
'DEFAULT' => 'es', 'DEFAULT' => 'es',

View File

@@ -5,30 +5,27 @@ namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\UrlShortenerConfigCustomizerPlugin; use Shlinkio\Shlink\CLI\Install\Plugin\UrlShortenerConfigCustomizer;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig; use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class UrlShortenerConfigCustomizerPluginTest extends TestCase class UrlShortenerConfigCustomizerTest extends TestCase
{ {
/** /**
* @var UrlShortenerConfigCustomizerPlugin * @var UrlShortenerConfigCustomizer
*/ */
private $plugin; private $plugin;
/** /**
* @var ObjectProphecy * @var ObjectProphecy
*/ */
private $questionHelper; private $io;
public function setUp() public function setUp()
{ {
$this->questionHelper = $this->prophesize(QuestionHelper::class); $this->io = $this->prophesize(SymfonyStyle::class);
$this->plugin = new UrlShortenerConfigCustomizerPlugin($this->questionHelper->reveal()); $this->io->title(Argument::any())->willReturn(null);
$this->plugin = new UrlShortenerConfigCustomizer();
} }
/** /**
@@ -36,20 +33,23 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase
*/ */
public function configIsRequestedToTheUser() public function configIsRequestedToTheUser()
{ {
/** @var MethodProphecy $askSecret */ $choice = $this->io->choice(Argument::cetera())->willReturn('something');
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('something'); $ask = $this->io->ask(Argument::cetera())->willReturn('something');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasUrlShortener()); $this->assertTrue($config->hasUrlShortener());
$this->assertEquals([ $this->assertEquals([
'SCHEMA' => 'something', 'SCHEMA' => 'something',
'HOSTNAME' => 'something', 'HOSTNAME' => 'something',
'CHARS' => 'something', 'CHARS' => 'something',
'VALIDATE_URL' => 'something', 'VALIDATE_URL' => true,
], $config->getUrlShortener()); ], $config->getUrlShortener());
$askSecret->shouldHaveBeenCalledTimes(4); $ask->shouldHaveBeenCalledTimes(2);
$choice->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledTimes(1);
} }
/** /**
@@ -57,20 +57,18 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase
*/ */
public function overwriteIsRequestedIfValueIsAlreadySet() public function overwriteIsRequestedIfValueIsAlreadySet()
{ {
/** @var MethodProphecy $ask */ $choice = $this->io->choice(Argument::cetera())->willReturn('foo');
$ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) { $ask = $this->io->ask(Argument::cetera())->willReturn('foo');
$last = array_pop($args); $confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
return $last instanceof ConfirmationQuestion ? false : 'foo';
});
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$config->setUrlShortener([ $config->setUrlShortener([
'SCHEMA' => 'bar', 'SCHEMA' => 'bar',
'HOSTNAME' => 'bar', 'HOSTNAME' => 'bar',
'CHARS' => 'bar', 'CHARS' => 'bar',
'VALIDATE_URL' => 'bar', 'VALIDATE_URL' => true,
]); ]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([ $this->assertEquals([
'SCHEMA' => 'foo', 'SCHEMA' => 'foo',
@@ -78,7 +76,9 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase
'CHARS' => 'foo', 'CHARS' => 'foo',
'VALIDATE_URL' => false, 'VALIDATE_URL' => false,
], $config->getUrlShortener()); ], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(5); $ask->shouldHaveBeenCalledTimes(2);
$choice->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledTimes(2);
} }
/** /**
@@ -86,8 +86,7 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase
*/ */
public function existingValueIsKeptIfRequested() public function existingValueIsKeptIfRequested()
{ {
/** @var MethodProphecy $ask */ $confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig(); $config = new CustomizableAppConfig();
$config->setUrlShortener([ $config->setUrlShortener([
@@ -97,7 +96,7 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase
'VALIDATE_URL' => 'foo', 'VALIDATE_URL' => 'foo',
]); ]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config); $this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([ $this->assertEquals([
'SCHEMA' => 'foo', 'SCHEMA' => 'foo',
@@ -105,6 +104,6 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase
'CHARS' => 'foo', 'CHARS' => 'foo',
'VALIDATE_URL' => 'foo', 'VALIDATE_URL' => 'foo',
], $config->getUrlShortener()); ], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(1); $confirm->shouldHaveBeenCalledTimes(1);
} }
} }

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Common\Factory;
use Interop\Container\ContainerInterface; use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException; use Interop\Container\Exception\ContainerException;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
use Zend\Expressive\Middleware\ImplicitOptionsMiddleware; use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
use Zend\ServiceManager\Exception\ServiceNotCreatedException; use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException; use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface; use Zend\ServiceManager\Factory\FactoryInterface;

View File

@@ -22,10 +22,11 @@ class IpLocationResolver implements IpLocationResolverInterface
} }
/** /**
* @param $ipAddress * @param string $ipAddress
* @return array * @return array
* @throws WrongIpException
*/ */
public function resolveIpLocation($ipAddress) public function resolveIpLocation(string $ipAddress): array
{ {
try { try {
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress)); $response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));

View File

@@ -3,11 +3,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service; namespace Shlinkio\Shlink\Common\Service;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
interface IpLocationResolverInterface interface IpLocationResolverInterface
{ {
/** /**
* @param $ipAddress * @param string $ipAddress
* @return array * @return array
* @throws WrongIpException
*/ */
public function resolveIpLocation($ipAddress); public function resolveIpLocation(string $ipAddress): array;
} }

View File

@@ -6,11 +6,11 @@ namespace Shlinkio\Shlink\Common\Util;
class DateRange class DateRange
{ {
/** /**
* @var \DateTimeInterface * @var \DateTimeInterface|null
*/ */
private $startDate; private $startDate;
/** /**
* @var \DateTimeInterface * @var \DateTimeInterface|null
*/ */
private $endDate; private $endDate;
@@ -21,7 +21,7 @@ class DateRange
} }
/** /**
* @return \DateTimeInterface * @return \DateTimeInterface|null
*/ */
public function getStartDate() public function getStartDate()
{ {
@@ -29,7 +29,7 @@ class DateRange
} }
/** /**
* @return \DateTimeInterface * @return \DateTimeInterface|null
*/ */
public function getEndDate() public function getEndDate()
{ {
@@ -39,8 +39,8 @@ class DateRange
/** /**
* @return bool * @return bool
*/ */
public function isEmpty() public function isEmpty(): bool
{ {
return is_null($this->startDate) && is_null($this->endDate); return $this->startDate === null && $this->endDate === null;
} }
} }

View File

@@ -6,7 +6,7 @@ namespace ShlinkioTest\Shlink\Common\Factory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory; use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
use Zend\Expressive\Middleware\ImplicitOptionsMiddleware; use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
use Zend\ServiceManager\ServiceManager; use Zend\ServiceManager\ServiceManager;
class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase

View File

@@ -55,7 +55,11 @@ return [
Service\Tag\TagService::class => ['em'], Service\Tag\TagService::class => ['em'],
// Middleware // Middleware
Action\RedirectAction::class => [Service\UrlShortener::class, Service\VisitsTracker::class], Action\RedirectAction::class => [
Service\UrlShortener::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
],
Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'], Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'],
Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class], Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class],
Middleware\QrCodeCacheMiddleware::class => [Cache::class], Middleware\QrCodeCacheMiddleware::class => [Cache::class],

View File

@@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait; use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Zend\Diactoros\Response\RedirectResponse; use Zend\Diactoros\Response\RedirectResponse;
@@ -26,11 +27,19 @@ class RedirectAction implements MiddlewareInterface
* @var VisitsTrackerInterface * @var VisitsTrackerInterface
*/ */
private $visitTracker; private $visitTracker;
/**
* @var AppOptions
*/
private $appOptions;
public function __construct(UrlShortenerInterface $urlShortener, VisitsTrackerInterface $visitTracker) public function __construct(
{ UrlShortenerInterface $urlShortener,
VisitsTrackerInterface $visitTracker,
AppOptions $appOptions
) {
$this->urlShortener = $urlShortener; $this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker; $this->visitTracker = $visitTracker;
$this->appOptions = $appOptions;
} }
/** /**
@@ -45,18 +54,16 @@ class RedirectAction implements MiddlewareInterface
public function process(Request $request, DelegateInterface $delegate) public function process(Request $request, DelegateInterface $delegate)
{ {
$shortCode = $request->getAttribute('shortCode', ''); $shortCode = $request->getAttribute('shortCode', '');
$query = $request->getQueryParams();
$disableTrackParam = $this->appOptions->getDisableTrackParam();
try { try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode); $longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
// If provided shortCode does not belong to a valid long URL, dispatch next middleware, which will trigger
// a not-found error
if ($longUrl === null) {
return $delegate->process($request);
}
// Track visit to this short code // Track visit to this short code
$this->visitTracker->track($shortCode, $request); if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) {
$this->visitTracker->track($shortCode, $request);
}
// Return a redirect response to the long URL. // Return a redirect response to the long URL.
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes // Use a temporary redirect to make sure browsers always hit the server for analytics purposes

View File

@@ -3,9 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception; namespace Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Common\Exception\ExceptionInterface; class EntityDoesNotExistException extends RuntimeException
class EntityDoesNotExistException extends \RuntimeException implements ExceptionInterface
{ {
public static function createFromEntityAndConditions($entityName, array $conditions) public static function createFromEntityAndConditions($entityName, array $conditions)
{ {

View File

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

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
}

View File

@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception; namespace Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
class InvalidShortCodeException extends RuntimeException class InvalidShortCodeException extends RuntimeException
{ {
public static function fromCharset($shortCode, $charSet, \Exception $previous = null) public static function fromCharset($shortCode, $charSet, \Exception $previous = null)

View File

@@ -3,13 +3,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception; namespace Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
class InvalidUrlException extends RuntimeException class InvalidUrlException extends RuntimeException
{ {
public static function fromUrl($url, \Exception $previous = null) public static function fromUrl($url, \Throwable $previous = null)
{ {
$code = isset($previous) ? $previous->getCode() : -1; $code = isset($previous) ? $previous->getCode() : -1;
return new static(sprintf('Provided URL "%s" is not an exisitng and valid URL', $url), $code, $previous); return new static(sprintf('Provided URL "%s" is not an existing and valid URL', $url), $code, $previous);
} }
} }

View File

@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception; namespace Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
class NonUniqueSlugException extends InvalidArgumentException class NonUniqueSlugException extends InvalidArgumentException
{ {
public static function fromSlug(string $slug): self public static function fromSlug(string $slug): self

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
class RuntimeException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Zend\InputFilter\InputFilterInterface;
class ValidationException extends RuntimeException
{
/**
* @var array
*/
private $invalidElements;
public function __construct(
string $message = '',
array $invalidElements = [],
int $code = 0,
\Throwable $previous = null
) {
$this->invalidElements = $invalidElements;
parent::__construct($message, $code, $previous);
}
/**
* @param InputFilterInterface $inputFilter
* @param \Throwable|null $prev
* @return ValidationException
*/
public static function fromInputFilter(InputFilterInterface $inputFilter, \Throwable $prev = null): self
{
return static::fromArray($inputFilter->getMessages(), $prev);
}
/**
* @param array $invalidData
* @param \Throwable|null $prev
* @return ValidationException
*/
public static function fromArray(array $invalidData, \Throwable $prev = null): self
{
return new self(
\sprintf(
'Provided data is not valid. These are the messages:%s%s%s',
PHP_EOL,
self::formMessagesToString($invalidData),
PHP_EOL
),
$invalidData,
-1,
$prev
);
}
private static function formMessagesToString(array $messages = [])
{
$text = '';
foreach ($messages as $name => $messageSet) {
$text .= \sprintf(
"\n\t'%s' => %s",
$name,
\is_array($messageSet) ? \print_r($messageSet, true) : $messageSet
);
}
return $text;
}
/**
* @return array
*/
public function getInvalidElements(): array
{
return $this->invalidElements;
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
final class ShortUrlMeta
{
/**
* @var \DateTime|null
*/
private $validSince;
/**
* @var \DateTime|null
*/
private $validUntil;
/**
* @var string|null
*/
private $customSlug;
/**
* @var int|null
*/
private $maxVisits;
// Force named constructors
private function __construct()
{
}
/**
* @param array $data
* @return ShortUrlMeta
* @throws ValidationException
*/
public static function createFromRawData(array $data): self
{
$instance = new self();
$instance->validate($data);
return $instance;
}
/**
* @param string|\DateTimeInterface|null $validSince
* @param string|\DateTimeInterface|null $validUntil
* @param string|null $customSlug
* @param int|null $maxVisits
* @return ShortUrlMeta
* @throws ValidationException
*/
public static function createFromParams(
$validSince = null,
$validUntil = null,
$customSlug = null,
$maxVisits = null
): self {
// We do not type hint the arguments because that will be done by the validation process
$instance = new self();
$instance->validate([
ShortUrlMetaInputFilter::VALID_SINCE => $validSince,
ShortUrlMetaInputFilter::VALID_UNTIL => $validUntil,
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits,
]);
return $instance;
}
/**
* @param array $data
* @throws ValidationException
*/
private function validate(array $data)
{
$inputFilter = new ShortUrlMetaInputFilter($data);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
$this->validSince = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE);
$this->validSince = $this->validSince !== null ? new \DateTime($this->validSince) : null;
$this->validUntil = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL);
$this->validUntil = $this->validUntil !== null ? new \DateTime($this->validUntil) : null;
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null;
}
/**
* @return \DateTime|null
*/
public function getValidSince()
{
return $this->validSince;
}
public function hasValidSince(): bool
{
return $this->validSince !== null;
}
/**
* @return \DateTime|null
*/
public function getValidUntil()
{
return $this->validUntil;
}
public function hasValidUntil(): bool
{
return $this->validUntil !== null;
}
/**
* @return null|string
*/
public function getCustomSlug()
{
return $this->customSlug;
}
public function hasCustomSlug(): bool
{
return $this->customSlug !== null;
}
/**
* @return int|null
*/
public function getMaxVisits()
{
return $this->maxVisits;
}
public function hasMaxVisits(): bool
{
return $this->maxVisits !== null;
}
}

View File

@@ -22,6 +22,10 @@ class AppOptions extends AbstractOptions
* @var string * @var string
*/ */
protected $secretKey = ''; protected $secretKey = '';
/**
* @var string|null
*/
protected $disableTrackParam;
/** /**
* AppOptions constructor. * AppOptions constructor.
@@ -86,6 +90,24 @@ class AppOptions extends AbstractOptions
return $this; return $this;
} }
/**
* @return string|null
*/
public function getDisableTrackParam()
{
return $this->disableTrackParam;
}
/**
* @param string|null $disableTrackParam
* @return $this|self
*/
protected function setDisableTrackParam($disableTrackParam): self
{
$this->disableTrackParam = $disableTrackParam;
return $this;
}
/** /**
* @return string * @return string
*/ */

View File

@@ -3,10 +3,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service; namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter; use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Zend\Paginator\Paginator; use Zend\Paginator\Paginator;
@@ -16,11 +17,11 @@ class ShortUrlService implements ShortUrlServiceInterface
use TagManagerTrait; use TagManagerTrait;
/** /**
* @var EntityManagerInterface * @var ORM\EntityManagerInterface
*/ */
private $em; private $em;
public function __construct(EntityManagerInterface $em) public function __construct(ORM\EntityManagerInterface $em)
{ {
$this->em = $em; $this->em = $em;
} }
@@ -49,9 +50,48 @@ class ShortUrlService implements ShortUrlServiceInterface
* @return ShortUrl * @return ShortUrl
* @throws InvalidShortCodeException * @throws InvalidShortCodeException
*/ */
public function setTagsByShortCode($shortCode, array $tags = []) public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl
{ {
/** @var ShortUrl $shortUrl */ $shortUrl = $this->findByShortCode($shortCode);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->flush();
return $shortUrl;
}
/**
* @param string $shortCode
* @param ShortUrlMeta $shortCodeMeta
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl
{
$shortUrl = $this->findByShortCode($shortCode);
if ($shortCodeMeta->hasValidSince()) {
$shortUrl->setValidSince($shortCodeMeta->getValidSince());
}
if ($shortCodeMeta->hasValidUntil()) {
$shortUrl->setValidUntil($shortCodeMeta->getValidUntil());
}
if ($shortCodeMeta->hasMaxVisits()) {
$shortUrl->setMaxVisits($shortCodeMeta->getMaxVisits());
}
/** @var ORM\EntityManager $em */
$em = $this->em;
$em->flush($shortUrl);
return $shortUrl;
}
/**
* @param string $shortCode
* @return ShortUrl
* @throws InvalidShortCodeException
*/
private function findByShortCode(string $shortCode): ShortUrl
{
/** @var ShortUrl|null $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode, 'shortCode' => $shortCode,
]); ]);
@@ -59,9 +99,6 @@ class ShortUrlService implements ShortUrlServiceInterface
throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); throw InvalidShortCodeException::fromNotFoundShortCode($shortCode);
} }
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->flush();
return $shortUrl; return $shortUrl;
} }
} }

View File

@@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Zend\Paginator\Paginator; use Zend\Paginator\Paginator;
interface ShortUrlServiceInterface interface ShortUrlServiceInterface
@@ -24,5 +25,13 @@ interface ShortUrlServiceInterface
* @return ShortUrl * @return ShortUrl
* @throws InvalidShortCodeException * @throws InvalidShortCodeException
*/ */
public function setTagsByShortCode($shortCode, array $tags = []); public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl;
/**
* @param string $shortCode
* @param ShortUrlMeta $shortCodeMeta
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl;
} }

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\Tag; namespace Shlinkio\Shlink\Core\Service\Tag;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepository;
@@ -15,11 +15,11 @@ class TagService implements TagServiceInterface
use TagManagerTrait; use TagManagerTrait;
/** /**
* @var EntityManagerInterface * @var ORM\EntityManagerInterface
*/ */
private $em; private $em;
public function __construct(EntityManagerInterface $em) public function __construct(ORM\EntityManagerInterface $em)
{ {
$this->em = $em; $this->em = $em;
} }
@@ -63,6 +63,7 @@ class TagService implements TagServiceInterface
* @param string $newName * @param string $newName
* @return Tag * @return Tag
* @throws EntityDoesNotExistException * @throws EntityDoesNotExistException
* @throws ORM\OptimisticLockException
*/ */
public function renameTag($oldName, $newName) public function renameTag($oldName, $newName)
{ {
@@ -74,7 +75,10 @@ class TagService implements TagServiceInterface
} }
$tag->setName($newName); $tag->setName($newName);
$this->em->flush($tag);
/** @var ORM\EntityManager $em */
$em = $this->em;
$em->flush($tag);
return $tag; return $tag;
} }

View File

@@ -7,16 +7,15 @@ use Cocur\Slugify\Slugify;
use Cocur\Slugify\SlugifyInterface; use Cocur\Slugify\SlugifyInterface;
use Doctrine\Common\Cache\Cache; use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMException;
use GuzzleHttp\ClientInterface; use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\TagManagerTrait;
@@ -90,6 +89,7 @@ class UrlShortener implements UrlShortenerInterface
int $maxVisits = null int $maxVisits = null
): string { ): string {
// If the url already exists in the database, just return its short code // If the url already exists in the database, just return its short code
/** @var ShortUrl|null $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'originalUrl' => $url, 'originalUrl' => $url,
]); ]);
@@ -118,14 +118,14 @@ class UrlShortener implements UrlShortenerInterface
$this->em->flush(); $this->em->flush();
// Generate the short code and persist it // Generate the short code and persist it
$shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode($shortUrl->getId()); $shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
$shortUrl->setShortCode($shortCode) $shortUrl->setShortCode($shortCode)
->setTags($this->tagNamesToEntities($this->em, $tags)); ->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->flush(); $this->em->flush();
$this->em->commit(); $this->em->commit();
return $shortCode; return $shortCode;
} catch (ORMException $e) { } catch (\Throwable $e) {
if ($this->em->getConnection()->isTransactionActive()) { if ($this->em->getConnection()->isTransactionActive()) {
$this->em->rollback(); $this->em->rollback();
$this->em->close(); $this->em->close();
@@ -155,13 +155,13 @@ class UrlShortener implements UrlShortenerInterface
/** /**
* Generates the unique shortcode for an autoincrement ID * Generates the unique shortcode for an autoincrement ID
* *
* @param int $id * @param float $id
* @return string * @return string
*/ */
private function convertAutoincrementIdToShortCode($id): string private function convertAutoincrementIdToShortCode(float $id): string
{ {
$id = ((int) $id) + 200000; // Increment the Id so that the generated shortcode is not too short $id += 200000; // Increment the Id so that the generated shortcode is not too short
$length = strlen($this->chars); $length = \strlen($this->chars);
$code = ''; $code = '';
while ($id > 0) { while ($id > 0) {

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service; namespace Shlinkio\Shlink\Core\Service;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
interface UrlShortenerInterface interface UrlShortenerInterface
{ {

View File

@@ -3,23 +3,22 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service; namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM\EntityManager; use Doctrine\ORM;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository;
class VisitsTracker implements VisitsTrackerInterface class VisitsTracker implements VisitsTrackerInterface
{ {
/** /**
* @var EntityManagerInterface|EntityManager * @var ORM\EntityManagerInterface
*/ */
private $em; private $em;
public function __construct(EntityManagerInterface $em) public function __construct(ORM\EntityManagerInterface $em)
{ {
$this->em = $em; $this->em = $em;
} }
@@ -29,6 +28,8 @@ class VisitsTracker implements VisitsTrackerInterface
* *
* @param string $shortCode * @param string $shortCode
* @param ServerRequestInterface $request * @param ServerRequestInterface $request
* @throws ORM\ORMInvalidArgumentException
* @throws ORM\OptimisticLockException
*/ */
public function track($shortCode, ServerRequestInterface $request) public function track($shortCode, ServerRequestInterface $request)
{ {
@@ -43,8 +44,10 @@ class VisitsTracker implements VisitsTrackerInterface
->setReferer($request->getHeaderLine('Referer')) ->setReferer($request->getHeaderLine('Referer'))
->setRemoteAddr($this->findOutRemoteAddr($request)); ->setRemoteAddr($this->findOutRemoteAddr($request));
$this->em->persist($visit); /** @var ORM\EntityManager $em */
$this->em->flush($visit); $em = $this->em;
$em->persist($visit);
$em->flush($visit);
} }
/** /**
@@ -66,14 +69,14 @@ class VisitsTracker implements VisitsTrackerInterface
/** /**
* Returns the visits on certain short code * Returns the visits on certain short code
* *
* @param $shortCode * @param string $shortCode
* @param DateRange $dateRange * @param DateRange $dateRange
* @return Visit[] * @return Visit[]
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function info($shortCode, DateRange $dateRange = null): array public function info(string $shortCode, DateRange $dateRange = null): array
{ {
/** @var ShortUrl $shortUrl */ /** @var ShortUrl|null $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode, 'shortCode' => $shortCode,
]); ]);

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service; namespace Shlinkio\Shlink\Core\Service;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
interface VisitsTrackerInterface interface VisitsTrackerInterface
{ {
@@ -21,10 +21,10 @@ interface VisitsTrackerInterface
/** /**
* Returns the visits on certain short code * Returns the visits on certain short code
* *
* @param $shortCode * @param string $shortCode
* @param DateRange $dateRange * @param DateRange $dateRange
* @return Visit[] * @return Visit[]
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function info($shortCode, DateRange $dateRange = null): array; public function info(string $shortCode, DateRange $dateRange = null): array;
} }

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use Zend\Filter\StringTrim;
use Zend\Filter\StripTags;
use Zend\InputFilter\Input;
trait InputFactoryTrait
{
public function createInput($name, $required = true): Input
{
$input = new Input($name);
$input->setRequired($required)
->getFilterChain()->attach(new StripTags())
->attach(new StringTrim());
return $input;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use Zend\I18n\Validator\IsInt;
use Zend\InputFilter\InputFilter;
use Zend\Validator\Date;
use Zend\Validator\GreaterThan;
class ShortUrlMetaInputFilter extends InputFilter
{
use InputFactoryTrait;
const VALID_SINCE = 'validSince';
const VALID_UNTIL = 'validUntil';
const CUSTOM_SLUG = 'customSlug';
const MAX_VISITS = 'maxVisits';
public function __construct(array $data = null)
{
$this->initialize();
if ($data !== null) {
$this->setData($data);
}
}
private function initialize()
{
$validSince = $this->createInput(self::VALID_SINCE, false);
$validSince->getValidatorChain()->attach(new Date(['format' => \DateTime::ATOM]));
$this->add($validSince);
$validUntil = $this->createInput(self::VALID_UNTIL, false);
$validUntil->getValidatorChain()->attach(new Date(['format' => \DateTime::ATOM]));
$this->add($validUntil);
$this->add($this->createInput(self::CUSTOM_SLUG, false));
$maxVisits = $this->createInput(self::MAX_VISITS, false);
$maxVisits->getValidatorChain()->attach(new IsInt())
->attach(new GreaterThan(['min' => 1, 'inclusive' => true]));
$this->add($maxVisits);
}
}

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