Compare commits

...

134 Commits

Author SHA1 Message Date
Alejandro Celaya
b3664597b0 Merge branch 'develop' 2018-05-07 11:27:13 +02:00
Alejandro Celaya
8cfb4f61ca Merge pull request #148 from acelaya/feature/1.9.0
Version 1.9.0
2018-05-07 11:26:27 +02:00
Alejandro Celaya
b0dbb2dae4 Updated CreateShortCodeContentNegotiationMiddleware so that query parameter takes precedence over Accept header 2018-05-07 11:17:10 +02:00
Alejandro Celaya
7c6da4985d Updated build script to delete more development-specific files 2018-05-07 11:09:32 +02:00
Alejandro Celaya
386b0dfb7b Updated changelog 2018-05-07 11:03:28 +02:00
Alejandro Celaya
1437ff48ce Ensured all core actions log errors 2018-05-07 10:58:49 +02:00
Alejandro Celaya
63294f20ee Updated language files 2018-05-06 12:36:07 +02:00
Alejandro Celaya
d8acc3c247 Removed unused use statement 2018-05-06 12:34:21 +02:00
Alejandro Celaya
52d8ffa212 Improved CreateShortCodeContentNegotiationMiddleware sho that it takes into account the case in which an error is returned from next middleware 2018-05-06 12:28:22 +02:00
Alejandro Celaya
98ad2816e8 Documented new endpoint to create short URLs in a single step 2018-05-06 12:19:08 +02:00
Alejandro Celaya
9d890f4227 Created CreateShortCodeContentNegotiationMiddleware 2018-05-03 19:04:40 +02:00
Alejandro Celaya
0932d04907 Fixed tests namespaces to match their subject under test 2018-05-03 18:34:45 +02:00
Alejandro Celaya
1f78b5c524 Improved CreateShortCodeContentNegotiationMiddleware so that it can determine the format based on a query partameter 2018-05-03 18:32:32 +02:00
Alejandro Celaya
59f10619ba Created middleware used with short codes creation actions to handle content negotiation 2018-05-03 18:26:31 +02:00
Alejandro Celaya
334710e92c Added middleware which injects the content-length header in the response if not present 2018-05-03 18:25:57 +02:00
Alejandro Celaya
75b8175824 Fixed coding styles in config file 2018-05-03 18:05:16 +02:00
Alejandro Celaya
8a74ef2a33 Moved action to subnamespace 2018-05-03 18:04:00 +02:00
Alejandro Celaya
d05ac5ce9d Moved action to subnamespace 2018-05-03 18:03:10 +02:00
Alejandro Celaya
3100fffa2b Moved action to subnamespace 2018-05-03 18:02:45 +02:00
Alejandro Celaya
6bbacb1017 Moved action to subnamespace 2018-05-03 18:01:57 +02:00
Alejandro Celaya
4403dc5df9 Moved action to subnamespace 2018-05-03 18:00:32 +02:00
Alejandro Celaya
fdc637c23d Moved action to subnamespace 2018-05-03 17:59:28 +02:00
Alejandro Celaya
b99d662417 Created SingleStepCreateShortCodeActionTest 2018-05-03 17:57:43 +02:00
Alejandro Celaya
eb9a964c66 Removed unused use statement 2018-05-03 13:34:13 +02:00
Alejandro Celaya
e5ef8d7f8c Created action which allows short URLs to be created on a single API request 2018-05-03 13:21:43 +02:00
Alejandro Celaya
28650aee2b Fixed case sensitivity errors 2018-05-03 12:19:51 +02:00
Alejandro Celaya
a2294704e6 Split try catch to prevent undefined variables 2018-05-01 19:38:44 +02:00
Alejandro Celaya
e5e1aa2ff4 Defined abstract action which handles short codes generations 2018-05-01 19:35:12 +02:00
Alejandro Celaya
2f5290b9d3 Moved whitelisted routes in CheckAuthenticationMiddleware to external configuration 2018-05-01 18:36:42 +02:00
Alejandro Celaya
ef3c4aadf2 Moved most of rest routes config to their actions 2018-05-01 18:28:37 +02:00
Alejandro Celaya
c9ce56eea5 Added public method in AbstractRestAction which builds route definition 2018-05-01 18:16:44 +02:00
Alejandro Celaya
4fee656f96 Prepared version 1.9.0 2018-05-01 10:10:19 +02:00
Alejandro Celaya
d2a04259f5 Merge branch 'develop' 2018-04-07 09:06:45 +02:00
Alejandro Celaya
e504daa1ba Merge pull request #142 from acelaya/develop
Develop
2018-04-07 09:05:56 +02:00
Alejandro Celaya
8793a67ce9 Reduced the number of includes by pointing to dcotrine scripts with extension 2018-04-07 08:37:41 +02:00
Alejandro Celaya
b4ded374e9 Updated changelog 2018-04-07 08:32:06 +02:00
Alejandro Celaya
91d350b12f Removed path workaround in PathVersionMiddleware and simplified code 2018-04-07 08:31:03 +02:00
Alejandro Celaya
b3e25f28fd Added v1.8.1 to changelog 2018-04-07 08:25:01 +02:00
Alejandro Celaya
aca89f9abe Updated links to doctrine CLI scripts to avoid depending on symlinks 2018-04-07 08:21:34 +02:00
Alejandro Celaya
243075dd78 Merge branch 'develop' 2018-03-29 09:52:00 +02:00
Alejandro Celaya
7130425896 Merge pull request #133 from acelaya/feature/1.8.0
1.8.0
2018-03-29 09:50:58 +02:00
Alejandro Celaya
fe9ab20cbb Applied some improvements 2018-03-27 23:57:29 +02:00
Alejandro Celaya
6935b2ebe2 Updated system so that NotFoundDelegate is used 2018-03-26 20:37:04 +02:00
Alejandro Celaya
3dcc510da1 Updated to symfony 4 2018-03-26 20:32:12 +02:00
Alejandro Celaya
2f26c82fa6 Removed expressive migration tool from dev dependencies 2018-03-26 20:25:30 +02:00
Alejandro Celaya
9ddb60a882 Updated changelog including v1.8.0 2018-03-26 20:22:57 +02:00
Alejandro Celaya
210b08b61f Created PixelActionTest 2018-03-26 20:17:38 +02:00
Alejandro Celaya
42fe4bd5ce Created new action to track visits, which returns an empty pixel 2018-03-26 20:13:03 +02:00
Alejandro Celaya
1b2a0820e5 Updated to phpunit 7 and dropped dbunit dependency 2018-03-26 19:09:10 +02:00
Alejandro Celaya
6cf0155417 Updated minimum required MSI 2018-03-26 19:06:49 +02:00
Alejandro Celaya
9b8be3e5b8 Fixed phpstan errors 2018-03-26 19:05:26 +02:00
Alejandro Celaya
a27b01b895 Fixed tests 2018-03-26 19:02:41 +02:00
Alejandro Celaya
16dd1838aa Updated to expressive 3 2018-03-26 18:49:28 +02:00
Alejandro Celaya
f788d6872f Added infection to the build matrix 2018-03-26 18:16:59 +02:00
Alejandro Celaya
d0df007812 Dropped support for PHP 7.0 2018-03-26 18:16:59 +02:00
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
185 changed files with 3613 additions and 1972 deletions

1
.gitattributes vendored
View File

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

View File

@@ -2,11 +2,9 @@ language: php
branches:
only:
- master
- develop
- /.*/
php:
- 7
- 7.1
- 7.2

View File

@@ -1,5 +1,75 @@
## CHANGELOG
### 1.9.0
**Features**
* [147: Allow short URLs to be created on the fly with query param authentication](https://github.com/shlinkio/shlink/issues/147)
**Bugs:**
* [139: Make sure all core actions log exceptions](https://github.com/shlinkio/shlink/issues/139)
### 1.8.1
**Tasks**
* [141: Remove workaround used in PathVersionMiddleware](https://github.com/shlinkio/shlink/issues/141)
**Bugs:**
* [140: Installation failed. Warning thrown while trying to include doctrine script](https://github.com/shlinkio/shlink/issues/140)
### 1.8.0
**Features**
* [125: Implement a path which returns a 1px image instead of a redirection](https://github.com/shlinkio/shlink/issues/125)
**Enhancements:**
* [130: Update to Expressive 3](https://github.com/shlinkio/shlink/issues/130)
* [137: Update symfony packages to v4](https://github.com/shlinkio/shlink/issues/137)
**Tasks**
* [131: Drop support for PHP 7](https://github.com/shlinkio/shlink/issues/131)
* [132: Add infection to improve tests](https://github.com/shlinkio/shlink/issues/132)
### 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**

View File

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

View File

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

View File

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

View File

@@ -33,8 +33,12 @@ rm composer.*
rm LICENSE
rm indocker
rm docker-compose.yml
rm docker-compose.override.yml
rm docker-compose.override.yml.dist
rm func_tests_bootstrap.php
rm php*
rm README.md
rm infection.json
rm -rf build
rm -ff data/database.sqlite
rm -rf data/infra

View File

@@ -12,48 +12,51 @@
}
],
"require": {
"php": "^7.0",
"acelaya/ze-content-based-error-handler": "^2.0",
"php": "^7.1",
"acelaya/ze-content-based-error-handler": "^2.2",
"cocur/slugify": "^3.0",
"doctrine/annotations": "^1.4 <1.5",
"doctrine/cache": "^1.6 <1.7",
"doctrine/collections": "^1.4 <1.5",
"doctrine/common": "^2.7 <2.8",
"doctrine/dbal": "^2.5 <2.6",
"doctrine/annotations": "^1.4",
"doctrine/cache": "^1.6",
"doctrine/collections": "^1.4",
"doctrine/common": "^2.7",
"doctrine/dbal": "^2.5",
"doctrine/migrations": "^1.4",
"doctrine/orm": "^2.5 <2.6",
"endroid/qrcode": "^1.7",
"doctrine/orm": "^2.5",
"endroid/qr-code": "^1.7",
"firebase/php-jwt": "^4.0",
"guzzlehttp/guzzle": "^6.2",
"http-interop/http-middleware": "^0.4.1",
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master",
"symfony/console": "^3.0",
"symfony/filesystem": "^3.0",
"symfony/process": "^3.0",
"symfony/console": "^4.0",
"symfony/filesystem": "^4.0",
"symfony/process": "^4.0",
"theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0",
"zendframework/zend-expressive": "^2.0",
"zendframework/zend-expressive-fastroute": "^2.0",
"zendframework/zend-expressive-helpers": "^4.2",
"zendframework/zend-expressive-platesrenderer": "^1.3",
"zendframework/zend-diactoros": "^1.7",
"zendframework/zend-expressive": "^3.0",
"zendframework/zend-expressive-fastroute": "^3.0",
"zendframework/zend-expressive-helpers": "^5.0",
"zendframework/zend-expressive-platesrenderer": "^2.0",
"zendframework/zend-i18n": "^2.7",
"zendframework/zend-inputfilter": "^2.8",
"zendframework/zend-paginator": "^2.6",
"zendframework/zend-servicemanager": "^3.0",
"zendframework/zend-servicemanager": "^3.2",
"zendframework/zend-stdlib": "^3.0"
},
"require-dev": {
"filp/whoops": "^2.0",
"phpunit/dbunit": "^3.0",
"phpunit/phpcov": "^4.0",
"phpunit/phpunit": "^6.0",
"infection/infection": "^0.8.1",
"phpstan/phpstan": "0.9",
"phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.0",
"slevomat/coding-standard": "^4.0",
"squizlabs/php_codesniffer": "^3.1",
"symfony/var-dumper": "^3.0",
"vlucas/phpdotenv": "^2.2",
"zendframework/zend-expressive-tooling": "^0.4"
"squizlabs/php_codesniffer": "^3.1 <3.2",
"symfony/dotenv": "^4.0",
"symfony/var-dumper": "^4.0",
"zendframework/zend-component-installer": "^2.1",
"zendframework/zend-expressive-tooling": "^1.0"
},
"autoload": {
"psr-4": {
@@ -83,8 +86,10 @@
"scripts": {
"check": [
"@cs",
"@stan",
"@test",
"@func-test"
"@func-test",
"@infect"
],
"cs": "phpcs",
"cs-fix": "phpcbf",
@@ -96,13 +101,17 @@
"@test",
"@func-test",
"phpcov merge build --html build/html"
]
],
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"infect": "infection --threads=4 --min-msi=65 --only-covered --log-verbosity=2",
"infect-show": "infection --threads=4 --min-msi=65 --only-covered --log-verbosity=2 --show-mutations",
"expressive": "expressive"
},
"config": {
"process-timeout": 0,
"sort-packages": true,
"platform": {
"php": "7.0"
"php": "7.1.8"
}
}
}

View File

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

View File

@@ -5,26 +5,24 @@ use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory
use Zend\Expressive;
use Zend\Expressive\Container;
use Zend\Expressive\Helper;
use Zend\Expressive\Middleware;
use Zend\Expressive\Plates;
use Zend\Expressive\Router;
use Zend\Expressive\Template;
use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\Stratigility\Middleware\ErrorHandler;
return [
'dependencies' => [
'factories' => [
Expressive\Application::class => Container\ApplicationFactory::class,
Template\TemplateRendererInterface::class => Plates\PlatesRendererFactory::class,
Router\RouterInterface::class => Router\FastRouteRouterFactory::class,
ErrorHandler::class => Container\ErrorHandlerFactory::class,
Middleware\ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class,
ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class,
Helper\UrlHelper::class => Helper\UrlHelperFactory::class,
Helper\ServerUrlHelper::class => InvokableFactory::class,
],
'delegators' => [
Expressive\Application::class => [
Container\ApplicationConfigInjectionDelegator::class,
],
],
],
];

View File

@@ -2,6 +2,7 @@
declare(strict_types=1);
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware;
use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware;
use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware;
@@ -15,6 +16,7 @@ return [
'pre-routing' => [
'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
LocaleMiddleware::class,
],
'priority' => 11,
@@ -29,7 +31,7 @@ return [
'routing' => [
'middleware' => [
Expressive\Application::ROUTING_MIDDLEWARE,
Expressive\Router\Middleware\RouteMiddleware::class,
],
'priority' => 10,
],
@@ -38,7 +40,7 @@ return [
'path' => '/rest',
'middleware' => [
CrossDomainMiddleware::class,
Expressive\Middleware\ImplicitOptionsMiddleware::class,
Expressive\Router\Middleware\ImplicitOptionsMiddleware::class,
BodyParserMiddleware::class,
CheckAuthenticationMiddleware::class,
],
@@ -47,7 +49,8 @@ return [
'post-routing' => [
'middleware' => [
Expressive\Application::DISPATCH_MIDDLEWARE,
Expressive\Router\Middleware\DispatchMiddleware::class,
NotFoundHandler::class,
],
'priority' => 1,
],

View File

@@ -7,6 +7,7 @@ use Shlinkio\Shlink\Common;
use Shlinkio\Shlink\Core;
use Shlinkio\Shlink\Rest;
use Zend\ConfigAggregator;
use Zend\Expressive;
/**
* Configuration files are loaded in a specific order. First ``global.php``, then ``*.global.php``.
@@ -18,6 +19,11 @@ use Zend\ConfigAggregator;
*/
return (new ConfigAggregator\ConfigAggregator([
Expressive\ConfigProvider::class,
Expressive\Router\ConfigProvider::class,
Expressive\Router\FastRouteRouter\ConfigProvider::class,
Expressive\Plates\ConfigProvider::class,
Expressive\Helper\ConfigProvider::class,
ExpressiveErrorHandler\ConfigProvider::class,
Common\ConfigProvider::class,
Core\ConfigProvider::class,

View File

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

0
data/infra/database/.gitignore vendored Normal file → Executable file
View File

0
data/infra/nginx/.gitignore vendored Normal file → Executable file
View File

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

View File

@@ -11,59 +11,73 @@
"in": "query",
"description": "The page to be displayed. Defaults to 1",
"required": false,
"type": "integer"
"schema": {
"type": "integer"
}
},
{
"name": "searchTerm",
"in": "query",
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
"required": false,
"type": "string"
"schema": {
"type": "string"
}
},
{
"name": "tags",
"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)",
"required": false,
"type": "array",
"items": {
"type": "string"
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "orderBy",
"in": "query",
"description": "The field from which you want to order the result. (Since v1.3.0)",
"enum": [
"originalUrl",
"shortCode",
"dateCreated",
"visits"
],
"required": false,
"type": "string"
},
"schema": {
"type": "string",
"enum": [
"originalUrl",
"shortCode",
"dateCreated",
"visits"
]
}
}
],
"security": [
{
"$ref": "../parameters/Authorization.json"
"Bearer": []
}
],
"responses": {
"200": {
"description": "The list of short URLs",
"schema": {
"type": "object",
"properties": {
"shortUrls": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrl.json"
"shortUrls": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrl.json"
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}
}
}
@@ -110,71 +124,114 @@
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
},
"post": {
"tags": [
"ShortCodes"
],
"summary": "Create short URL",
"description": "Creates a new short code",
"parameters": [
"security": [
{
"name": "longUrl",
"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"
"Bearer": []
}
],
"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": {
"200": {
"description": "The result of parsing the long URL",
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL that has been parsed"
},
"shortUrl": {
"type": "string",
"description": "The generated short URL"
},
"shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL that has been parsed"
},
"shortUrl": {
"type": "string",
"description": "The generated short URL"
},
"shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
}
}
}
}
}
},
"400": {
"description": "The long URL was not provided or is invalid.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

View File

@@ -0,0 +1,125 @@
{
"get": {
"tags": [
"ShortCodes"
],
"summary": "Create a short URL",
"description": "Creates a short URL in a single API call. Useful for third party integrations",
"parameters": [
{
"name": "apiKey",
"in": "query",
"description": "The API key used to authenticate the request",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "longUrl",
"in": "query",
"description": "The URL to be shortened",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "format",
"in": "query",
"description": "The format in which you want the response to be returned. You can also use the \"Accept\" header instead of this",
"required": false,
"schema": {
"type": "string",
"enum": [
"txt",
"json"
]
}
}
],
"responses": {
"200": {
"description": "The list of short URLs",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL that has been shortened"
},
"shortUrl": {
"type": "string",
"description": "The generated short URL"
},
"shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"longUrl": "https://github.com/shlinkio/shlink",
"shortUrl": "https://dom.ain/abc123",
"shortCode": "abc123"
},
"text/plain": "https://dom.ain/abc123"
}
},
"400": {
"description": "The long URL was not provided or is invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"error": "INVALID_URL",
"message": "Provided URL foo is invalid. Try with a different one."
},
"text/plain": "INVALID_URL"
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"error": "UNKNOWN_ERROR",
"message": "Unexpected error occurred"
},
"text/plain": "UNKNOWN_ERROR"
}
}
}
}
}

View File

@@ -9,23 +9,31 @@
{
"name": "shortCode",
"in": "path",
"type": "string",
"description": "The short code to resolve.",
"required": true
},
"required": true,
"schema": {
"type": "string"
}
}
],
"security": [
{
"$ref": "../parameters/Authorization.json"
"Bearer": []
}
],
"responses": {
"200": {
"description": "The long URL behind a short code.",
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL behind the short code."
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL behind the short code."
}
}
}
}
},
@@ -37,20 +45,116 @@
},
"400": {
"description": "Provided shortCode does not match the character set currently used by the app to generate short codes.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"404": {
"description": "No URL was found for provided short code.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"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": {
"$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",
"in": "path",
"type": "string",
"description": "The shortCode in which we want to edit tags.",
"required": true
},
{
"name": "tags",
"in": "formData",
"type": "array",
"items": {
"required": true,
"schema": {
"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": {
"200": {
"description": "List of tags.",
"schema": {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string"
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
@@ -53,20 +74,32 @@
},
"400": {
"description": "The request body does not contain a \"tags\" param with array type.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

View File

@@ -10,27 +10,35 @@
{
"name": "shortCode",
"in": "path",
"type": "string",
"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": {
"200": {
"description": "List of visits.",
"schema": {
"type": "object",
"properties": {
"visits": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/Visit.json"
"visits": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/Visit.json"
}
}
}
}
}
@@ -66,14 +74,22 @@
},
"404": {
"description": "The short code does not belong to any short URL.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

View File

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

View File

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

18
infection.json Normal file
View File

@@ -0,0 +1,18 @@
{
"source": {
"directories": [
"module/*/src"
],
"excludes": []
},
"timeout": 10,
"logs": {
"text": "build/infection/infection-log.txt",
"summary": "build/infection/summary-log.txt",
"debug": "build/infection/debug-log.txt"
},
"tmpDir": "build/infection/temp",
"phpUnit": {
"configDir": "."
}
}

View File

@@ -9,21 +9,25 @@ return [
'cli' => [
'locale' => Common\env('CLI_LOCALE', 'en'),
'commands' => [
Command\Shortcode\GenerateShortcodeCommand::class,
Command\Shortcode\ResolveUrlCommand::class,
Command\Shortcode\ListShortcodesCommand::class,
Command\Shortcode\GetVisitsCommand::class,
Command\Shortcode\GeneratePreviewCommand::class,
Command\Visit\ProcessVisitsCommand::class,
Command\Config\GenerateCharsetCommand::class,
Command\Config\GenerateSecretCommand::class,
Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::class,
Command\Api\ListKeysCommand::class,
Command\Tag\ListTagsCommand::class,
Command\Tag\CreateTagCommand::class,
Command\Tag\RenameTagCommand::class,
Command\Tag\DeleteTagsCommand::class,
Command\Shortcode\GenerateShortcodeCommand::NAME => Command\Shortcode\GenerateShortcodeCommand::class,
Command\Shortcode\ResolveUrlCommand::NAME => Command\Shortcode\ResolveUrlCommand::class,
Command\Shortcode\ListShortcodesCommand::NAME => Command\Shortcode\ListShortcodesCommand::class,
Command\Shortcode\GetVisitsCommand::NAME => Command\Shortcode\GetVisitsCommand::class,
Command\Shortcode\GeneratePreviewCommand::NAME => Command\Shortcode\GeneratePreviewCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
Command\Config\GenerateCharsetCommand::NAME => Command\Config\GenerateCharsetCommand::class,
Command\Config\GenerateSecretCommand::NAME => Command\Config\GenerateSecretCommand::class,
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::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 ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2017-10-21 20:17+0200\n"
"PO-Revision-Date: 2017-10-21 20:19+0200\n"
"POT-Creation-Date: 2018-01-21 09:36+0100\n"
"PO-Revision-Date: 2018-01-21 09:39+0100\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.1\n"
"X-Generator: Poedit 2.0.4\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
@@ -24,8 +24,8 @@ msgid "The API key to disable"
msgstr "La clave de API a deshabilitar"
#, php-format
msgid "API key %s properly disabled"
msgstr "Clave de API %s deshabilitada correctamente"
msgid "API key \"%s\" properly disabled"
msgstr "Clave de API \"%s\" deshabilitada correctamente"
#, php-format
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 "
"válido en PHP."
msgid "Generated API key"
msgstr "Generada clave de API"
#, php-format
msgid "Generated API key: \"%s\""
msgstr "Generada clave de API. \"%s\""
msgid "Lists all the available API keys."
msgstr "Lista todas las claves de API disponibles."
@@ -51,12 +52,12 @@ msgstr "Define si sólo las claves de API habilitadas deben ser devueltas."
msgid "Key"
msgstr "Clave"
msgid "Expiration date"
msgstr "Fecha de caducidad"
msgid "Is enabled"
msgstr "Está habilitada"
msgid "Expiration date"
msgstr "Fecha de caducidad"
#, php-format
msgid ""
"Generates a character set sample just by shuffling the default one, \"%s\". "
@@ -65,8 +66,9 @@ msgstr ""
"Genera un grupo de caracteres simplemente mexclando el grupo por defecto \"%s"
"\". Después puede ser utilizado en la variable de entrono SHORTCODE_CHARS"
msgid "Character set:"
msgstr "Grupo de caracteres:"
#, php-format
msgid "Character set: \"%s\""
msgstr "Grupo de caracteres: \"%s\""
msgid ""
"Generates a random secret string that can be used for JWT token encryption"
@@ -74,8 +76,9 @@ msgstr ""
"Genera una cadena de caracteres aleatoria que puede ser usada para cifrar "
"tokens JWT"
msgid "Secret key:"
msgstr "Clave secreta:"
#, php-format
msgid "Secret key: \"%s\""
msgstr "Clave secreta: \"%s\""
msgid ""
"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 ""
"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?"
msgid "A URL was not provided!"
msgstr "¡No se ha proporcionado una URL!"
msgid "Processed URL:"
msgstr "URL procesada:"
msgid "Processed long URL:"
msgstr "URL larga procesada:"
msgid "Generated URL:"
msgstr "URL generada:"
msgid "Generated short URL:"
msgstr "URL corta generada:"
#, php-format
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 ""
"Permite filtrar las visitas, devolviendo sólo aquellas más nuevas que endDate"
msgid "A short code was not provided. Which short code do you want to use?:"
msgstr "No se prporcionó un código corto. ¿Qué código corto deseas usar?:"
msgid "A short code was not provided. Which short code do you want to use?"
msgstr "No se proporcionó un código corto. ¿Qué código corto deseas usar?"
msgid "Referer"
msgstr "Origen"
@@ -222,8 +230,8 @@ msgstr "Número de visitas"
msgid "Tags"
msgstr "Etiquetas"
msgid "You have reached last page"
msgstr "Has alcanzado la última página"
msgid "Short codes properly listed"
msgstr "Códigos cortos correctamente listados"
msgid "Continue with page"
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"
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 ""
"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\""
"No se proporcionó un código corto. ¿Qué código corto quieres convertir?"
msgid "Long URL:"
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"
msgstr "Debes proporcionar al menos un nombre de etiqueta"
msgid "Created tags"
msgstr "Etiquetas creadas"
msgid "Tags properly created"
msgstr "Etiquetas correctamente creadas"
msgid "Deletes one or more tags."
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"
msgstr "El nombre de las etiquetas a eliminar"
msgid "Deleted tags"
msgstr "Etiquetas eliminadas"
msgid "Tags properly deleted"
msgstr "Etiquetas correctamente eliminadas"
msgid "Lists existing tags."
msgstr "Lista las etiquetas existentes."
@@ -315,3 +319,15 @@ msgstr "Dirección localizada en \"%s\""
msgid "Finished processing all 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\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class DisableKeyCommand extends Command
{
const NAME = 'api-key:disable';
/**
* @var ApiKeyServiceInterface
*/
@@ -30,7 +33,7 @@ class DisableKeyCommand extends Command
public function configure()
{
$this->setName('api-key:disable')
$this->setName(self::NAME)
->setDescription($this->translator->translate('Disables an API key.'))
->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)
{
$apiKey = $input->getArgument('apiKey');
$io = new SymfonyStyle($input, $output);
try {
$this->apiKeyService->disable($apiKey);
$output->writeln(sprintf(
$this->translator->translate('API key %s properly disabled'),
'<info>' . $apiKey . '</info>'
));
$io->success(sprintf($this->translator->translate('API key "%s" properly disabled'), $apiKey));
} catch (\InvalidArgumentException $e) {
$output->writeln(sprintf(
'<error>' . $this->translator->translate('API key "%s" does not exist.') . '</error>',
$apiKey
));
$io->error(sprintf($this->translator->translate('API key "%s" does not exist.'), $apiKey));
}
}
}

View File

@@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class GenerateKeyCommand extends Command
{
const NAME = 'api-key:generate';
/**
* @var ApiKeyServiceInterface
*/
@@ -30,7 +33,7 @@ class GenerateKeyCommand extends Command
public function configure()
{
$this->setName('api-key:generate')
$this->setName(self::NAME)
->setDescription($this->translator->translate('Generates a new valid API key.'))
->addOption(
'expirationDate',
@@ -44,6 +47,9 @@ class GenerateKeyCommand extends Command
{
$expirationDate = $input->getOption('expirationDate');
$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\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class ListKeysCommand extends Command
{
const NAME = 'api-key:list';
/**
* @var ApiKeyServiceInterface
*/
@@ -32,7 +34,7 @@ class ListKeysCommand extends Command
public function configure()
{
$this->setName('api-key:list')
$this->setName(self::NAME)
->setDescription($this->translator->translate('Lists all the available API keys.'))
->addOption(
'enabledOnly',
@@ -44,78 +46,75 @@ class ListKeysCommand extends Command
public function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$enabledOnly = $input->getOption('enabledOnly');
$list = $this->apiKeyService->listKeys($enabledOnly);
$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'),
]);
}
$rows = [];
/** @var ApiKey $row */
foreach ($list as $row) {
$key = $row->getKey();
$expiration = $row->getExpirationDate();
$rowData = [];
$formatMethod = ! $row->isEnabled()
? 'getErrorString'
: ($row->isExpired() ? 'getWarningString' : 'getSuccessString');
$formatMethod = $this->determineFormatMethod($row);
if ($enabledOnly) {
$rowData[] = $this->{$formatMethod}($key);
} else {
$rowData[] = $this->{$formatMethod}($key);
$rowData[] = $this->{$formatMethod}($this->getEnabledSymbol($row));
// Set columns for this row
$rowData = [$formatMethod($key)];
if (! $enabledOnly) {
$rowData[] = $formatMethod($this->getEnabledSymbol($row));
}
$rowData[] = $expiration !== null ? $expiration->format(\DateTime::ATOM) : '-';
$rowData[] = isset($expiration) ? $expiration->format(\DateTime::ATOM) : '-';
$table->addRow($rowData);
$rows[] = $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
*/
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
*/
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
*/
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
* @return string
*/
protected function getEnabledSymbol(ApiKey $apiKey)
private function getEnabledSymbol(ApiKey $apiKey): string
{
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\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class GenerateCharsetCommand extends Command
{
const NAME = 'config:generate-charset';
/**
* @var TranslatorInterface
*/
@@ -24,7 +27,7 @@ class GenerateCharsetCommand extends Command
public function configure()
{
$this->setName('config:generate-charset')
$this->setName(self::NAME)
->setDescription(sprintf($this->translator->translate(
'Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable'
@@ -34,6 +37,8 @@ class GenerateCharsetCommand extends Command
public function execute(InputInterface $input, OutputInterface $output)
{
$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\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class GenerateSecretCommand extends Command
{
use StringUtilsTrait;
const NAME = 'config:generate-secret';
/**
* @var TranslatorInterface
*/
@@ -26,7 +29,7 @@ class GenerateSecretCommand extends Command
public function configure()
{
$this->setName('config:generate-secret')
$this->setName(self::NAME)
->setDescription($this->translator->translate(
'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)
{
$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;
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\Model\CustomizableAppConfig;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Zend\Config\Writer\WriterInterface;
@@ -24,17 +25,9 @@ class InstallCommand extends Command
const GENERATED_CONFIG_PATH = 'config/params/generated_config.php';
/**
* @var InputInterface
* @var SymfonyStyle
*/
private $input;
/**
* @var OutputInterface
*/
private $output;
/**
* @var QuestionHelper
*/
private $questionHelper;
private $io;
/**
* @var ProcessHelper
*/
@@ -48,7 +41,7 @@ class InstallCommand extends Command
*/
private $filesystem;
/**
* @var ConfigCustomizerPluginManagerInterface
* @var ConfigCustomizerManagerInterface
*/
private $configCustomizers;
/**
@@ -60,13 +53,14 @@ class InstallCommand extends Command
* InstallCommand constructor.
* @param WriterInterface $configWriter
* @param Filesystem $filesystem
* @param ConfigCustomizerManagerInterface $configCustomizers
* @param bool $isUpdate
* @throws LogicException
*/
public function __construct(
WriterInterface $configWriter,
Filesystem $filesystem,
ConfigCustomizerPluginManagerInterface $configCustomizers,
ConfigCustomizerManagerInterface $configCustomizers,
$isUpdate = false
) {
parent::__construct();
@@ -83,30 +77,34 @@ class InstallCommand extends Command
->setDescription('Installs or updates Shlink');
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return void
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function execute(InputInterface $input, OutputInterface $output)
{
$this->input = $input;
$this->output = $output;
$this->questionHelper = $this->getHelper('question');
$this->processHelper = $this->getHelper('process');
$this->io = new SymfonyStyle($input, $output);
$output->writeln([
$this->io->writeln([
'<info>Welcome to Shlink!!</info>',
'This will guide you through the installation process.',
]);
// Check if a cached config file exists and drop it if so
if ($this->filesystem->exists('data/cache/app_config.php')) {
$output->write('Deleting old cached config...');
$this->io->write('Deleting old cached config...');
try {
$this->filesystem->remove('data/cache/app_config.php');
$output->writeln(' <info>Success</info>');
$this->io->writeln(' <info>Success</info>');
} catch (IOException $e) {
$output->writeln(
' <error>Failed!</error> You will have to manually delete the data/cache/app_config.php file to get'
. ' new config applied.'
$this->io->error(
'Failed! You will have to manually delete the data/cache/app_config.php file to'
. ' get new config applied.'
);
if ($output->isVerbose()) {
if ($this->io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
return;
@@ -118,56 +116,65 @@ class InstallCommand extends Command
// Ask for custom config params
foreach ([
Plugin\DatabaseConfigCustomizerPlugin::class,
Plugin\UrlShortenerConfigCustomizerPlugin::class,
Plugin\LanguageConfigCustomizerPlugin::class,
Plugin\ApplicationConfigCustomizerPlugin::class,
Plugin\DatabaseConfigCustomizer::class,
Plugin\UrlShortenerConfigCustomizer::class,
Plugin\LanguageConfigCustomizer::class,
Plugin\ApplicationConfigCustomizer::class,
] as $pluginName) {
/** @var Plugin\ConfigCustomizerPluginInterface $configCustomizer */
/** @var Plugin\ConfigCustomizerInterface $configCustomizer */
$configCustomizer = $this->configCustomizers->get($pluginName);
$configCustomizer->process($input, $output, $config);
$configCustomizer->process($this->io, $config);
}
// Generate config params files
$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 (! $this->isUpdate) {
$this->output->writeln('Initializing database...');
$this->io->write('Initializing database...');
if (! $this->runCommand(
'php vendor/bin/doctrine.php orm:schema-tool:create',
'Error generating database.'
'php vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
'Error generating database.',
$output
)) {
return;
}
}
// Run database migrations
$output->writeln('Updating database...');
if (! $this->runCommand('php vendor/bin/doctrine-migrations migrations:migrate', 'Error updating database.')) {
$this->io->write('Updating database...');
if (! $this->runCommand(
'php vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate',
'Error updating database.',
$output
)) {
return;
}
// Generate proxies
$output->writeln('Generating proxies...');
if (! $this->runCommand('php vendor/bin/doctrine.php orm:generate-proxies', 'Error generating proxies.')) {
$this->io->write('Generating proxies...');
if (! $this->runCommand(
'php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies',
'Error generating proxies.',
$output
)) {
return;
}
$this->io->success('Installation complete!');
}
/**
* @return CustomizableAppConfig
* @throws RuntimeException
*/
private function importConfig()
private function importConfig(): CustomizableAppConfig
{
$config = new CustomizableAppConfig();
// Ask the user if he/she wants to import an older configuration
$importConfig = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion(
'<question>Do you want to import previous configuration? (Y/n):</question> '
));
$importConfig = $this->io->confirm('Do you want to import configuration from previous installation?');
if (! $importConfig) {
return $config;
}
@@ -175,17 +182,16 @@ class InstallCommand extends Command
// Ask the user for the older shlink path
$keepAsking = true;
do {
$config->setImportedInstallationPath($this->ask(
$config->setImportedInstallationPath($this->io->ask(
'Previous shlink installation path from which to import config'
));
$configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH;
$configExists = $this->filesystem->exists($configFile);
if (! $configExists) {
$keepAsking = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion(
'Provided path does not seem to be a valid shlink root path. '
. '<question>Do you want to try another path? (Y/n):</question> '
));
$keepAsking = $this->io->confirm(
'Provided path does not seem to be a valid shlink root path. Do you want to try another path?'
);
}
} while (! $configExists && $keepAsking);
@@ -199,51 +205,31 @@ class InstallCommand extends Command
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 $errorMessage
* @param OutputInterface $output
* @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()) {
$this->output->writeln(' <info>Success!</info>');
$this->io->writeln(' <info>Success!</info>');
return true;
}
if ($this->output->isVerbose()) {
if ($this->io->isVerbose()) {
return false;
}
$this->output->writeln(
' <error>' . $errorMessage . '</error> Run this command with -vvv to see specific error info.'
);
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
return false;
}
}

View File

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

View File

@@ -6,17 +6,17 @@ namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class GetVisitsCommand extends Command
{
const NAME = 'shortcode:visits';
/**
* @var VisitsTrackerInterface
*/
@@ -30,12 +30,12 @@ class GetVisitsCommand extends Command
{
$this->visitsTracker = $visitsTracker;
$this->translator = $translator;
parent::__construct(null);
parent::__construct();
}
public function configure()
{
$this->setName('shortcode:visits')
$this->setName(self::NAME)
->setDescription(
$this->translator->translate('Returns the detailed visits information for provided short code')
)
@@ -65,14 +65,10 @@ class GetVisitsCommand extends Command
return;
}
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new Question(sprintf(
'<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);
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask(
$this->translator->translate('A short code was not provided. Which short code do you want to use?')
);
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
@@ -80,33 +76,32 @@ class GetVisitsCommand extends Command
public function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
$startDate = $this->getDateOption($input, 'startDate');
$endDate = $this->getDateOption($input, 'endDate');
$visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate));
$table = new Table($output);
$table->setHeaders([
$this->translator->translate('Referer'),
$this->translator->translate('Date'),
$this->translator->translate('Remote Address'),
$this->translator->translate('User agent'),
]);
$rows = [];
foreach ($visits as $row) {
$rowData = $row->jsonSerialize();
// Unset location info
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)
{
$value = $input->getOption($key);
if (isset($value)) {
if (! empty($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\Core\Service\ShortUrlServiceInterface;
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\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class ListShortcodesCommand extends Command
{
use PaginatorUtilsTrait;
const NAME = 'shortcode:list';
/**
* @var ShortUrlServiceInterface
*/
@@ -32,12 +32,12 @@ class ListShortcodesCommand extends Command
{
$this->shortUrlService = $shortUrlService;
$this->translator = $translator;
parent::__construct(null);
parent::__construct();
}
public function configure()
{
$this->setName('shortcode:list')
$this->setName(self::NAME)
->setDescription($this->translator->translate('List all short URLs'))
->addOption(
'page',
@@ -81,19 +81,16 @@ class ListShortcodesCommand extends Command
public function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('searchTerm');
$tags = $input->getOption('tags');
$tags = ! empty($tags) ? explode(',', $tags) : [];
$tags = ! empty($tags) ? \explode(',', $tags) : [];
$showTags = $input->getOption('showTags');
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
do {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$page++;
$table = new Table($output);
$headers = [
$this->translator->translate('Short code'),
@@ -104,8 +101,8 @@ class ListShortcodesCommand extends Command
if ($showTags) {
$headers[] = $this->translator->translate('Tags');
}
$table->setHeaders($headers);
$rows = [];
foreach ($result as $row) {
$shortUrl = $row->jsonSerialize();
if ($showTags) {
@@ -118,27 +115,23 @@ class ListShortcodesCommand extends Command
unset($shortUrl['tags']);
}
$table->addRow(array_values($shortUrl));
$rows[] = \array_values($shortUrl);
}
$table->render();
$io->table($headers, $rows);
if ($this->isLastPage($result)) {
$continue = false;
$output->writeln(
sprintf('<info>%s</info>', $this->translator->translate('You have reached last page'))
);
$io->success($this->translator->translate('Short codes properly listed'));
} else {
$continue = $helper->ask($input, $output, new ConfirmationQuestion(
sprintf('<question>' . $this->translator->translate(
'Continue with page'
) . ' <bg=cyan;options=bold>%s</>? (y/N)</question> ', $page),
$continue = $io->confirm(
\sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),
false
));
);
}
} while ($continue);
}
protected function processOrderBy(InputInterface $input)
private function processOrderBy(InputInterface $input)
{
$orderBy = $input->getOption('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\Service\UrlShortenerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class ResolveUrlCommand extends Command
{
const NAME = 'shortcode:parse';
/**
* @var UrlShortenerInterface
*/
@@ -34,7 +35,7 @@ class ResolveUrlCommand extends Command
public function configure()
{
$this->setName('shortcode:parse')
$this->setName(self::NAME)
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
->addArgument(
'shortCode',
@@ -50,14 +51,10 @@ class ResolveUrlCommand extends Command
return;
}
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new Question(sprintf(
'<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);
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask(
$this->translator->translate('A short code was not provided. Which short code do you want to parse?')
);
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
@@ -65,27 +62,20 @@ class ResolveUrlCommand extends Command
public function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
if (! isset($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));
$output->writeln(\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $longUrl));
} catch (InvalidShortCodeException $e) {
$output->writeln(sprintf('<error>' . $this->translator->translate(
'Provided short code "%s" has an invalid format.'
) . '</error>', $shortCode));
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode)
);
} catch (EntityDoesNotExistException $e) {
$output->writeln(sprintf('<error>' . $this->translator->translate(
'Provided short code "%s" could not be found.'
) . '</error>', $shortCode));
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,11 +10,13 @@ use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command
{
const LOCALHOST = '127.0.0.1';
const NAME = 'visit:process';
/**
* @var VisitServiceInterface
@@ -42,7 +44,7 @@ class ProcessVisitsCommand extends Command
public function configure()
{
$this->setName('visit:process')
$this->setName(self::NAME)
->setDescription(
$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)
{
$io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits();
foreach ($visits as $visit) {
$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) {
$output->writeln(
$io->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
);
continue;
@@ -64,11 +67,13 @@ class ProcessVisitsCommand extends Command
try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
$location = new VisitLocation();
$location->exchangeArray($result);
$visit->setVisitLocation($location);
$this->visitService->saveVisit($visit);
$output->writeln(sprintf(
$io->writeln(sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')',
$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\Exception\ContainerException;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Symfony\Component\Console\Application as CliApp;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
@@ -20,28 +23,23 @@ class ApplicationFactory implements FactoryInterface
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @return CliApp
* @throws NotFoundExceptionInterface
* @throws ContainerExceptionInterface
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws 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)
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): CliApp
{
$config = $container->get('config')['cli'];
$appOptions = $container->get(AppOptions::class);
$translator = $container->get(Translator::class);
$translator->setLocale($config['locale']);
$commands = isset($config['commands']) ? $config['commands'] : [];
$commands = $config['commands'] ?? [];
$app = new CliApp($appOptions->getName(), $appOptions->getVersion());
foreach ($commands as $command) {
if (! $container->has($command)) {
continue;
}
$app->add($container->get($command));
}
$app->setCommandLoader(new ContainerCommandLoader($container, $commands));
return $app;
}

View File

@@ -6,9 +6,8 @@ namespace Shlinkio\Shlink\CLI\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
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\Factory\DefaultConfigCustomizerPluginFactory;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Filesystem\Filesystem;
@@ -17,6 +16,7 @@ use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use Zend\ServiceManager\Factory\InvokableFactory;
class InstallApplicationFactory implements FactoryInterface
{
@@ -41,11 +41,11 @@ class InstallApplicationFactory implements FactoryInterface
$command = new InstallCommand(
new PhpArray(),
$container->get(Filesystem::class),
new ConfigCustomizerPluginManager($container, ['factories' => [
Plugin\DatabaseConfigCustomizerPlugin::class => ConfigAbstractFactory::class,
Plugin\UrlShortenerConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class,
Plugin\LanguageConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class,
Plugin\ApplicationConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class,
new ConfigCustomizerManager($container, ['factories' => [
Plugin\DatabaseConfigCustomizer::class => ConfigAbstractFactory::class,
Plugin\UrlShortenerConfigCustomizer::class => InvokableFactory::class,
Plugin\LanguageConfigCustomizer::class => InvokableFactory::class,
Plugin\ApplicationConfigCustomizer::class => InvokableFactory::class,
]]),
$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;
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 = [
'app_options' => [
'secret_key' => $this->app['SECRET'],
'disable_track_param' => $this->app['DISABLE_TRACK_PARAM'] ?? null,
],
'entity_manager' => [
'connection' => [

View File

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

View File

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

View File

@@ -65,8 +65,9 @@ class GenerateShortcodeCommandTest extends TestCase
'longUrl' => 'http://domain.com/invalid',
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(
strpos($output, 'Provided URL "http://domain.com/invalid" is invalid. Try with a different one.') === 0
$this->assertContains(
'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\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
@@ -22,10 +21,6 @@ class ListShortcodesCommandTest extends TestCase
* @var CommandTester
*/
protected $commandTester;
/**
* @var QuestionHelper
*/
protected $questionHelper;
/**
* @var ObjectProphecy
*/
@@ -37,8 +32,6 @@ class ListShortcodesCommandTest extends TestCase
$app = new Application();
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]));
$app->add($command);
$this->questionHelper = $command->getHelper('question');
$this->commandTester = new CommandTester($command);
}
@@ -47,10 +40,10 @@ class ListShortcodesCommandTest extends TestCase
*/
public function noInputCallsListJustOnce()
{
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->setInputs(['n']);
$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
$data = [];
for ($i = 0; $i < 30; $i++) {
for ($i = 0; $i < 50; $i++) {
$data[] = new ShortUrl();
}
$data = array_chunk($data, 11);
$questionHelper = $this->questionHelper;
$that = $this;
$this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (
&$data,
$questionHelper,
$that
) {
$questionHelper->setInputStream($that->getInputStream('y'));
return new Paginator(new ArrayAdapter(array_shift($data)));
$this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (&$data) {
return new Paginator(new ArrayAdapter($data));
})->shouldBeCalledTimes(3);
$this->commandTester->setInputs(['y', 'y', 'n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
}
@@ -91,10 +77,10 @@ class ListShortcodesCommandTest extends TestCase
$data[] = new ShortUrl();
}
$this->questionHelper->setInputStream($this->getInputStream('n'));
$this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledTimes(1);
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
}
@@ -104,10 +90,10 @@ class ListShortcodesCommandTest extends TestCase
public function passingPageWillMakeListStartOnThatPage()
{
$page = 5;
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--page' => $page,
@@ -119,24 +105,15 @@ class ListShortcodesCommandTest extends TestCase
*/
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--showTags' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Tags') > 0);
}
protected function getInputStream($inputData)
{
$stream = fopen('php://memory', 'r+', false);
fputs($stream, $inputData);
rewind($stream);
return $stream;
$this->assertContains('Tags', $output);
}
}

View File

@@ -66,7 +66,7 @@ class ResolveUrlCommandTest extends TestCase
'shortCode' => $shortCode,
]);
$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,
]);
$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
{
/**
* @var CreateTagCommand
*/
private $command;
/**
* @var CommandTester
*/
@@ -63,7 +59,7 @@ class CreateTagCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Created tags: ["%s"]', implode('", "', $tagNames)), $output);
$this->assertContains('Tags properly created', $output);
$createTags->shouldHaveBeenCalled();
}
}

View File

@@ -64,7 +64,7 @@ class DeleteTagsCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Deleted tags: ["%s"]', implode('", "', $tagNames)), $output);
$this->assertContains('Tags properly deleted', $output);
$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 Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
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 Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizerPluginTest extends TestCase
class DatabaseConfigCustomizerTest extends TestCase
{
/**
* @var DatabaseConfigCustomizerPlugin
* @var DatabaseConfigCustomizer
*/
private $plugin;
/**
* @var ObjectProphecy
*/
private $questionHelper;
private $io;
/**
* @var ObjectProphecy
*/
@@ -32,13 +28,11 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
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->plugin = new DatabaseConfigCustomizerPlugin(
$this->questionHelper->reveal(),
$this->filesystem->reveal()
);
$this->plugin = new DatabaseConfigCustomizer($this->filesystem->reveal());
}
/**
@@ -46,22 +40,23 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
*/
public function configIsRequestedToTheUser()
{
/** @var MethodProphecy $askSecret */
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('MySQL');
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$ask = $this->io->ask(Argument::cetera())->willReturn('param');
$config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasDatabase());
$this->assertEquals([
'DRIVER' => 'pdo_mysql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'NAME' => 'param',
'USER' => 'param',
'PASSWORD' => 'param',
'HOST' => 'param',
'PORT' => 'param',
], $config->getDatabase());
$askSecret->shouldHaveBeenCalledTimes(6);
$choice->shouldHaveBeenCalledTimes(1);
$ask->shouldHaveBeenCalledTimes(5);
}
/**
@@ -69,11 +64,9 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
*/
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 : 'MySQL';
});
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$ask = $this->io->ask(Argument::cetera())->willReturn('MySQL');
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
@@ -84,7 +77,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'PORT' => 'MySQL',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_mysql',
@@ -94,7 +87,9 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'HOST' => 'MySQL',
'PORT' => 'MySQL',
], $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()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setDatabase([
@@ -115,7 +109,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'PORT' => 'MySQL',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_pgsql',
@@ -125,7 +119,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'HOST' => 'MySQL',
'PORT' => 'MySQL',
], $config->getDatabase());
$ask->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledTimes(1);
}
/**
@@ -133,9 +127,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
*/
public function sqliteDatabaseIsImportedWhenRequested()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
/** @var MethodProphecy $copy */
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$copy = $this->filesystem->copy(Argument::cetera())->willReturn(null);
$config = new CustomizableAppConfig();
@@ -143,12 +135,12 @@ class DatabaseConfigCustomizerPluginTest extends TestCase
'DRIVER' => 'pdo_sqlite',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_sqlite',
], $config->getDatabase());
$ask->shouldHaveBeenCalledTimes(1);
$confirm->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 Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
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 Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
class LanguageConfigCustomizerPluginTest extends TestCase
class LanguageConfigCustomizerTest extends TestCase
{
/**
* @var LanguageConfigCustomizerPlugin
* @var LanguageConfigCustomizer
*/
protected $plugin;
/**
* @var ObjectProphecy
*/
protected $questionHelper;
protected $io;
public function setUp()
{
$this->questionHelper = $this->prophesize(QuestionHelper::class);
$this->plugin = new LanguageConfigCustomizerPlugin($this->questionHelper->reveal());
$this->io = $this->prophesize(SymfonyStyle::class);
$this->io->title(Argument::any())->willReturn(null);
$this->plugin = new LanguageConfigCustomizer();
}
/**
@@ -36,18 +33,17 @@ class LanguageConfigCustomizerPluginTest extends TestCase
*/
public function configIsRequestedToTheUser()
{
/** @var MethodProphecy $askSecret */
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('en');
$ask = $this->io->choice(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasLanguage());
$this->assertEquals([
'DEFAULT' => 'en',
'CLI' => 'en',
], $config->getLanguage());
$askSecret->shouldHaveBeenCalledTimes(2);
$ask->shouldHaveBeenCalledTimes(2);
}
/**
@@ -55,24 +51,22 @@ class LanguageConfigCustomizerPluginTest extends TestCase
*/
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 : 'es';
});
$choice = $this->io->choice(Argument::cetera())->willReturn('es');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setLanguage([
'DEFAULT' => 'en',
'CLI' => 'en',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DEFAULT' => 'es',
'CLI' => 'es',
], $config->getLanguage());
$ask->shouldHaveBeenCalledTimes(3);
$choice->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledTimes(1);
}
/**
@@ -80,8 +74,7 @@ class LanguageConfigCustomizerPluginTest extends TestCase
*/
public function existingValueIsKeptIfRequested()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$ask = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setLanguage([
@@ -89,7 +82,7 @@ class LanguageConfigCustomizerPluginTest extends TestCase
'CLI' => 'es',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DEFAULT' => 'es',

View File

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

View File

@@ -3,6 +3,6 @@ declare(strict_types=1);
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
{
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);
}

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Common\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
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\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
@@ -27,6 +27,8 @@ class EmptyResponseImplicitOptionsMiddlewareFactory implements FactoryInterface
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new ImplicitOptionsMiddleware(new EmptyResponse());
return new ImplicitOptionsMiddleware(function () {
return new EmptyResponse();
});
}
}

View File

@@ -3,10 +3,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Middleware;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as DelegateInterface;
use Zend\I18n\Translator\Translator;
class LocaleMiddleware implements MiddlewareInterface
@@ -32,15 +32,15 @@ class LocaleMiddleware implements MiddlewareInterface
*
* @return Response
*/
public function process(Request $request, DelegateInterface $delegate)
public function process(Request $request, DelegateInterface $delegate): Response
{
if (! $request->hasHeader('Accept-Language')) {
return $delegate->process($request);
return $delegate->handle($request);
}
$locale = $request->getHeaderLine('Accept-Language');
$this->translator->setLocale($this->normalizeLocale($locale));
return $delegate->process($request);
return $delegate->handle($request);
}
/**

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Response;
use Psr\Http\Message\StreamInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
class PixelResponse extends Response
{
private const BASE_64_IMAGE = 'R0lGODlhAQABAJAAAP8AAAAAACH5BAUQAAAALAAAAAABAAEAAAICBAEAOw==';
private const CONTENT_TYPE = 'image/gif';
public function __construct(int $status = 200, array $headers = [])
{
$headers['content-type'] = self::CONTENT_TYPE;
parent::__construct($this->createBody(), $status, $headers);
}
/**
* Create the message body.
*
* @return StreamInterface
*/
private function createBody(): StreamInterface
{
$body = new Stream('php://temp', 'wb+');
$body->write(\base64_decode(self::BASE_64_IMAGE));
$body->rewind();
return $body;
}
}

View File

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

View File

@@ -3,11 +3,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
interface IpLocationResolverInterface
{
/**
* @param $ipAddress
* @param string $ipAddress
* @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
{
/**
* @var \DateTimeInterface
* @var \DateTimeInterface|null
*/
private $startDate;
/**
* @var \DateTimeInterface
* @var \DateTimeInterface|null
*/
private $endDate;
@@ -21,7 +21,7 @@ class DateRange
}
/**
* @return \DateTimeInterface
* @return \DateTimeInterface|null
*/
public function getStartDate()
{
@@ -29,7 +29,7 @@ class DateRange
}
/**
* @return \DateTimeInterface
* @return \DateTimeInterface|null
*/
public function getEndDate()
{
@@ -39,8 +39,8 @@ class DateRange
/**
* @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

@@ -3,40 +3,17 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\DbUnit;
use Doctrine\DBAL\Driver\PDOConnection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\DbUnit\Database\Connection as DbConn;
use PHPUnit\DbUnit\DataSet\IDataSet as DataSet;
use PHPUnit\DbUnit\TestCase;
use PHPUnit\Framework\TestCase;
abstract class DatabaseTestCase extends TestCase
{
const ENTITIES_TO_EMPTY = [];
protected const ENTITIES_TO_EMPTY = [];
/**
* @var EntityManagerInterface
*/
public static $em;
/**
* @var DbConn
*/
private static $conn;
public function getConnection(): DbConn
{
if (isset(self::$conn)) {
return self::$conn;
}
/** @var PDOConnection $pdo */
$pdo = static::$em->getConnection()->getWrappedConnection();
return self::$conn = $this->createDefaultDBConnection($pdo, static::$em->getConnection()->getDatabase());
}
public function getDataSet(): DataSet
{
return $this->createArrayDataSet([]);
}
protected function getEntityManager(): EntityManagerInterface
{

View File

@@ -6,7 +6,7 @@ namespace ShlinkioTest\Shlink\Common\Factory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Expressive\Middleware\ImplicitOptionsMiddleware;
use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
use Zend\ServiceManager\ServiceManager;
class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase
@@ -38,8 +38,8 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase
$instance = $this->factory->__invoke(new ServiceManager(), '');
$ref = new \ReflectionObject($instance);
$prop = $ref->getProperty('response');
$prop = $ref->getProperty('responseFactory');
$prop->setAccessible(true);
$this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance));
$this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance)());
}
}

View File

@@ -32,7 +32,7 @@ class LocaleMiddlewareTest extends TestCase
public function whenNoHeaderIsPresentLocaleIsNotChanged()
{
$this->assertEquals('ru', $this->translator->getLocale());
$this->middleware->process(ServerRequestFactory::fromGlobals(), TestUtils::createDelegateMock()->reveal());
$this->middleware->process(ServerRequestFactory::fromGlobals(), TestUtils::createReqHandlerMock()->reveal());
$this->assertEquals('ru', $this->translator->getLocale());
}
@@ -43,7 +43,7 @@ class LocaleMiddlewareTest extends TestCase
{
$this->assertEquals('ru', $this->translator->getLocale());
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es');
$this->middleware->process($request, TestUtils::createDelegateMock()->reveal());
$this->middleware->process($request, TestUtils::createReqHandlerMock()->reveal());
$this->assertEquals('es', $this->translator->getLocale());
}
@@ -52,7 +52,7 @@ class LocaleMiddlewareTest extends TestCase
*/
public function localeGetsNormalized()
{
$delegate = TestUtils::createDelegateMock();
$delegate = TestUtils::createReqHandlerMock();
$this->assertEquals('ru', $this->translator->getLocale());

View File

@@ -3,22 +3,22 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Util;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Prophecy\Argument;
use Prophecy\Prophet;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
class TestUtils
{
private static $prophet;
public static function createDelegateMock(ResponseInterface $response = null, RequestInterface $request = null)
public static function createReqHandlerMock(ResponseInterface $response = null, RequestInterface $request = null)
{
$argument = $request ?: Argument::any();
$delegate = static::getProphet()->prophesize(DelegateInterface::class);
$delegate->process($argument)->willReturn($response ?: new Response());
$delegate = static::getProphet()->prophesize(RequestHandlerInterface::class);
$delegate->handle($argument)->willReturn($response ?: new Response());
return $delegate;
}

View File

@@ -6,7 +6,7 @@ use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Action;
use Shlinkio\Shlink\Core\Middleware;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Shlinkio\Shlink\Core\Service;
use Zend\Expressive\Router\RouterInterface;
use Zend\Expressive\Template\TemplateRendererInterface;
@@ -17,7 +17,7 @@ return [
'dependencies' => [
'factories' => [
Options\AppOptions::class => Options\AppOptionsFactory::class,
NotFoundDelegate::class => ConfigAbstractFactory::class,
NotFoundHandler::class => ConfigAbstractFactory::class,
// Services
Service\UrlShortener::class => ConfigAbstractFactory::class,
@@ -28,18 +28,15 @@ return [
// Middleware
Action\RedirectAction::class => ConfigAbstractFactory::class,
Action\PixelAction::class => ConfigAbstractFactory::class,
Action\QrCodeAction::class => ConfigAbstractFactory::class,
Action\PreviewAction::class => ConfigAbstractFactory::class,
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
],
'aliases' => [
'Zend\Expressive\Delegate\DefaultDelegate' => NotFoundDelegate::class,
],
],
ConfigAbstractFactory::class => [
NotFoundDelegate::class => [TemplateRendererInterface::class],
NotFoundHandler::class => [TemplateRendererInterface::class],
// Services
Service\UrlShortener::class => [
@@ -55,9 +52,20 @@ return [
Service\Tag\TagService::class => ['em'],
// Middleware
Action\RedirectAction::class => [Service\UrlShortener::class, Service\VisitsTracker::class],
Action\RedirectAction::class => [
Service\UrlShortener::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
'Logger_Shlink',
],
Action\PixelAction::class => [
Service\UrlShortener::class,
Service\VisitsTracker::class,
Options\AppOptions::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, 'Logger_Shlink'],
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
],

View File

@@ -13,6 +13,12 @@ return [
'middleware' => Action\RedirectAction::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'pixel-tracking',
'path' => '/{shortCode}/track',
'middleware' => Action\PixelAction::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'short-url-qr-code',
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
abstract class AbstractTrackingAction implements MiddlewareInterface
{
use ErrorResponseBuilderTrait;
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var VisitsTrackerInterface
*/
private $visitTracker;
/**
* @var AppOptions
*/
private $appOptions;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(
UrlShortenerInterface $urlShortener,
VisitsTrackerInterface $visitTracker,
AppOptions $appOptions,
LoggerInterface $logger = null
) {
$this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker;
$this->appOptions = $appOptions;
$this->logger = $logger ?: new NullLogger();
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
*
* @return ResponseInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$shortCode = $request->getAttribute('shortCode', '');
$query = $request->getQueryParams();
$disableTrackParam = $this->appOptions->getDisableTrackParam();
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
// Track visit to this short code
if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) {
$this->visitTracker->track($shortCode, $request);
}
return $this->createResp($longUrl);
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
$this->logger->warning('An error occurred while tracking short code.' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}
}
abstract protected function createResp(string $longUrl): ResponseInterface;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Common\Response\PixelResponse;
class PixelAction extends AbstractTrackingAction
{
protected function createResp(string $longUrl): ResponseInterface
{
return new PixelResponse();
}
}

View File

@@ -3,10 +3,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Common\Util\ResponseUtilsTrait;
@@ -28,11 +30,19 @@ class PreviewAction implements MiddlewareInterface
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(PreviewGeneratorInterface $previewGenerator, UrlShortenerInterface $urlShortener)
{
public function __construct(
PreviewGeneratorInterface $previewGenerator,
UrlShortenerInterface $urlShortener,
LoggerInterface $logger = null
) {
$this->previewGenerator = $previewGenerator;
$this->urlShortener = $urlShortener;
$this->logger = $logger ?: new NullLogger();
}
/**
@@ -40,11 +50,11 @@ class PreviewAction implements MiddlewareInterface
* to the next middleware component to create the response.
*
* @param Request $request
* @param DelegateInterface $delegate
* @param RequestHandlerInterface $handler
*
* @return Response
*/
public function process(Request $request, DelegateInterface $delegate)
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$shortCode = $request->getAttribute('shortCode');
@@ -52,12 +62,9 @@ class PreviewAction implements MiddlewareInterface
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$imagePath = $this->previewGenerator->generatePreview($url);
return $this->generateImageResponse($imagePath);
} catch (InvalidShortCodeException $e) {
return $this->buildErrorResponse($request, $delegate);
} catch (EntityDoesNotExistException $e) {
return $this->buildErrorResponse($request, $delegate);
} catch (PreviewGenerationException $e) {
return $this->buildErrorResponse($request, $delegate);
} catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) {
$this->logger->warning('An error occurred while generating preview image.' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\QrCode;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
@@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Zend\Expressive\Router\Exception\RuntimeException;
use Zend\Expressive\Router\RouterInterface;
class QrCodeAction implements MiddlewareInterface
@@ -49,22 +50,21 @@ class QrCodeAction implements MiddlewareInterface
* to the next middleware component to create the response.
*
* @param Request $request
* @param DelegateInterface $delegate
* @param RequestHandlerInterface $handler
*
* @return Response
* @throws \InvalidArgumentException
* @throws RuntimeException
*/
public function process(Request $request, DelegateInterface $delegate)
public function process(Request $request, RequestHandlerInterface $handler): Response
{
// Make sure the short URL exists for this short code
$shortCode = $request->getAttribute('shortCode');
try {
$this->urlShortener->shortCodeToUrl($shortCode);
} catch (InvalidShortCodeException $e) {
$this->logger->warning('Tried to create a QR code with an invalid short code' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $delegate);
} catch (EntityDoesNotExistException $e) {
$this->logger->warning('Tried to create a QR code with a not found short code' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $delegate);
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
$this->logger->warning('An error occurred while creating QR code' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}
$path = $this->router->generateUri('long-url-redirect', ['shortCode' => $shortCode]);
@@ -80,7 +80,7 @@ class QrCodeAction implements MiddlewareInterface
* @param Request $request
* @return int
*/
protected function getSizeParam(Request $request)
private function getSizeParam(Request $request): int
{
$size = (int) $request->getAttribute('size', 300);
if ($size < 50) {

View File

@@ -3,68 +3,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Zend\Diactoros\Response\RedirectResponse;
class RedirectAction implements MiddlewareInterface
class RedirectAction extends AbstractTrackingAction
{
use ErrorResponseBuilderTrait;
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var VisitsTrackerInterface
*/
private $visitTracker;
public function __construct(UrlShortenerInterface $urlShortener, VisitsTrackerInterface $visitTracker)
protected function createResp(string $longUrl): Response
{
$this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param Request $request
* @param DelegateInterface $delegate
*
* @return Response
*/
public function process(Request $request, DelegateInterface $delegate)
{
$shortCode = $request->getAttribute('shortCode', '');
try {
$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
$this->visitTracker->track($shortCode, $request);
// Return a redirect response to the long URL.
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
return new RedirectResponse($longUrl);
} catch (InvalidShortCodeException $e) {
return $this->buildErrorResponse($request, $delegate);
} catch (EntityDoesNotExistException $e) {
return $this->buildErrorResponse($request, $delegate);
}
// Return a redirect response to the long URL.
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
return new RedirectResponse($longUrl);
}
}

View File

@@ -3,16 +3,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action\Util;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
trait ErrorResponseBuilderTrait
{
private function buildErrorResponse(ServerRequestInterface $request, DelegateInterface $delegate): ResponseInterface
{
$request = $request->withAttribute(NotFoundDelegate::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code');
return $delegate->process($request);
private function buildErrorResponse(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$request = $request->withAttribute(NotFoundHandler::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code');
return $handler->handle($request);
}
}

View File

@@ -57,7 +57,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
/**
* @return string
*/
public function getReferer()
public function getReferer(): string
{
return $this->referer;
}
@@ -66,7 +66,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @param string $referer
* @return $this
*/
public function setReferer($referer)
public function setReferer($referer): self
{
$this->referer = $referer;
return $this;
@@ -75,7 +75,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
/**
* @return \DateTime
*/
public function getDate()
public function getDate(): \DateTime
{
return $this->date;
}
@@ -84,7 +84,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @param \DateTime $date
* @return $this
*/
public function setDate($date)
public function setDate($date): self
{
$this->date = $date;
return $this;
@@ -93,7 +93,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
/**
* @return ShortUrl
*/
public function getShortUrl()
public function getShortUrl(): ShortUrl
{
return $this->shortUrl;
}
@@ -102,7 +102,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @param ShortUrl $shortUrl
* @return $this
*/
public function setShortUrl($shortUrl)
public function setShortUrl($shortUrl): self
{
$this->shortUrl = $shortUrl;
return $this;
@@ -111,7 +111,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
/**
* @return string
*/
public function getRemoteAddr()
public function getRemoteAddr(): string
{
return $this->remoteAddr;
}
@@ -120,7 +120,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @param string $remoteAddr
* @return $this
*/
public function setRemoteAddr($remoteAddr)
public function setRemoteAddr($remoteAddr): self
{
$this->remoteAddr = $remoteAddr;
return $this;
@@ -129,7 +129,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
/**
* @return string
*/
public function getUserAgent()
public function getUserAgent(): string
{
return $this->userAgent;
}
@@ -138,7 +138,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @param string $userAgent
* @return $this
*/
public function setUserAgent($userAgent)
public function setUserAgent($userAgent): self
{
$this->userAgent = $userAgent;
return $this;
@@ -147,7 +147,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
/**
* @return VisitLocation
*/
public function getVisitLocation()
public function getVisitLocation(): VisitLocation
{
return $this->visitLocation;
}
@@ -156,7 +156,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @param VisitLocation $visitLocation
* @return $this
*/
public function setVisitLocation($visitLocation)
public function setVisitLocation($visitLocation): self
{
$this->visitLocation = $visitLocation;
return $this;
@@ -165,11 +165,11 @@ class Visit extends AbstractEntity implements \JsonSerializable
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* @return array data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
public function jsonSerialize(): array
{
return [
'referer' => $this->referer,

View File

@@ -3,9 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Common\Exception\ExceptionInterface;
class EntityDoesNotExistException extends \RuntimeException implements ExceptionInterface
class EntityDoesNotExistException extends RuntimeException
{
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
{
}

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