Compare commits

...

59 Commits

Author SHA1 Message Date
Alejandro Celaya
47117d1fb7 Added version 1.13 to changelog 2018-10-06 20:03:19 +02:00
Alejandro Celaya
cb8ef408a4 Merge pull request #227 from acelaya/feature/visits-threshold-config
Feature/visits threshold config
2018-10-06 12:22:44 +02:00
Alejandro Celaya
e5f21a88fa Fixed typo 2018-10-06 12:13:55 +02:00
Alejandro Celaya
0458c4f798 Updated changelog 2018-10-06 12:08:51 +02:00
Alejandro Celaya
75f6160432 Improved ApplicationConfigCustomizer while asking for visits threshold 2018-10-06 12:02:06 +02:00
Alejandro Celaya
5337eb48e7 Added missing type hint 2018-10-06 11:43:34 +02:00
Alejandro Celaya
86c30ee731 Added new visits_threshold config to installation 2018-10-06 11:41:26 +02:00
Alejandro Celaya
d68dc38959 Merge pull request #224 from acelaya/feature/config-params
Feature/config params
2018-10-06 11:25:56 +02:00
Alejandro Celaya
0525639329 Created CustomizableAppConfigTest 2018-10-06 11:19:02 +02:00
Alejandro Celaya
0d9c7282df Used constants when possible when parsing app config 2018-10-06 11:12:42 +02:00
Alejandro Celaya
3b95925217 Fixed consig customizer tests 2018-10-06 10:05:25 +02:00
Alejandro Celaya
fa595f7aa3 Fixed non-existing keys not being set with default values in imported config 2018-10-06 09:40:18 +02:00
Alejandro Celaya
ff80f32f72 Created json_encode function which always maps to array and converts errors into exceptions 2018-10-05 19:19:44 +02:00
Alejandro Celaya
e55dbef2fc Replaced in_array by contains 2018-10-05 18:52:42 +02:00
Alejandro Celaya
ebf2e459e8 Refactored Databa config customizer so that it uses new structure 2018-10-05 18:43:39 +02:00
Alejandro Celaya
1b5081ae21 Refactored Language and UrlShortener config customizers 2018-10-03 18:55:20 +02:00
Alejandro Celaya
d5736756f7 Ensured asking for previous shlink path is a mandatory question when updating shlink 2018-09-30 18:26:52 +02:00
Alejandro Celaya
757cf2e193 Updated ApplicationConfigCustomizer to support new keys in the future 2018-09-30 18:20:27 +02:00
Alejandro Celaya
3a75ac0486 Merge pull request #222 from acelaya/feature/required-installation-config
Feature/required installation config
2018-09-30 14:10:02 +02:00
Alejandro Celaya
3c3ef6fa05 Fixed installer tests 2018-09-30 11:14:38 +02:00
Alejandro Celaya
3282bfd03b Ensured symfony/console stays in v4.1.4, since the next one throws a lot of phpstan errors 2018-09-30 11:02:01 +02:00
Alejandro Celaya
0813df6b29 Added unreleased entry to Changelog with already merged tasks from v1.13 mi8lestone 2018-09-30 10:52:11 +02:00
Alejandro Celaya
df74a04085 Fixed coding style 2018-09-30 09:47:47 +02:00
Alejandro Celaya
8323b87076 Ensured required config options cannot be left empty 2018-09-30 09:40:43 +02:00
Alejandro Celaya
48f01921e1 Used modern PHP features in CustomizableAppCOnfig 2018-09-30 09:04:00 +02:00
Alejandro Celaya
ae9d99257e Merge pull request #221 from acelaya/feature/chronos
Migrated from standard datetime objects to chronos objects
2018-09-29 13:02:43 +02:00
Alejandro Celaya
0183c8a4b7 Migrated from standard datetime objects to chronos objects 2018-09-29 12:52:32 +02:00
Alejandro Celaya
9a2ca35e6e Merge pull request #220 from acelaya/feature/installer-module
Feature/installer module
2018-09-29 10:24:22 +02:00
Alejandro Celaya
2edb48e314 Documented where the installer command has to be run 2018-09-29 10:15:39 +02:00
Alejandro Celaya
a81fd497d4 Updated Rest translations 2018-09-29 10:09:12 +02:00
Alejandro Celaya
49cca5cd69 Removed FQCN 2018-09-29 10:07:10 +02:00
Alejandro Celaya
f92cff6241 Removed not used translator config 2018-09-29 10:05:13 +02:00
Alejandro Celaya
1b4343ffc2 Moved update and install duplicated code to common config file 2018-09-29 10:00:17 +02:00
Alejandro Celaya
d5392a5f59 Added missing void return type hint 2018-09-29 09:55:13 +02:00
Alejandro Celaya
a65ce649ac Created new Installer module and moved everything from CLI there 2018-09-29 09:52:32 +02:00
Alejandro Celaya
d5dc6cea99 Merge pull request #218 from acelaya/feature/api-key
Feature/api key
2018-09-29 09:05:37 +02:00
Alejandro Celaya
5ecfe9f0f0 Implemented ApiKeyHeaderPlugin 2018-09-29 08:34:47 +02:00
Alejandro Celaya
0f5fb066d1 Converted AuthenticationpluginManager in a plain plugin manager and encasulated in new service adding extra behavior 2018-09-29 08:16:40 +02:00
Alejandro Celaya
8e61639598 Created system of authentication plugins 2018-09-28 22:08:01 +02:00
Alejandro Celaya
e88468d867 Renamed CheckAuthenticationMiddleware to just AuthenticationMiddleware 2018-09-24 23:07:10 +02:00
Alejandro Celaya
bc46e2f509 Defined API key authentication type in swagger docs 2018-09-24 23:07:10 +02:00
Alejandro Celaya
2241279bb6 Merge pull request #217 from acelaya/feature/deprecated-endpoints
Noticed that old endpoints will keep working
2018-09-24 23:05:38 +02:00
Alejandro Celaya
25ffbed756 Fixed references to short codes where actually short URLs are being managed 2018-09-24 23:01:15 +02:00
Alejandro Celaya
8784843a7a Noticed that old endpoints will keep working 2018-09-24 22:49:30 +02:00
Alejandro Celaya
a964e2b3c9 Added note in readme file that travis is the one creating Github releases 2018-09-24 19:47:00 +02:00
Alejandro Celaya
7f7efd45ab Merge pull request #215 from acelaya/feature/automatic-release
Automatic release
2018-09-24 19:44:10 +02:00
Alejandro Celaya
af8f5afef8 Added automatic release generation to travis config 2018-09-24 19:38:22 +02:00
Alejandro Celaya
dcfaed437c Improved build process to not require parent dir, sudo and exclude dirs 2018-09-24 19:35:45 +02:00
Alejandro Celaya
47e2322e33 Merge pull request #213 from acelaya/feature/rename-rest-actions
Feature/rename rest actions
2018-09-20 21:03:43 +02:00
Alejandro Celaya
00e7d57245 Improved API descriptions 2018-09-20 20:53:57 +02:00
Alejandro Celaya
d53a3222d0 Changed documented paths on short URL-related endpoints from short-code to short-url 2018-09-20 20:52:27 +02:00
Alejandro Celaya
80fe3a73e2 More classes renamed and fixes for usage of the short code concept in place of short URL 2018-09-20 20:38:51 +02:00
Alejandro Celaya
7ab993b764 Created and registered middleware which replaces short-code from short-url on rest paths 2018-09-20 20:27:34 +02:00
Alejandro Celaya
622edd2ed1 Renamed rest middlewares to use the short-url concept instead of the short-code concept 2018-09-20 20:00:53 +02:00
Alejandro Celaya
1f5faee356 Renamed rest actions to use the short-url concept instead of the short-code concept 2018-09-20 19:55:24 +02:00
Alejandro Celaya
076b0cf867 Merge pull request #209 from acelaya/feature/cli-refactoring
CLI Refactoring
2018-09-16 19:28:05 +02:00
Alejandro Celaya
d4168bebc6 Ensured install tool knows the install command is the only one 2018-09-16 18:48:10 +02:00
Alejandro Celaya
13c3629cd6 Updated few translations 2018-09-16 18:37:54 +02:00
Alejandro Celaya
1eff9801e8 Updated references to short code and replaced them to short URL where appropriate 2018-09-16 18:36:02 +02:00
156 changed files with 3089 additions and 1689 deletions

View File

@@ -1,5 +1,7 @@
language: php
sudo: false # Use containerized environment
branches:
only:
- /.*/
@@ -12,7 +14,7 @@ before_install:
- phpenv config-add data/infra/travis-php/memcached.ini
- phpenv config-add data/infra/travis-php/apcu.ini
before_script:
install:
- composer self-update
- composer install --no-interaction
@@ -20,9 +22,21 @@ script:
- mkdir build
- composer check
after_script:
after_success:
- vendor/bin/phpcov merge build --clover build/clover.xml
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
sudo: false
# Before deploying, build dist file for current travis tag
before_deploy:
- ./build.sh ${TRAVIS_TAG#?}
deploy:
provider: releases
api_key:
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true
php: 7.1

View File

@@ -1,5 +1,49 @@
# CHANGELOG
## 1.13.0 - 2018-10-06
#### Added
* [#197](https://github.com/shlinkio/shlink/issues/197) Added [cakephp/chronos](https://book.cakephp.org/3.0/en/chronos.html) library for date manipulations.
* [#214](https://github.com/shlinkio/shlink/issues/214) Improved build script, which allows builds to be done without "jumping" outside the project directory, and generates smaller dist files.
It also allows automating the dist file generation in travis-ci builds.
* [#207](https://github.com/shlinkio/shlink/issues/207) Added two new config options which are asked during installation process. The config options already existed in previous shlink version, but you had to manually set their values.
These are the new options:
* Visits threshold to allow short URLs to be deleted.
* Check the visits threshold when trying to delete a short URL via REST API.
#### Changed
* [#211](https://github.com/shlinkio/shlink/issues/211) Extracted installer to its own module, which will simplify moving it to a separated package in the future.
* [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) Renamed REST Action classes and CLI Command classes to use the concept of `ShortUrl` instead of the concept of `ShortCode` when referring to the entity, and left the `short code` concept to the identifier which is used as a unique code for a specific `Short URL`.
* [#181](https://github.com/shlinkio/shlink/issues/181) When importing the configuration from a previous shlink installation, it no longer asks to import every block. Instead, it is capable of detecting only new config options introduced in the new version, and ask only for those.
If no new options are found and you have selected to import config, no further questions will be asked and shlink will just import the old config.
#### Deprecated
* [#205](https://github.com/shlinkio/shlink/issues/205) Deprecated `[POST /authenticate]` endpoint, and allowed any API request to be automatically authenticated using the `X-Api-Key` header with a valid API key.
This effectively deprecates the `Authorization: Bearer <JWT>` authentication form, but it will keep working.
* As of [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) REST urls have changed from `/short-codes/...` to `/short-urls/...`, and the command namespaces have changed from `short-code:...` to `short-url:...`.
In both cases, backwards compatibility has been retained and the old ones are aliases for the new ones, but the old ones are considered deprecated.
#### Removed
* *Nothing*
#### Fixed
* [#203](https://github.com/shlinkio/shlink/issues/203) Fixed some warnings thrown while unzipping distributable files.
* [#206](https://github.com/shlinkio/shlink/issues/206) An error is now thrown during installation if any required param is left empty, making the installer display a message and ask again until a value is set.
## 1.12.0 - 2018-09-15
#### Added

View File

@@ -37,13 +37,13 @@ Then, you will need a built version of the project. There are a few ways to get
After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory.
This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is created, attaching generated dist file to it.
This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.org/shlinkio/shlink), attaching generated dist file to it.
Despite how you built the project, you are going to need to install it now, by following these steps:
* If you are going to use MySQL or PostgreSQL, create an empty database with the name of your choice.
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
* Setup the application by running the `bin/install` script. It will guide you through the installation process.
* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
* Configure the web server of your choice to serve shlink using your short domain.
For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, this would be the basic configuration for Nginx and Apache.

View File

@@ -1,29 +1,12 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer;
use Symfony\Component\Console\Application;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\ServiceManager;
use Zend\ServiceManager\ServiceLocatorInterface;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$container = new ServiceManager([
'factories' => [
Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class,
],
'services' => [
'config' => [
ConfigAbstractFactory::class => [
DatabaseConfigCustomizer::class => [Filesystem::class]
],
],
],
]);
/** @var ServiceLocatorInterface $container */
$container = include __DIR__ . '/../config/install-container.php';
$container->build(Application::class)->run();

View File

@@ -1,29 +1,12 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer;
use Symfony\Component\Console\Application;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\ServiceManager;
use Zend\ServiceManager\ServiceLocatorInterface;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$container = new ServiceManager([
'factories' => [
Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class,
],
'services' => [
'config' => [
ConfigAbstractFactory::class => [
DatabaseConfigCustomizer::class => [Filesystem::class]
],
],
],
]);
/** @var ServiceLocatorInterface $container */
$container = include __DIR__ . '/../config/install-container.php';
$container->build(Application::class, ['isUpdate' => true])->run();

View File

@@ -8,53 +8,51 @@ if [ "$#" -ne 1 ]; then
fi
version=$1
builtcontent=$(readlink -f "../shlink_${version}_dist")
builtcontent="./build/shlink_${version}_dist"
projectdir=$(pwd)
[ -f ./composer.phar ] && composerBin='./composer.phar' || composerBin='composer'
# Copy project content to temp dir
echo 'Copying project files...'
rm -rf "${builtcontent}"
mkdir "${builtcontent}"
sudo chmod -R 777 "${projectdir}"/data/infra/{database,nginx}
cp -R "${projectdir}"/* "${builtcontent}"
mkdir -p "${builtcontent}"
rsync -av * "${builtcontent}" \
--exclude=data/infra \
--exclude=**/.gitignore \
--exclude=CHANGELOG.md \
--exclude=composer.lock \
--exclude=vendor \
--exclude=docs \
--exclude=indocker \
--exclude=docker* \
--exclude=func_tests_bootstrap.php \
--exclude=php* \
--exclude=infection.json \
--exclude=phpstan.neon \
--exclude=config/autoload/*local* \
--exclude=**/test* \
--exclude=build*
cd "${builtcontent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
rm -rf vendor
rm -f composer.lock
$composerBin self-update
$composerBin install --no-dev --optimize-autoloader --no-progress --no-interaction
${composerBin} self-update
${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction
# Delete development files
echo 'Deleting dev files...'
rm build.sh
rm CHANGELOG.md
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
rm -rf data/{cache,log,proxies}/{*,.gitignore}
rm -rf config/params/{*,.gitignore}
rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
rm -f data/database.sqlite
# Update shlink version in config
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Compressing file
echo 'Compressing files...'
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"
cd "${projectdir}"/build
rm -f ./shlink_${version}_dist.zip
zip -ry ./shlink_${version}_dist.zip ./shlink_${version}_dist
cd "${projectdir}"
rm -rf "${builtcontent}"
echo 'Done!'

View File

@@ -16,6 +16,7 @@
"ext-json": "*",
"ext-pdo": "*",
"acelaya/ze-content-based-error-handler": "^2.2",
"cakephp/chronos": "^1.2",
"cocur/slugify": "^3.0",
"doctrine/cache": "^1.6",
"doctrine/migrations": "^1.4",
@@ -26,13 +27,13 @@
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master",
"symfony/console": "^4.0",
"symfony/console": "^4.0 <4.1.5",
"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-diactoros": "^1.7",
"zendframework/zend-diactoros": "^2.0",
"zendframework/zend-expressive": "^3.0",
"zendframework/zend-expressive-fastroute": "^3.0",
"zendframework/zend-expressive-helpers": "^5.0",
@@ -61,7 +62,8 @@
"Shlinkio\\Shlink\\CLI\\": "module/CLI/src",
"Shlinkio\\Shlink\\Rest\\": "module/Rest/src",
"Shlinkio\\Shlink\\Core\\": "module/Core/src",
"Shlinkio\\Shlink\\Common\\": "module/Common/src"
"Shlinkio\\Shlink\\Common\\": "module/Common/src",
"Shlinkio\\Shlink\\Installer\\": "module/Installer/src"
},
"files": [
"module/Common/functions/functions.php"
@@ -78,7 +80,8 @@
"ShlinkioTest\\Shlink\\Common\\": [
"module/Common/test",
"module/Common/test-func"
]
],
"ShlinkioTest\\Shlink\\Installer\\": "module/Installer/test"
}
},
"scripts": {
@@ -86,30 +89,44 @@
"@cs",
"@stan",
"@test",
"@func-test",
"@infect"
],
"cs": "phpcs",
"cs-fix": "phpcbf",
"serve": "php -S 0.0.0.0:8000 -t public/",
"test": "phpunit --coverage-php build/coverage-unit.cov",
"pretty-test": "phpunit --coverage-html build/coverage",
"func-test": "phpunit -c phpunit-func.xml --coverage-php build/coverage-func.cov",
"complete-pretty-test": [
"@test",
"@func-test",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"test": [
"@test:unit",
"@test:func"
],
"test:unit": "phpunit --coverage-php build/coverage-unit.cov",
"test:func": "phpunit -c phpunit-func.xml --coverage-php build/coverage-func.cov",
"test:pretty": [
"@test:unit",
"@test:func",
"phpcov merge build --html build/html"
],
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"infect": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2",
"infect-show": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2 --show-mutations",
"expressive": "expressive"
"test:unit:pretty": "phpunit --coverage-html build/coverage",
"infect": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2",
"infect:show": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2 --show-mutations"
},
"scripts-descriptions": {
"check": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test\" and \"infect\"</>",
"cs": "<fg=blue;options=bold>Checks coding styles</>",
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
"test": "<fg=blue;options=bold>Runs all test suites</>",
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:func": "<fg=blue;options=bold>Runs functional test suites (covering entity repositories)</>",
"test:pretty": "<fg=blue;options=bold>Runs all test suites and generates an HTML code coverage report</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>"
},
"config": {
"process-timeout": 0,
"sort-packages": true,
"platform": {
"php": "7.1.8"
}
"sort-packages": true
}
}

View File

@@ -1,12 +1,8 @@
<?php
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;
use Shlinkio\Shlink\Rest\Middleware\PathVersionMiddleware;
namespace Shlinkio\Shlink;
use Zend\Expressive;
use Zend\Stratigility\Middleware\ErrorHandler;
@@ -17,14 +13,15 @@ return [
'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
LocaleMiddleware::class,
Common\Middleware\LocaleMiddleware::class,
],
'priority' => 11,
'priority' => 12,
],
'pre-routing-rest' => [
'path' => '/rest',
'middleware' => [
PathVersionMiddleware::class,
Rest\Middleware\PathVersionMiddleware::class,
Rest\Middleware\ShortUrl\ShortCodePathMiddleware::class,
],
'priority' => 11,
],
@@ -39,10 +36,10 @@ return [
'rest' => [
'path' => '/rest',
'middleware' => [
CrossDomainMiddleware::class,
Rest\Middleware\CrossDomainMiddleware::class,
Expressive\Router\Middleware\ImplicitOptionsMiddleware::class,
BodyParserMiddleware::class,
CheckAuthenticationMiddleware::class,
Rest\Middleware\BodyParserMiddleware::class,
Rest\Middleware\AuthenticationMiddleware::class,
],
'priority' => 5,
],
@@ -50,7 +47,7 @@ return [
'post-routing' => [
'middleware' => [
Expressive\Router\Middleware\DispatchMiddleware::class,
NotFoundHandler::class,
Core\Response\NotFoundHandler::class,
],
'priority' => 1,
],

View File

@@ -1,23 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Acelaya\ExpressiveErrorHandler;
use Shlinkio\Shlink\CLI;
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``.
* then ``local.php`` and finally ``*.local.php``. This way local settings overwrite global settings.
*
* The configuration can be cached. This can be done by setting ``config_cache_enabled`` to ``true``.
*
* Obviously, if you use closures in your config you can't cache it.
*/
return (new ConfigAggregator\ConfigAggregator([
Expressive\ConfigProvider::class,
Expressive\Router\ConfigProvider::class,
@@ -31,6 +20,7 @@ return (new ConfigAggregator\ConfigAggregator([
Common\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Installer\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\ZendConfigProvider('config/{autoload/{{,*.}global,{,*.}local},params/generated_config}.php'),
], 'data/cache/app_config.php'))->getMergedConfig();

View File

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

View File

@@ -1,4 +1,4 @@
FROM php:7.1-fpm-alpine
FROM php:7.1.22-fpm-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
RUN apk update

View File

@@ -1,10 +1,12 @@
{
"post": {
"deprecated": true,
"operationId": "authenticate",
"tags": [
"Authentication"
],
"summary": "Perform authentication",
"description": "Performs an authentication",
"summary": "[Deprecated] Perform authentication",
"description": "**This endpoint is deprecated, since the authentication can be performed via API key now**. Performs an authentication.",
"requestBody": {
"description": "Request body.",
"required": true,

View File

@@ -1,10 +1,11 @@
{
"get": {
"operationId": "listShortUrls",
"tags": [
"ShortCodes"
"Short URLs"
],
"summary": "List short URLs",
"description": "Returns the list of short codes",
"description": "Returns the list of short URLs.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"name": "page",
@@ -53,6 +54,9 @@
}
],
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
@@ -142,12 +146,16 @@
},
"post": {
"operationId": "createShortUrl",
"tags": [
"ShortCodes"
"Short URLs"
],
"summary": "Create short URL",
"description": "Creates a new short code",
"description": "Creates a new short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}

View File

@@ -1,10 +1,11 @@
{
"get": {
"operationId": "shortenUrl",
"tags": [
"ShortCodes"
"Short URLs"
],
"summary": "Create a short URL",
"description": "Creates a short URL in a single API call. Useful for third party integrations",
"description": "Creates a short URL in a single API call. Useful for third party integrations.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"name": "apiKey",

View File

@@ -1,10 +1,11 @@
{
"get": {
"operationId": "getShortUrl",
"tags": [
"ShortCodes"
"Short URLs"
],
"summary": "Parse short code",
"description": "Get the long URL behind a short code.",
"description": "Get the long URL behind a short URL's short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"name": "shortCode",
@@ -17,6 +18,9 @@
}
],
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
@@ -78,11 +82,12 @@
},
"put": {
"operationId": "editShortUrl",
"tags": [
"ShortCodes"
"Short URLs"
],
"summary": "Edit short code",
"description": "Update certain meta arguments from an existing short URL.",
"summary": "Edit short URL",
"description": "Update certain meta arguments from an existing short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"name": "shortCode",
@@ -120,6 +125,9 @@
}
},
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
@@ -162,11 +170,12 @@
},
"delete": {
"operationId": "deleteShortUrl",
"tags": [
"ShortCodes"
"Short URLs"
],
"summary": "Delete short code",
"description": "Deletes the short URL for provided short code.",
"summary": "Delete short URL",
"description": "Deletes the short URL for provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"name": "shortCode",
@@ -179,13 +188,16 @@
}
],
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
],
"responses": {
"204": {
"description": "The short code has been properly deleted."
"description": "The short URL has been properly deleted."
},
"400": {
"description": "The visits threshold in shlink does not allow this short URL to be deleted.",

View File

@@ -1,16 +1,16 @@
{
"put": {
"operationId": "editShortUrlTags",
"tags": [
"ShortCodes",
"Tags"
"Short URLs"
],
"summary": "Edit tags on short URL",
"description": "Edit the tags on provided short code.",
"description": "Edit the tags on URL identified by provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The shortCode in which we want to edit tags.",
"description": "The short code for the short URL in which we want to edit tags.",
"required": true,
"schema": {
"type": "string"
@@ -41,6 +41,9 @@
}
},
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}

View File

@@ -1,16 +1,16 @@
{
"get": {
"operationId": "getShortUrlVisits",
"tags": [
"ShortCodes",
"Visits"
],
"summary": "List visits for short URL",
"description": "Get the list of visits on provided short code.",
"description": "Get the list of visits on the short URL behind provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The shortCode from which we want to get the visits.",
"description": "The short code for the short URL from which we want to get the visits.",
"required": true,
"schema": {
"type": "string"
@@ -36,6 +36,9 @@
}
],
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}

View File

@@ -1,11 +1,15 @@
{
"get": {
"operationId": "listTags",
"tags": [
"Tags"
],
"summary": "List existing tags",
"description": "Returns the list of all tags used in any short URL, ordered by name",
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
@@ -60,12 +64,16 @@
},
"post": {
"operationId": "createTags",
"tags": [
"Tags"
],
"summary": "Create tags",
"description": "Provided a list of tags, creates all that do not yet exist",
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
@@ -143,12 +151,16 @@
},
"put": {
"operationId": "renameTag",
"tags": [
"Tags"
],
"summary": "Rename tag",
"description": "Renames one existing tag",
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
@@ -216,6 +228,7 @@
},
"delete": {
"operationId": "deleteTags",
"tags": [
"Tags"
],
@@ -236,6 +249,9 @@
}
],
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}

View File

@@ -23,8 +23,14 @@
"components": {
"securitySchemes": {
"ApiKey": {
"description": "A valid shlink API key",
"type": "apiKey",
"in": "header",
"name": "X-Api-Key"
},
"Bearer": {
"description": "The JWT identifying a previously logged API key",
"description": "**[Deprecated]** The JWT identifying a previously authenticated API key",
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
@@ -32,30 +38,49 @@
}
},
"paths": {
"/v1/authenticate": {
"$ref": "paths/v1_authenticate.json"
"tags": [
{
"name": "Short URLs",
"description": "Operations that can be performed on short URLs"
},
{
"name": "Tags",
"description": "Let you handle the list of available tags"
},
{
"name": "Visits",
"description": "Operations to manage visits on short URLs"
},
{
"name": "Authentication",
"description": "Authentication-related endpoints"
}
],
"/v1/short-codes": {
"$ref": "paths/v1_short-codes.json"
"paths": {
"/v1/short-urls": {
"$ref": "paths/v1_short-urls.json"
},
"/v1/short-codes/shorten": {
"$ref": "paths/v1_short-codes_shorten.json"
"/v1/short-urls/shorten": {
"$ref": "paths/v1_short-urls_shorten.json"
},
"/v1/short-codes/{shortCode}": {
"$ref": "paths/v1_short-codes_{shortCode}.json"
"/v1/short-urls/{shortCode}": {
"$ref": "paths/v1_short-urls_{shortCode}.json"
},
"/v1/short-codes/{shortCode}/tags": {
"$ref": "paths/v1_short-codes_{shortCode}_tags.json"
"/v1/short-urls/{shortCode}/tags": {
"$ref": "paths/v1_short-urls_{shortCode}_tags.json"
},
"/v1/tags": {
"$ref": "paths/v1_tags.json"
},
"/v1/short-codes/{shortCode}/visits": {
"$ref": "paths/v1_short-codes_{shortCode}_visits.json"
"/v1/short-urls/{shortCode}/visits": {
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
},
"/v1/authenticate": {
"$ref": "paths/v1_authenticate.json"
}
}
}

View File

@@ -1,20 +1,21 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\CLI\Command;
use Shlinkio\Shlink\Common;
namespace Shlinkio\Shlink\CLI;
use function Shlinkio\Shlink\Common\env;
return [
'cli' => [
'locale' => Common\env('CLI_LOCALE', 'en'),
'locale' => env('CLI_LOCALE', 'en'),
'commands' => [
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\Shortcode\DeleteShortCodeCommand::NAME => Command\Shortcode\DeleteShortCodeCommand::class,
Command\ShortUrl\GenerateShortUrlCommand::NAME => Command\ShortUrl\GenerateShortUrlCommand::class,
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
Command\ShortUrl\GetVisitsCommand::NAME => Command\ShortUrl\GetVisitsCommand::class,
Command\ShortUrl\GeneratePreviewCommand::NAME => Command\ShortUrl\GeneratePreviewCommand::class,
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,

View File

@@ -1,8 +1,8 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\CLI\Command;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
namespace Shlinkio\Shlink\CLI;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Service;
@@ -15,14 +15,14 @@ return [
'dependencies' => [
'factories' => [
Application::class => ApplicationFactory::class,
Application::class => Factory\ApplicationFactory::class,
Command\Shortcode\GenerateShortcodeCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\ResolveUrlCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\ListShortcodesCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\DeleteShortCodeCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
@@ -41,24 +41,24 @@ return [
],
ConfigAbstractFactory::class => [
Command\Shortcode\GenerateShortcodeCommand::class => [
Command\ShortUrl\GenerateShortUrlCommand::class => [
Service\UrlShortener::class,
'translator',
'config.url_shortener.domain',
],
Command\Shortcode\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'],
Command\Shortcode\ListShortcodesCommand::class => [
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'],
Command\ShortUrl\ListShortUrlsCommand::class => [
Service\ShortUrlService::class,
'translator',
'config.url_shortener.domain',
],
Command\Shortcode\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'],
Command\Shortcode\GeneratePreviewCommand::class => [
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'],
Command\ShortUrl\GeneratePreviewCommand::class => [
Service\ShortUrlService::class,
PreviewGenerator::class,
'translator',
],
Command\Shortcode\DeleteShortCodeCommand::class => [
Command\ShortUrl\DeleteShortUrlCommand::class => [
Service\ShortUrl\DeleteShortUrlService::class,
'translator',
],

Binary file not shown.

View File

@@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-09-15 17:57+0200\n"
"PO-Revision-Date: 2018-09-15 18:02+0200\n"
"POT-Creation-Date: 2018-09-16 18:36+0200\n"
"PO-Revision-Date: 2018-09-16 18:37+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@@ -83,8 +83,8 @@ msgstr "Clave secreta: \"%s\""
msgid "Deletes a short URL"
msgstr "Elimina una URL"
msgid "The short code to be deleted"
msgstr "El código corto a eliminar"
msgid "The short code for the short URL to be deleted"
msgstr "El código corto de la URL corta a eliminar"
msgid ""
"Ignores the safety visits threshold check, which could make short URLs with "
@@ -135,9 +135,8 @@ msgstr " <info>¡Correcto!</info>"
msgid "Error"
msgstr "Error"
msgid "Generates a short code for provided URL and returns the short URL"
msgstr ""
"Genera un código corto para la URL proporcionada y devuelve la URL acortada"
msgid "Generates a short URL for provided long URL and returns it"
msgstr "Genera una URL corta para la URL larga proporcionada y la devuelve"
msgid "The long URL to parse"
msgstr "La URL larga a procesar"
@@ -268,8 +267,8 @@ msgstr "Número de visitas"
msgid "Tags"
msgstr "Etiquetas"
msgid "Short codes properly listed"
msgstr "Códigos cortos correctamente listados"
msgid "Short URLs properly listed"
msgstr "URLs cortas listadas correctamente"
msgid "Continue with page"
msgstr "Continuar con la página"

View File

@@ -10,10 +10,11 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class DisableKeyCommand extends Command
{
const NAME = 'api-key:disable';
public const NAME = 'api-key:disable';
/**
* @var ApiKeyServiceInterface
@@ -31,14 +32,14 @@ class DisableKeyCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Disables an API key.'))
->addArgument('apiKey', InputArgument::REQUIRED, $this->translator->translate('The API key to disable'));
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$apiKey = $input->getArgument('apiKey');
$io = new SymfonyStyle($input, $output);

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -10,10 +11,11 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class GenerateKeyCommand extends Command
{
const NAME = 'api-key:generate';
public const NAME = 'api-key:generate';
/**
* @var ApiKeyServiceInterface
@@ -31,7 +33,7 @@ class GenerateKeyCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Generates a new valid API key.'))
@@ -43,10 +45,10 @@ class GenerateKeyCommand extends Command
);
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$expirationDate = $input->getOption('expirationDate');
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? new \DateTime($expirationDate) : null);
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);
(new SymfonyStyle($input, $output))->success(
sprintf($this->translator->translate('Generated API key: "%s"'), $apiKey)

View File

@@ -11,6 +11,8 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function array_filter;
use function sprintf;
class ListKeysCommand extends Command
{
@@ -36,7 +38,7 @@ class ListKeysCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Lists all the available API keys.'))
@@ -48,7 +50,7 @@ class ListKeysCommand extends Command
);
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$enabledOnly = $input->getOption('enabledOnly');
@@ -62,16 +64,16 @@ class ListKeysCommand extends Command
$messagePattern = $this->determineMessagePattern($row);
// Set columns for this row
$rowData = [\sprintf($messagePattern, $key)];
$rowData = [sprintf($messagePattern, $key)];
if (! $enabledOnly) {
$rowData[] = \sprintf($messagePattern, $this->getEnabledSymbol($row));
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($row));
}
$rowData[] = $expiration !== null ? $expiration->format(\DateTime::ATOM) : '-';
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
$rows[] = $rowData;
}
$io->table(\array_filter([
$io->table(array_filter([
$this->translator->translate('Key'),
! $enabledOnly ? $this->translator->translate('Is enabled') : null,
$this->translator->translate('Expiration date'),

View File

@@ -9,10 +9,12 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
use function str_shuffle;
class GenerateCharsetCommand extends Command
{
const NAME = 'config:generate-charset';
public const NAME = 'config:generate-charset';
/**
* @var TranslatorInterface
@@ -25,7 +27,7 @@ class GenerateCharsetCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription(sprintf($this->translator->translate(
@@ -34,11 +36,11 @@ class GenerateCharsetCommand extends Command
), UrlShortener::DEFAULT_CHARS));
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
(new SymfonyStyle($input, $output))->success(
\sprintf($this->translator->translate('Character set: "%s"'), $charSet)
sprintf($this->translator->translate('Character set: "%s"'), $charSet)
);
}
}

View File

@@ -9,12 +9,13 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class GenerateSecretCommand extends Command
{
use StringUtilsTrait;
const NAME = 'config:generate-secret';
public const NAME = 'config:generate-secret';
/**
* @var TranslatorInterface
@@ -27,7 +28,7 @@ class GenerateSecretCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate(
@@ -35,7 +36,7 @@ class GenerateSecretCommand extends Command
));
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$secret = $this->generateRandomString(32);
(new SymfonyStyle($input, $output))->success(

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
@@ -13,10 +13,10 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class DeleteShortCodeCommand extends Command
class DeleteShortUrlCommand extends Command
{
public const NAME = 'short-code:delete';
private const ALIASES = [];
public const NAME = 'short-url:delete';
private const ALIASES = ['short-code:delete'];
/**
* @var DeleteShortUrlServiceInterface
@@ -45,7 +45,7 @@ class DeleteShortCodeCommand extends Command
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to be deleted')
$this->translator->translate('The short code for the short URL to be deleted')
)
->addOption(
'ignore-threshold',

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
@@ -14,8 +14,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class GeneratePreviewCommand extends Command
{
public const NAME = 'short-code:process-previews';
private const ALIASES = ['shortcode:process-previews'];
public const NAME = 'short-url:process-previews';
private const ALIASES = ['shortcode:process-previews', 'short-code:process-previews'];
/**
* @var PreviewGeneratorInterface

View File

@@ -1,8 +1,9 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@@ -16,12 +17,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Diactoros\Uri;
use Zend\I18n\Translator\TranslatorInterface;
class GenerateShortcodeCommand extends Command
class GenerateShortUrlCommand extends Command
{
use ShortUrlBuilderTrait;
public const NAME = 'short-code:generate';
private const ALIASES = ['shortcode:generate'];
public const NAME = 'short-url:generate';
private const ALIASES = ['shortcode:generate', 'short-code:generate'];
/**
* @var UrlShortenerInterface
@@ -53,7 +54,7 @@ class GenerateShortcodeCommand extends Command
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Generates a short code for provided URL and returns the short URL')
$this->translator->translate('Generates a short URL for provided long URL and returns it')
)
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
->addOption(
@@ -143,9 +144,9 @@ class GenerateShortcodeCommand extends Command
}
}
private function getOptionalDate(InputInterface $input, string $fieldName): ?\DateTime
private function getOptionalDate(InputInterface $input, string $fieldName): ?Chronos
{
$since = $input->getOption($fieldName);
return $since !== null ? new \DateTime($since) : null;
return $since !== null ? Chronos::parse($since) : null;
}
}

View File

@@ -1,8 +1,9 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Symfony\Component\Console\Command\Command;
@@ -15,8 +16,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class GetVisitsCommand extends Command
{
public const NAME = 'short-code:visits';
private const ALIASES = ['shortcode:visits'];
public const NAME = 'short-url:visits';
private const ALIASES = ['shortcode:visits', 'short-code:visits'];
/**
* @var VisitsTrackerInterface
@@ -107,10 +108,6 @@ class GetVisitsCommand extends Command
private function getDateOption(InputInterface $input, $key)
{
$value = $input->getOption($key);
if (! empty($value)) {
$value = new \DateTime($value);
}
return $value;
return ! empty($value) ? Chronos::parse($value) : $value;
}
}

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
@@ -14,12 +14,12 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class ListShortcodesCommand extends Command
class ListShortUrlsCommand extends Command
{
use PaginatorUtilsTrait;
public const NAME = 'short-code:list';
private const ALIASES = ['shortcode:list'];
public const NAME = 'short-url:list';
private const ALIASES = ['shortcode:list', 'short-code:list'];
/**
* @var ShortUrlServiceInterface
@@ -132,7 +132,7 @@ class ListShortcodesCommand extends Command
if ($this->isLastPage($result)) {
$continue = false;
$io->success($this->translator->translate('Short codes properly listed'));
$io->success($this->translator->translate('Short URLs properly listed'));
} else {
$continue = $io->confirm(
\sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
@@ -15,8 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class ResolveUrlCommand extends Command
{
public const NAME = 'short-code:parse';
private const ALIASES = ['shortcode:parse'];
public const NAME = 'short-url:parse';
private const ALIASES = ['shortcode:parse', 'short-code:parse'];
/**
* @var UrlShortenerInterface

View File

@@ -13,7 +13,7 @@ use Zend\I18n\Translator\TranslatorInterface;
class CreateTagCommand extends Command
{
const NAME = 'tag:create';
public const NAME = 'tag:create';
/**
* @var TagServiceInterface
@@ -31,7 +31,7 @@ class CreateTagCommand extends Command
parent::__construct();
}
protected function configure()
protected function configure(): void
{
$this
->setName(self::NAME)
@@ -44,7 +44,7 @@ class CreateTagCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name');

View File

@@ -13,7 +13,7 @@ use Zend\I18n\Translator\TranslatorInterface;
class DeleteTagsCommand extends Command
{
const NAME = 'tag:delete';
public const NAME = 'tag:delete';
/**
* @var TagServiceInterface
@@ -31,7 +31,7 @@ class DeleteTagsCommand extends Command
parent::__construct();
}
protected function configure()
protected function configure(): void
{
$this
->setName(self::NAME)
@@ -44,7 +44,7 @@ class DeleteTagsCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name');

View File

@@ -10,10 +10,11 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function array_map;
class ListTagsCommand extends Command
{
const NAME = 'tag:list';
public const NAME = 'tag:list';
/**
* @var TagServiceInterface
@@ -31,14 +32,14 @@ class ListTagsCommand extends Command
parent::__construct();
}
protected function configure()
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription($this->translator->translate('Lists existing tags.'));
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$io->table([$this->translator->translate('Name')], $this->getTagsRows());
@@ -51,7 +52,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

@@ -11,10 +11,11 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class RenameTagCommand extends Command
{
const NAME = 'tag:rename';
public const NAME = 'tag:rename';
/**
* @var TagServiceInterface
@@ -32,7 +33,7 @@ class RenameTagCommand extends Command
parent::__construct();
}
protected function configure()
protected function configure(): void
{
$this
->setName(self::NAME)
@@ -41,7 +42,7 @@ class RenameTagCommand extends Command
->addArgument('newName', InputArgument::REQUIRED, $this->translator->translate('New name of the tag.'));
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
@@ -51,7 +52,7 @@ class RenameTagCommand extends Command
$this->tagService->renameTag($oldName, $newName);
$io->success($this->translator->translate('Tag properly renamed.'));
} catch (EntityDoesNotExistException $e) {
$io->error(\sprintf($this->translator->translate('A tag with name "%s" was not found'), $oldName));
$io->error(sprintf($this->translator->translate('A tag with name "%s" was not found'), $oldName));
}
}
}

View File

@@ -13,6 +13,8 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sleep;
use function sprintf;
class ProcessVisitsCommand extends Command
{
@@ -42,7 +44,7 @@ class ProcessVisitsCommand extends Command
parent::__construct(null);
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription(
@@ -50,7 +52,7 @@ class ProcessVisitsCommand extends Command
);
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits();
@@ -58,10 +60,10 @@ class ProcessVisitsCommand extends Command
$count = 0;
foreach ($visits as $visit) {
$ipAddr = $visit->getRemoteAddr();
$io->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 === IpAddress::LOCALHOST) {
$io->writeln(
\sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
);
continue;
}
@@ -75,13 +77,13 @@ class ProcessVisitsCommand extends Command
$visit->setVisitLocation($location);
$this->visitService->saveVisit($visit);
$io->writeln(\sprintf(
$io->writeln(sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName()
));
} catch (WrongIpException $e) {
$io->writeln(
\sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
);
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
@@ -91,11 +93,11 @@ class ProcessVisitsCommand extends Command
if ($count === $this->ipLocationResolver->getApiLimit()) {
$count = 0;
$seconds = $this->ipLocationResolver->getApiInterval();
$io->note(\sprintf(
$io->note(sprintf(
$this->translator->translate('IP location resolver limit reached. Waiting %s seconds...'),
$seconds
));
\sleep($seconds);
sleep($seconds);
}
}

View File

@@ -1,44 +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\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,17 +0,0 @@
<?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,78 +0,0 @@
<?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,36 +0,0 @@
<?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,43 +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\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,270 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Model;
use Zend\Stdlib\ArraySerializableInterface;
final class CustomizableAppConfig implements ArraySerializableInterface
{
const SQLITE_DB_PATH = 'data/database.sqlite';
/**
* @var array
*/
private $database;
/**
* @var array
*/
private $urlShortener;
/**
* @var array
*/
private $language;
/**
* @var array
*/
private $app;
/**
* @var string
*/
private $importedInstallationPath;
/**
* @return array
*/
public function getDatabase()
{
return $this->database;
}
/**
* @param array $database
* @return $this
*/
public function setDatabase(array $database)
{
$this->database = $database;
return $this;
}
/**
* @return bool
*/
public function hasDatabase()
{
return ! empty($this->database);
}
/**
* @return array
*/
public function getUrlShortener()
{
return $this->urlShortener;
}
/**
* @param array $urlShortener
* @return $this
*/
public function setUrlShortener(array $urlShortener)
{
$this->urlShortener = $urlShortener;
return $this;
}
/**
* @return bool
*/
public function hasUrlShortener()
{
return ! empty($this->urlShortener);
}
/**
* @return array
*/
public function getLanguage()
{
return $this->language;
}
/**
* @param array $language
* @return $this
*/
public function setLanguage(array $language)
{
$this->language = $language;
return $this;
}
/**
* @return bool
*/
public function hasLanguage()
{
return ! empty($this->language);
}
/**
* @return array
*/
public function getApp()
{
return $this->app;
}
/**
* @param array $app
* @return $this
*/
public function setApp(array $app)
{
$this->app = $app;
return $this;
}
/**
* @return bool
*/
public function hasApp()
{
return ! empty($this->app);
}
/**
* @return string
*/
public function getImportedInstallationPath()
{
return $this->importedInstallationPath;
}
/**
* @param string $importedInstallationPath
* @return $this|self
*/
public function setImportedInstallationPath($importedInstallationPath)
{
$this->importedInstallationPath = $importedInstallationPath;
return $this;
}
/**
* @return bool
*/
public function hasImportedInstallationPath()
{
return $this->importedInstallationPath !== null;
}
/**
* Exchange internal values from provided array
*
* @param array $array
* @return void
*/
public function exchangeArray(array $array)
{
if (isset($array['app_options'], $array['app_options']['secret_key'])) {
$this->setApp([
'SECRET' => $array['app_options']['secret_key'],
]);
}
if (isset($array['entity_manager'], $array['entity_manager']['connection'])) {
$this->deserializeDatabase($array['entity_manager']['connection']);
}
if (isset($array['translator'], $array['translator']['locale'], $array['cli'], $array['cli']['locale'])) {
$this->setLanguage([
'DEFAULT' => $array['translator']['locale'],
'CLI' => $array['cli']['locale'],
]);
}
if (isset($array['url_shortener'])) {
$urlShortener = $array['url_shortener'];
$this->setUrlShortener([
'SCHEMA' => $urlShortener['domain']['schema'],
'HOSTNAME' => $urlShortener['domain']['hostname'],
'CHARS' => $urlShortener['shortcode_chars'],
'VALIDATE_URL' => $urlShortener['validate_url'] ?? true,
]);
}
}
private function deserializeDatabase(array $conn)
{
if (! isset($conn['driver'])) {
return;
}
$driver = $conn['driver'];
$params = ['DRIVER' => $driver];
if ($driver !== 'pdo_sqlite') {
$params['USER'] = $conn['user'];
$params['PASSWORD'] = $conn['password'];
$params['NAME'] = $conn['dbname'];
$params['HOST'] = $conn['host'];
$params['PORT'] = $conn['port'];
}
$this->setDatabase($params);
}
/**
* Return an array representation of the object
*
* @return array
*/
public function getArrayCopy()
{
$config = [
'app_options' => [
'secret_key' => $this->app['SECRET'],
'disable_track_param' => $this->app['DISABLE_TRACK_PARAM'] ?? null,
],
'entity_manager' => [
'connection' => [
'driver' => $this->database['DRIVER'],
],
],
'translator' => [
'locale' => $this->language['DEFAULT'],
],
'cli' => [
'locale' => $this->language['CLI'],
],
'url_shortener' => [
'domain' => [
'schema' => $this->urlShortener['SCHEMA'],
'hostname' => $this->urlShortener['HOSTNAME'],
],
'shortcode_chars' => $this->urlShortener['CHARS'],
'validate_url' => $this->urlShortener['VALIDATE_URL'],
],
];
// Build dynamic database config based on selected driver
if ($this->database['DRIVER'] === 'pdo_sqlite') {
$config['entity_manager']['connection']['path'] = self::SQLITE_DB_PATH;
} else {
$config['entity_manager']['connection']['user'] = $this->database['USER'];
$config['entity_manager']['connection']['password'] = $this->database['PASSWORD'];
$config['entity_manager']['connection']['dbname'] = $this->database['NAME'];
$config['entity_manager']['connection']['host'] = $this->database['HOST'];
$config['entity_manager']['connection']['port'] = $this->database['PORT'];
if ($this->database['DRIVER'] === 'pdo_mysql') {
$config['entity_manager']['connection']['driverOptions'] = [
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
];
}
}
return $config;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
@@ -50,8 +51,8 @@ class GenerateKeyCommandTest extends TestCase
*/
public function expirationDateIsDefinedIfProvided()
{
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
'--expirationDate' => '2016-01-01',

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\DeleteShortCodeCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Application;
@@ -28,7 +28,7 @@ class DeleteShortCodeCommandTest extends TestCase
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$command = new DeleteShortCodeCommand($this->service->reveal(), Translator::factory([]));
$command = new DeleteShortUrlCommand($this->service->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\GeneratePreviewCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GeneratePreviewCommand;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\GenerateShortcodeCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
@@ -28,7 +28,7 @@ class GenerateShortcodeCommandTest extends TestCase
public function setUp()
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new GenerateShortcodeCommand($this->urlShortener->reveal(), Translator::factory([]), [
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), Translator::factory([]), [
'schema' => 'http',
'hostname' => 'foo.com',
]);

View File

@@ -1,12 +1,13 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\GetVisitsCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
@@ -58,7 +59,7 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$startDate = '2016-01-01';
$endDate = '2016-02-01';
$this->visitsTracker->info($shortCode, new DateRange(new \DateTime($startDate), new \DateTime($endDate)))
$this->visitsTracker->info($shortCode, new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)))
->willReturn([])
->shouldBeCalledTimes(1);

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\ListShortcodesCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Application;
@@ -30,7 +30,7 @@ class ListShortcodesCommandTest extends TestCase
{
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$app = new Application();
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]), []);
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), Translator::factory([]), []);
$app->add($command);
$this->commandTester = new CommandTester($command);
}

View File

@@ -1,11 +1,11 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\ResolveUrlCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;

View File

@@ -1,91 +0,0 @@
<?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

@@ -3,7 +3,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use const JSON_ERROR_NONE;
use function array_key_exists;
use function array_shift;
use function getenv;
use function in_array;
use function is_array;
use function json_last_error;
use function json_last_error_msg;
use function strtolower;
use function trim;
@@ -40,3 +47,54 @@ function env($key, $default = null)
return trim($value);
}
function contains($needle, array $haystack): bool
{
return in_array($needle, $haystack, true);
}
function json_decode(string $json, int $depth = 512, int $options = 0): array
{
$data = \json_decode($json, true, $depth, $options);
if (JSON_ERROR_NONE !== json_last_error()) {
throw new Exception\InvalidArgumentException('Error decoding JSON: ' . json_last_error_msg());
}
return $data;
}
function array_path_exists(array $path, array $array): bool
{
// As soon as a step is not found, the path does not exist
$step = array_shift($path);
if (! array_key_exists($step, $array)) {
return false;
}
// Once the path is empty, we have found all the parts in the path
if (empty($path)) {
return true;
}
// If current value is not an array, then we have not found the path
$newArray = $array[$step];
if (! is_array($newArray)) {
return false;
}
return array_path_exists($path, $newArray);
}
function array_get_path(array $path, array $array)
{
do {
$step = array_shift($path);
if (! is_array($array) || ! array_key_exists($step, $array)) {
return null;
}
$array = $array[$step];
} while (! empty($path));
return $array;
}

View File

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

View File

@@ -6,15 +6,17 @@ namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\Common;
use Memcached;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use function Shlinkio\Shlink\Common\contains;
use function Shlinkio\Shlink\Common\env;
class CacheFactory implements FactoryInterface
{
const VALID_CACHE_ADAPTERS = [
private const VALID_CACHE_ADAPTERS = [
Cache\ApcuCache::class,
Cache\ArrayCache::class,
Cache\FilesystemCache::class,
@@ -51,14 +53,12 @@ class CacheFactory implements FactoryInterface
{
// Try to get the adapter from config
$config = $container->get('config');
if (isset($config['cache'], $config['cache']['adapter'])
&& in_array($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS)
) {
if (isset($config['cache']['adapter']) && contains($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS)) {
return $this->resolveCacheAdapter($config['cache']);
}
// If the adapter has not been set in config, create one based on environment
return Common\env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
}
/**
@@ -75,8 +75,8 @@ class CacheFactory implements FactoryInterface
case Cache\PhpFileCache::class:
return new $cacheConfig['adapter']($cacheConfig['options']['dir']);
case Cache\MemcachedCache::class:
$memcached = new \Memcached();
$servers = isset($cacheConfig['options']['servers']) ? $cacheConfig['options']['servers'] : [];
$memcached = new Memcached();
$servers = $cacheConfig['options']['servers'] ?? [];
foreach ($servers as $server) {
if (! isset($server['host'])) {

View File

@@ -5,10 +5,13 @@ namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Tools\Setup;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
@@ -16,15 +19,10 @@ use Zend\ServiceManager\Factory\FactoryInterface;
class EntityManagerFactory 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
* @throws ORMException
* @throws DBALException
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
@@ -35,6 +33,8 @@ class EntityManagerFactory implements FactoryInterface
$connectionConfig = $emConfig['connection'] ?? [];
$ormConfig = $emConfig['orm'] ?? [];
Type::addType(ChronosDateTimeType::CHRONOS_DATETIME, ChronosDateTimeType::class);
return EntityManager::create($connectionConfig, Setup::createAnnotationMetadataConfiguration(
$ormConfig['entities_paths'] ?? [],
$isDevMode,

View File

@@ -5,7 +5,10 @@ namespace Shlinkio\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use function Shlinkio\Shlink\Common\json_decode;
use function sprintf;
class IpApiLocationResolver implements IpLocationResolverInterface
{
@@ -29,10 +32,12 @@ class IpApiLocationResolver implements IpLocationResolverInterface
public function resolveIpLocation(string $ipAddress): array
{
try {
$response = $this->httpClient->get(\sprintf(self::SERVICE_PATTERN, $ipAddress));
return $this->mapFields(\json_decode((string) $response->getBody(), true));
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
return $this->mapFields(json_decode((string) $response->getBody()));
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
} catch (InvalidArgumentException $e) {
throw new WrongIpException('IP-API returned invalid body while locating IP address', 0, $e);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Type;
use Cake\Chronos\Chronos;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\DateTimeImmutableType;
class ChronosDateTimeType extends DateTimeImmutableType
{
public const CHRONOS_DATETIME = 'chronos_datetime';
public function getName(): string
{
return self::CHRONOS_DATETIME;
}
/**
* @throws ConversionException
*/
public function convertToPHPValue($value, AbstractPlatform $platform): ?Chronos
{
if ($value === null) {
return null;
}
$dateTime = parent::convertToPHPValue($value, $platform);
return Chronos::instance($dateTime);
}
/**
* @throws ConversionException
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if (null === $value) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format($platform->getDateTimeFormatString());
}
throw ConversionException::conversionFailedInvalidType(
$value,
$this->getName(),
['null', \DateTimeInterface::class]
);
}
}

View File

@@ -3,42 +3,35 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Util;
class DateRange
use Cake\Chronos\Chronos;
final class DateRange
{
/**
* @var \DateTimeInterface|null
* @var Chronos|null
*/
private $startDate;
/**
* @var \DateTimeInterface|null
* @var Chronos|null
*/
private $endDate;
public function __construct(\DateTimeInterface $startDate = null, \DateTimeInterface $endDate = null)
public function __construct(?Chronos $startDate = null, ?Chronos $endDate = null)
{
$this->startDate = $startDate;
$this->endDate = $endDate;
}
/**
* @return \DateTimeInterface|null
*/
public function getStartDate()
public function getStartDate(): ?Chronos
{
return $this->startDate;
}
/**
* @return \DateTimeInterface|null
*/
public function getEndDate()
public function getEndDate(): ?Chronos
{
return $this->endDate;
}
/**
* @return bool
*/
public function isEmpty(): bool
{
return $this->startDate === null && $this->endDate === null;

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Type;
use Cake\Chronos\Chronos;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
class ChronosDateTimeTypeTest extends TestCase
{
/**
* @var ChronosDateTimeType
*/
private $type;
public function setUp()
{
if (! Type::hasType(ChronosDateTimeType::CHRONOS_DATETIME)) {
Type::addType(ChronosDateTimeType::CHRONOS_DATETIME, ChronosDateTimeType::class);
}
$this->type = Type::getType(ChronosDateTimeType::CHRONOS_DATETIME);
}
/**
* @test
*/
public function nameIsReturned()
{
$this->assertEquals(ChronosDateTimeType::CHRONOS_DATETIME, $this->type->getName());
}
/**
* @test
* @dataProvider provideValues
*/
public function valueIsConverted(?string $value, ?string $expected)
{
$platform = $this->prophesize(AbstractPlatform::class);
$platform->getDateTimeFormatString()->willReturn('Y-m-d H:i:s');
$result = $this->type->convertToPHPValue($value, $platform->reveal());
if ($expected === null) {
$this->assertNull($result);
} else {
$this->assertInstanceOf($expected, $result);
}
}
public function provideValues(): array
{
return [
[null, null],
['now', Chronos::class],
['2017-01-01', Chronos::class],
];
}
/**
* @test
* @dataProvider providePhpValues
*/
public function valueIsConvertedToDatabaseFormat(?\DateTimeInterface $value, ?string $expected)
{
$platform = $this->prophesize(AbstractPlatform::class);
$platform->getDateTimeFormatString()->willReturn('Y-m-d');
$this->assertEquals($expected, $this->type->convertToDatabaseValue($value, $platform->reveal()));
}
public function providePhpValues(): array
{
return [
[null, null],
[new \DateTimeImmutable('2017-01-01'), '2017-01-01'],
[Chronos::parse('2017-02-01'), '2017-02-01'],
[new \DateTime('2017-03-01'), '2017-03-01'],
];
}
/**
* @test
*/
public function exceptionIsThrownIfInvalidValueIsParsedToDatabase()
{
$this->expectException(ConversionException::class);
$this->type->convertToDatabaseValue(new \stdClass(), $this->prophesize(AbstractPlatform::class)->reveal());
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Util;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\DateRange;
@@ -24,8 +25,8 @@ class DateRangeTest extends TestCase
*/
public function providedDatesAreSet()
{
$startDate = new \DateTime();
$endDate = new \DateTime();
$startDate = Chronos::now();
$endDate = Chronos::now();
$range = new DateRange($startDate, $endDate);
$this->assertSame($startDate, $range->getStartDate());
$this->assertSame($endDate, $range->getEndDate());

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -36,8 +37,8 @@ class ShortUrl extends AbstractEntity
*/
private $shortCode;
/**
* @var \DateTime
* @ORM\Column(name="date_created", type="datetime")
* @var Chronos
* @ORM\Column(name="date_created", type="chronos_datetime")
*/
private $dateCreated;
/**
@@ -56,13 +57,13 @@ class ShortUrl extends AbstractEntity
*/
private $tags;
/**
* @var \DateTime
* @ORM\Column(name="valid_since", type="datetime", nullable=true)
* @var Chronos|null
* @ORM\Column(name="valid_since", type="chronos_datetime", nullable=true)
*/
private $validSince;
/**
* @var \DateTime
* @ORM\Column(name="valid_until", type="datetime", nullable=true)
* @var Chronos|null
* @ORM\Column(name="valid_until", type="chronos_datetime", nullable=true)
*/
private $validUntil;
/**
@@ -74,7 +75,7 @@ class ShortUrl extends AbstractEntity
public function __construct()
{
$this->shortCode = '';
$this->dateCreated = new \DateTime();
$this->dateCreated = Chronos::now();
$this->visits = new ArrayCollection();
$this->tags = new ArrayCollection();
}
@@ -117,12 +118,12 @@ class ShortUrl extends AbstractEntity
return $this;
}
public function getDateCreated(): \DateTime
public function getDateCreated(): Chronos
{
return $this->dateCreated;
}
public function setDateCreated(\DateTime $dateCreated): self
public function setDateCreated(Chronos $dateCreated): self
{
$this->dateCreated = $dateCreated;
return $this;
@@ -151,23 +152,23 @@ class ShortUrl extends AbstractEntity
return $this;
}
public function getValidSince(): ?\DateTime
public function getValidSince(): ?Chronos
{
return $this->validSince;
}
public function setValidSince(?\DateTime $validSince): self
public function setValidSince(?Chronos $validSince): self
{
$this->validSince = $validSince;
return $this;
}
public function getValidUntil(): ?\DateTime
public function getValidUntil(): ?Chronos
{
return $this->validUntil;
}
public function setValidUntil(?\DateTime $validUntil): self
public function setValidUntil(?Chronos $validUntil): self
{
$this->validUntil = $validUntil;
return $this;

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use Cake\Chronos\Chronos;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
@@ -25,8 +26,8 @@ class Visit extends AbstractEntity implements \JsonSerializable
*/
private $referer;
/**
* @var \DateTime
* @ORM\Column(type="datetime", nullable=false)
* @var Chronos
* @ORM\Column(type="chronos_datetime", nullable=false)
*/
private $date;
/**
@@ -54,7 +55,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
public function __construct()
{
$this->date = new \DateTime();
$this->date = Chronos::now();
}
public function getReferer(): string
@@ -68,12 +69,12 @@ class Visit extends AbstractEntity implements \JsonSerializable
return $this;
}
public function getDate(): \DateTime
public function getDate(): Chronos
{
return $this->date;
}
public function setDate(\DateTime $date): self
public function setDate(Chronos $date): self
{
$this->date = $date;
return $this;
@@ -148,7 +149,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
{
return [
'referer' => $this->referer,
'date' => isset($this->date) ? $this->date->format(\DateTime::ATOM) : null,
'date' => isset($this->date) ? $this->date->toAtomString() : null,
'userAgent' => $this->userAgent,
'visitLocation' => $this->visitLocation,

View File

@@ -5,7 +5,7 @@ namespace Shlinkio\Shlink\Core\Model;
use Psr\Http\Message\UriInterface;
final class CreateShortCodeData
final class CreateShortUrlData
{
/**
* @var UriInterface

View File

@@ -3,17 +3,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function is_string;
final class ShortUrlMeta
{
/**
* @var \DateTime|null
* @var Chronos|null
*/
private $validSince;
/**
* @var \DateTime|null
* @var Chronos|null
*/
private $validUntil;
/**
@@ -43,8 +45,8 @@ final class ShortUrlMeta
}
/**
* @param string|\DateTimeInterface|null $validSince
* @param string|\DateTimeInterface|null $validUntil
* @param string|Chronos|null $validSince
* @param string|Chronos|null $validUntil
* @param string|null $customSlug
* @param int|null $maxVisits
* @return ShortUrlMeta
@@ -86,26 +88,23 @@ final class ShortUrlMeta
}
/**
* @param string|\DateTime|null $date
* @return \DateTime|null
* @param string|Chronos|null $date
* @return Chronos|null
*/
private function parseDateField($date): ?\DateTime
private function parseDateField($date): ?Chronos
{
if ($date === null || $date instanceof \DateTime) {
if ($date === null || $date instanceof Chronos) {
return $date;
}
if (\is_string($date)) {
return new \DateTime($date);
if (is_string($date)) {
return Chronos::parse($date);
}
return null;
}
/**
* @return \DateTime|null
*/
public function getValidSince(): ?\DateTime
public function getValidSince(): ?Chronos
{
return $this->validSince;
}
@@ -115,10 +114,7 @@ final class ShortUrlMeta
return $this->validSince !== null;
}
/**
* @return \DateTime|null
*/
public function getValidUntil(): ?\DateTime
public function getValidUntil(): ?Chronos
{
return $this->validUntil;
}

View File

@@ -3,9 +3,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use function array_column;
use function array_key_exists;
use function is_array;
use function key;
use function Shlinkio\Shlink\Common\contains;
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
{
@@ -54,19 +60,19 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
'shortCode' => 'shortCode',
'dateCreated' => 'dateCreated',
];
$fieldName = \is_array($orderBy) ? \key($orderBy) : $orderBy;
$order = \is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
$fieldName = is_array($orderBy) ? key($orderBy) : $orderBy;
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
if (\in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
if (contains($fieldName, ['visits', 'visitsCount', 'visitCount'])) {
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
->groupBy('s')
->orderBy('totalVisits', $order);
return \array_column($qb->getQuery()->getResult(), 0);
return array_column($qb->getQuery()->getResult(), 0);
}
if (\array_key_exists($fieldName, $fieldNameMap)) {
if (array_key_exists($fieldName, $fieldNameMap)) {
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
}
return $qb->getQuery()->getResult();
@@ -131,7 +137,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
*/
public function findOneByShortCode(string $shortCode): ?ShortUrl
{
$now = new \DateTimeImmutable();
$now = Chronos::now();
$qb = $this->createQueryBuilder('s');
$qb->where($qb->expr()->eq('s.shortCode', ':shortCode'))

View File

@@ -9,6 +9,9 @@ use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
use Zend\Expressive\Template\TemplateRendererInterface;
use function array_shift;
use function explode;
use function Shlinkio\Shlink\Common\contains;
class NotFoundHandler implements RequestHandlerInterface
{
@@ -39,12 +42,12 @@ class NotFoundHandler implements RequestHandlerInterface
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$accepts = \explode(',', $request->getHeaderLine('Accept'));
$accept = \array_shift($accepts);
$accepts = explode(',', $request->getHeaderLine('Accept'));
$accept = array_shift($accepts);
$status = StatusCodeInterface::STATUS_NOT_FOUND;
// If the first accepted type is json, return a json response
if (\in_array($accept, ['application/json', 'text/json', 'application/x-json'], true)) {
if (contains($accept, ['application/json', 'text/json', 'application/x-json'])) {
return new Response\JsonResponse([
'error' => 'NOT_FOUND',
'message' => 'Not found',

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Cake\Chronos\Chronos;
use Cocur\Slugify\Slugify;
use Cocur\Slugify\SlugifyInterface;
use Doctrine\ORM\EntityManagerInterface;
@@ -61,14 +62,6 @@ class UrlShortener implements UrlShortenerInterface
}
/**
* Creates and persists a unique shortcode generated for provided url
*
* @param UriInterface $url
* @param string[] $tags
* @param \DateTime|null $validSince
* @param \DateTime|null $validUntil
* @param string|null $customSlug
* @param int|null $maxVisits
* @throws NonUniqueSlugException
* @throws InvalidUrlException
* @throws RuntimeException
@@ -76,10 +69,10 @@ class UrlShortener implements UrlShortenerInterface
public function urlToShortCode(
UriInterface $url,
array $tags = [],
\DateTime $validSince = null,
\DateTime $validUntil = null,
string $customSlug = null,
int $maxVisits = null
?Chronos $validSince = null,
?Chronos $validUntil = null,
?string $customSlug = null,
?int $maxVisits = null
): ShortUrl {
// If the URL validation is enabled, check that the URL actually exists
if ($this->urlValidationEnabled) {

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Cake\Chronos\Chronos;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
@@ -14,14 +15,6 @@ use Shlinkio\Shlink\Core\Exception\RuntimeException;
interface UrlShortenerInterface
{
/**
* Creates and persists a unique shortcode generated for provided url
*
* @param UriInterface $url
* @param string[] $tags
* @param \DateTime|null $validSince
* @param \DateTime|null $validUntil
* @param string|null $customSlug
* @param int|null $maxVisits
* @throws NonUniqueSlugException
* @throws InvalidUrlException
* @throws RuntimeException
@@ -29,10 +22,10 @@ interface UrlShortenerInterface
public function urlToShortCode(
UriInterface $url,
array $tags = [],
\DateTime $validSince = null,
\DateTime $validUntil = null,
string $customSlug = null,
int $maxVisits = null
?Chronos $validSince = null,
?Chronos $validUntil = null,
?string $customSlug = null,
?int $maxVisits = null
): ShortUrl;
/**

View File

@@ -24,7 +24,6 @@ class ShortUrlDataTransformer implements DataTransformerInterface
/**
* @param ShortUrl $value
* @return array
*/
public function transform($value): array
{
@@ -36,7 +35,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface
'shortCode' => $shortCode,
'shortUrl' => $this->buildShortUrl($this->domainConfig, $shortCode),
'longUrl' => $longUrl,
'dateCreated' => $dateCreated !== null ? $dateCreated->format(\DateTime::ATOM) : null,
'dateCreated' => $dateCreated !== null ? $dateCreated->toAtomString() : null,
'visitsCount' => $value->getVisitsCount(),
'tags' => \array_map([$this, 'serializeTag'], $value->getTags()->toArray()),

View File

@@ -12,12 +12,12 @@ class ShortUrlMetaInputFilter extends InputFilter
{
use InputFactoryTrait;
const VALID_SINCE = 'validSince';
const VALID_UNTIL = 'validUntil';
const CUSTOM_SLUG = 'customSlug';
const MAX_VISITS = 'maxVisits';
public const VALID_SINCE = 'validSince';
public const VALID_UNTIL = 'validUntil';
public const CUSTOM_SLUG = 'customSlug';
public const MAX_VISITS = 'maxVisits';
public function __construct(array $data = null)
public function __construct(?array $data = null)
{
$this->initialize();
if ($data !== null) {
@@ -25,7 +25,7 @@ class ShortUrlMetaInputFilter extends InputFilter
}
}
private function initialize()
private function initialize(): void
{
$validSince = $this->createInput(self::VALID_SINCE, false);
$validSince->getValidatorChain()->attach(new Date(['format' => \DateTime::ATOM]));

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
@@ -41,7 +42,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$bar = new ShortUrl();
$bar->setOriginalUrl('bar')
->setShortCode('bar_very_long_text')
->setValidSince((new \DateTime())->add(new \DateInterval('P1M')));
->setValidSince(Chronos::now()->addMonth());
$this->getEntityManager()->persist($bar);
$visits = [];

View File

@@ -3,12 +3,14 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
use function sprintf;
class VisitRepositoryTest extends DatabaseTestCase
{
@@ -61,7 +63,7 @@ class VisitRepositoryTest extends DatabaseTestCase
for ($i = 0; $i < 6; $i++) {
$visit = new Visit();
$visit->setShortUrl($shortUrl)
->setDate(new \DateTime('2016-01-0' . ($i + 1)));
->setDate(Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
$this->getEntityManager()->persist($visit);
}
@@ -70,11 +72,11 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->assertCount(0, $this->repo->findVisitsByShortUrl('invalid'));
$this->assertCount(6, $this->repo->findVisitsByShortUrl($shortUrl->getId()));
$this->assertCount(2, $this->repo->findVisitsByShortUrl($shortUrl->getId(), new DateRange(
new \DateTime('2016-01-02'),
new \DateTime('2016-01-03')
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03')
)));
$this->assertCount(4, $this->repo->findVisitsByShortUrl($shortUrl->getId(), new DateRange(
new \DateTime('2016-01-03')
Chronos::parse('2016-01-03')
)));
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
@@ -42,10 +43,10 @@ class ShortUrlMetaTest extends TestCase
*/
public function properlyCreatedInstanceReturnsValues()
{
$meta = ShortUrlMeta::createFromParams((new \DateTime('2015-01-01'))->format(\DateTime::ATOM), null, 'foobar');
$meta = ShortUrlMeta::createFromParams(Chronos::parse('2015-01-01')->toAtomString(), null, 'foobar');
$this->assertTrue($meta->hasValidSince());
$this->assertEquals(new \DateTime('2015-01-01'), $meta->getValidSince());
$this->assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince());
$this->assertFalse($meta->hasValidUntil());
$this->assertNull($meta->getValidUntil());

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase;
@@ -104,15 +105,15 @@ class ShortUrlServiceTest extends TestCase
$flush = $this->em->flush($shortUrl)->willReturn(null);
$result = $this->service->updateMetadataByShortCode('abc123', ShortUrlMeta::createFromParams(
(new \DateTime('2017-01-01 00:00:00'))->format(\DateTime::ATOM),
(new \DateTime('2017-01-05 00:00:00'))->format(\DateTime::ATOM),
Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
null,
5
));
$this->assertSame($shortUrl, $result);
$this->assertEquals(new \DateTime('2017-01-01 00:00:00'), $shortUrl->getValidSince());
$this->assertEquals(new \DateTime('2017-01-05 00:00:00'), $shortUrl->getValidUntil());
$this->assertEquals(Chronos::parse('2017-01-01 00:00:00'), $shortUrl->getValidSince());
$this->assertEquals(Chronos::parse('2017-01-05 00:00:00'), $shortUrl->getValidUntil());
$this->assertEquals(5, $shortUrl->getMaxVisits());
$findShortUrl->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();

View File

@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
return [];

View File

@@ -1,13 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Install;
namespace Shlinkio\Shlink\Installer\Command;
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 Shlinkio\Shlink\Installer\Config\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
@@ -23,7 +24,9 @@ use Zend\Config\Writer\WriterInterface;
class InstallCommand extends Command
{
const GENERATED_CONFIG_PATH = 'config/params/generated_config.php';
use AskUtilsTrait;
public const GENERATED_CONFIG_PATH = 'config/params/generated_config.php';
/**
* @var SymfonyStyle
@@ -102,7 +105,7 @@ class InstallCommand extends Command
$this->io->writeln([
'<info>Welcome to Shlink!!</info>',
'This will guide you through the installation process.',
'This tool will guide you through the installation process.',
]);
// Check if a cached config file exists and drop it if so
@@ -143,7 +146,7 @@ class InstallCommand extends Command
$this->io->writeln(['<info>Custom configuration properly generated!</info>', '']);
// If current command is not update, generate database
if (! $this->isUpdate) {
if (! $this->isUpdate) {
$this->io->write('Initializing database...');
if (! $this->runPhpCommand(
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
@@ -186,7 +189,10 @@ class InstallCommand extends Command
$config = new CustomizableAppConfig();
// Ask the user if he/she wants to import an older configuration
$importConfig = $this->io->confirm('Do you want to import configuration from previous installation?');
$importConfig = $this->io->confirm(
'Do you want to import configuration from previous installation? (You will still be asked for any new '
. 'config option that did not exist in previous shlink versions)'
);
if (! $importConfig) {
return $config;
}
@@ -194,7 +200,9 @@ class InstallCommand extends Command
// Ask the user for the older shlink path
$keepAsking = true;
do {
$config->setImportedInstallationPath($this->io->ask(
$config->setImportedInstallationPath($this->askRequired(
$this->io,
'previous installation path',
'Previous shlink installation path from which to import config'
));
$configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH;

View File

@@ -1,9 +1,9 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install;
namespace Shlinkio\Shlink\Installer\Config;
use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerInterface;
use Shlinkio\Shlink\Installer\Config\Plugin\ConfigCustomizerInterface;
use Zend\ServiceManager\AbstractPluginManager;
class ConfigCustomizerManager extends AbstractPluginManager implements ConfigCustomizerManagerInterface

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Install;
namespace Shlinkio\Shlink\Installer\Config;
use Psr\Container\ContainerInterface;

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Installer\Exception\InvalidConfigOptionException;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_diff;
use function array_keys;
use function is_numeric;
use function sprintf;
class ApplicationConfigCustomizer implements ConfigCustomizerInterface
{
use StringUtilsTrait;
public const SECRET = 'SECRET';
public const DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
public const CHECK_VISITS_THRESHOLD = 'CHECK_VISITS_THRESHOLD';
public const VISITS_THRESHOLD = 'VISITS_THRESHOLD';
private const EXPECTED_KEYS = [
self::SECRET,
self::DISABLE_TRACK_PARAM,
self::CHECK_VISITS_THRESHOLD,
self::VISITS_THRESHOLD,
];
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$app = $appConfig->getApp();
$keysToAskFor = $appConfig->hasApp() ? array_diff(self::EXPECTED_KEYS, array_keys($app)) : self::EXPECTED_KEYS;
if (empty($keysToAskFor)) {
return;
}
$io->title('APPLICATION');
foreach ($keysToAskFor as $key) {
// Skip visits threshold when the user decided not to check visits on deletions
if ($key === self::VISITS_THRESHOLD && ! $app[self::CHECK_VISITS_THRESHOLD]) {
continue;
}
$app[$key] = $this->ask($io, $key);
}
$appConfig->setApp($app);
}
private function ask(SymfonyStyle $io, string $key)
{
switch ($key) {
case self::SECRET:
return $io->ask(
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one) '
. '<fg=red>[DEPRECATED. TO BE REMOVED]</>'
) ?: $this->generateRandomString(32);
case self::DISABLE_TRACK_PARAM:
return $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)'
);
case self::CHECK_VISITS_THRESHOLD:
return $io->confirm(
'Do you want to enable a safety check which will not allow short URLs to be deleted when they '
. 'have more than a specific amount of visits?'
);
case self::VISITS_THRESHOLD:
return $io->ask(
'What is the amount of visits from which the system will not allow short URLs to be deleted?',
15,
[$this, 'validateVisitsThreshold']
);
}
return '';
}
public function validateVisitsThreshold($value): int
{
if (! is_numeric($value) || $value < 1) {
throw new InvalidConfigOptionException(
sprintf('Provided value "%s" is invalid. Expected a number greater than 1', $value)
);
}
return (int) $value;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
interface ConfigCustomizerInterface
{
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void;
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use function array_diff;
use function array_keys;
use function Shlinkio\Shlink\Common\contains;
class DatabaseConfigCustomizer implements ConfigCustomizerInterface
{
use AskUtilsTrait;
public const DRIVER = 'DRIVER';
public const NAME = 'NAME';
public const USER = 'USER';
public const PASSWORD = 'PASSWORD';
public const HOST = 'HOST';
public const PORT = 'PORT';
private const DRIVER_DEPENDANT_OPTIONS = [
self::DRIVER,
self::NAME,
self::USER,
self::PASSWORD,
self::HOST,
self::PORT,
];
private const EXPECTED_KEYS = self::DRIVER_DEPENDANT_OPTIONS; // Same now, but could change in the future
private const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
'SQLite' => 'pdo_sqlite',
];
/**
* @var Filesystem
*/
private $filesystem;
public function __construct(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
}
/**
* @throws IOException
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$titlePrinted = false;
$db = $appConfig->getDatabase();
$doImport = $appConfig->hasDatabase();
$keysToAskFor = $doImport ? array_diff(self::EXPECTED_KEYS, array_keys($db)) : self::EXPECTED_KEYS;
// If the user selected to keep DB, try to import SQLite database
if ($doImport) {
$this->importSqliteDbFile($io, $appConfig);
}
if (empty($keysToAskFor)) {
return;
}
// If the driver is one of the params to ask for, ask for it first
if (contains(self::DRIVER, $keysToAskFor)) {
$io->title('DATABASE');
$titlePrinted = true;
$db[self::DRIVER] = $this->ask($io, self::DRIVER);
$keysToAskFor = array_diff($keysToAskFor, [self::DRIVER]);
}
// If driver is SQLite, do not ask any driver-dependant option
if ($db[self::DRIVER] === self::DATABASE_DRIVERS['SQLite']) {
$keysToAskFor = array_diff($keysToAskFor, self::DRIVER_DEPENDANT_OPTIONS);
}
if (! $titlePrinted && ! empty($keysToAskFor)) {
$io->title('DATABASE');
}
foreach ($keysToAskFor as $key) {
$db[$key] = $this->ask($io, $key, $db);
}
$appConfig->setDatabase($db);
}
private function importSqliteDbFile(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
if ($appConfig->getDatabase()[self::DRIVER] !== self::DATABASE_DRIVERS['SQLite']) {
return;
}
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;
}
}
private function ask(SymfonyStyle $io, string $key, array $params = [])
{
switch ($key) {
case self::DRIVER:
$databases = array_keys(self::DATABASE_DRIVERS);
$dbType = $io->choice('Select database type', $databases, $databases[0]);
return self::DATABASE_DRIVERS[$dbType];
case self::NAME:
return $io->ask('Database name', 'shlink');
case self::USER:
return $this->askRequired($io, 'username', 'Database username');
case self::PASSWORD:
return $this->askRequired($io, 'password', 'Database password');
case self::HOST:
return $io->ask('Database host', 'localhost');
case self::PORT:
return $io->ask('Database port', $this->getDefaultDbPort($params[self::DRIVER]));
}
return '';
}
private function getDefaultDbPort(string $driver): string
{
return $driver === 'pdo_mysql' ? '3306' : '5432';
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_diff;
use function array_keys;
class LanguageConfigCustomizer implements ConfigCustomizerInterface
{
public const DEFAULT_LANG = 'DEFAULT';
public const CLI_LANG = 'CLI';
private const EXPECTED_KEYS = [
self::DEFAULT_LANG,
self::CLI_LANG,
];
private const SUPPORTED_LANGUAGES = ['en', 'es'];
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$lang = $appConfig->getLanguage();
$keysToAskFor = $appConfig->hasLanguage()
? array_diff(self::EXPECTED_KEYS, array_keys($lang))
: self::EXPECTED_KEYS;
if (empty($keysToAskFor)) {
return;
}
$io->title('LANGUAGE');
foreach ($keysToAskFor as $key) {
$lang[$key] = $this->ask($io, $key);
}
$appConfig->setLanguage($lang);
}
private function ask(SymfonyStyle $io, string $key)
{
switch ($key) {
case self::DEFAULT_LANG:
return $this->chooseLanguage($io, 'Select default language for the application in general');
case self::CLI_LANG:
return $this->chooseLanguage($io, 'Select default language for CLI executions');
}
return '';
}
private function chooseLanguage(SymfonyStyle $io, string $message): string
{
return $io->choice($message, self::SUPPORTED_LANGUAGES, self::SUPPORTED_LANGUAGES[0]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_diff;
use function array_keys;
use function str_shuffle;
class UrlShortenerConfigCustomizer implements ConfigCustomizerInterface
{
use AskUtilsTrait;
public const SCHEMA = 'SCHEMA';
public const HOSTNAME = 'HOSTNAME';
public const CHARS = 'CHARS';
public const VALIDATE_URL = 'VALIDATE_URL';
private const EXPECTED_KEYS = [
self::SCHEMA,
self::HOSTNAME,
self::CHARS,
self::VALIDATE_URL,
];
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$urlShortener = $appConfig->getUrlShortener();
$doImport = $appConfig->hasUrlShortener();
$keysToAskFor = $doImport ? array_diff(self::EXPECTED_KEYS, array_keys($urlShortener)) : self::EXPECTED_KEYS;
if (empty($keysToAskFor)) {
return;
}
$io->title('URL SHORTENER');
foreach ($keysToAskFor as $key) {
$urlShortener[$key] = $this->ask($io, $key);
}
$appConfig->setUrlShortener($urlShortener);
}
private function ask(SymfonyStyle $io, string $key)
{
switch ($key) {
case self::SCHEMA:
return $io->choice(
'Select schema for generated short URLs',
['http', 'https'],
'http'
);
case self::HOSTNAME:
return $this->askRequired($io, 'hostname', 'Hostname for generated URLs');
case self::CHARS:
return $io->ask(
'Character set for generated short codes (leave empty to autogenerate one)'
) ?: str_shuffle(UrlShortener::DEFAULT_CHARS);
case self::VALIDATE_URL:
return $io->confirm('Do you want to validate long urls by 200 HTTP status code on response');
}
return '';
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
use Zend\Config\Factory;
use Zend\Stdlib\Glob;
class ConfigProvider
{
public function __invoke()
{
return Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Exception;
use RuntimeException;
use function sprintf;
class MissingRequiredOptionException extends RuntimeException implements ExceptionInterface
{
public static function fromOption(string $optionName): self
{
return new self(sprintf('The "%s" is required and can\'t be empty', $optionName));
}
}

View File

@@ -1,13 +1,13 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Factory;
namespace Shlinkio\Shlink\Installer\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerManager;
use Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\Installer\Command\InstallCommand;
use Shlinkio\Shlink\Installer\Config\ConfigCustomizerManager;
use Shlinkio\Shlink\Installer\Config\Plugin;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Filesystem\Filesystem;
@@ -50,7 +50,7 @@ class InstallApplicationFactory implements FactoryInterface
$isUpdate
);
$app->add($command);
$app->setDefaultCommand($command->getName());
$app->setDefaultCommand($command->getName(), true);
return $app;
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Model;
use Shlinkio\Shlink\Installer\Config\Plugin\ApplicationConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\UrlShortenerConfigCustomizer;
use Zend\Stdlib\ArraySerializableInterface;
use function Shlinkio\Shlink\Common\array_get_path;
use function Shlinkio\Shlink\Common\array_path_exists;
final class CustomizableAppConfig implements ArraySerializableInterface
{
public const SQLITE_DB_PATH = 'data/database.sqlite';
/**
* @var array
*/
private $database = [];
/**
* @var array
*/
private $urlShortener = [];
/**
* @var array
*/
private $language = [];
/**
* @var array
*/
private $app = [];
/**
* @var string|null
*/
private $importedInstallationPath;
public function getDatabase(): array
{
return $this->database;
}
public function setDatabase(array $database): self
{
$this->database = $database;
return $this;
}
public function hasDatabase(): bool
{
return ! empty($this->database);
}
public function getUrlShortener(): array
{
return $this->urlShortener;
}
public function setUrlShortener(array $urlShortener): self
{
$this->urlShortener = $urlShortener;
return $this;
}
public function hasUrlShortener(): bool
{
return ! empty($this->urlShortener);
}
public function getLanguage(): array
{
return $this->language;
}
public function setLanguage(array $language): self
{
$this->language = $language;
return $this;
}
public function hasLanguage(): bool
{
return ! empty($this->language);
}
public function getApp(): array
{
return $this->app;
}
public function setApp(array $app): self
{
$this->app = $app;
return $this;
}
public function hasApp(): bool
{
return ! empty($this->app);
}
public function getImportedInstallationPath(): ?string
{
return $this->importedInstallationPath;
}
public function setImportedInstallationPath(string $importedInstallationPath): self
{
$this->importedInstallationPath = $importedInstallationPath;
return $this;
}
public function hasImportedInstallationPath(): bool
{
return $this->importedInstallationPath !== null;
}
public function exchangeArray(array $array): void
{
$this->setApp($this->mapExistingPathsToKeys([
ApplicationConfigCustomizer::SECRET => ['app_options', 'secret_key'],
ApplicationConfigCustomizer::DISABLE_TRACK_PARAM => ['app_options', 'disable_track_param'],
ApplicationConfigCustomizer::CHECK_VISITS_THRESHOLD => ['delete_short_urls', 'check_visits_threshold'],
ApplicationConfigCustomizer::VISITS_THRESHOLD => ['delete_short_urls', 'visits_threshold'],
], $array));
$this->setDatabase($this->mapExistingPathsToKeys([
DatabaseConfigCustomizer::DRIVER => ['entity_manager', 'connection', 'driver'],
DatabaseConfigCustomizer::USER => ['entity_manager', 'connection', 'user'],
DatabaseConfigCustomizer::PASSWORD => ['entity_manager', 'connection', 'password'],
DatabaseConfigCustomizer::NAME => ['entity_manager', 'connection', 'dbname'],
DatabaseConfigCustomizer::HOST => ['entity_manager', 'connection', 'host'],
DatabaseConfigCustomizer::PORT => ['entity_manager', 'connection', 'port'],
], $array));
$this->setLanguage($this->mapExistingPathsToKeys([
LanguageConfigCustomizer::DEFAULT_LANG => ['translator', 'locale'],
LanguageConfigCustomizer::CLI_LANG => ['cli', 'locale'],
], $array));
$this->setUrlShortener($this->mapExistingPathsToKeys([
UrlShortenerConfigCustomizer::SCHEMA => ['url_shortener', 'domain', 'schema'],
UrlShortenerConfigCustomizer::HOSTNAME => ['url_shortener', 'domain', 'hostname'],
UrlShortenerConfigCustomizer::CHARS => ['url_shortener', 'shortcode_chars'],
UrlShortenerConfigCustomizer::VALIDATE_URL => ['url_shortener', 'validate_url'],
], $array));
}
private function mapExistingPathsToKeys(array $map, array $config): array
{
$result = [];
foreach ($map as $key => $path) {
if (array_path_exists($path, $config)) {
$result[$key] = array_get_path($path, $config);
}
}
return $result;
}
public function getArrayCopy(): array
{
$dbDriver = $this->database[DatabaseConfigCustomizer::DRIVER] ?? '';
$config = [
'app_options' => [
'secret_key' => $this->app[ApplicationConfigCustomizer::SECRET] ?? '',
'disable_track_param' => $this->app[ApplicationConfigCustomizer::DISABLE_TRACK_PARAM] ?? null,
],
'delete_short_urls' => [
'check_visits_threshold' => $this->app[ApplicationConfigCustomizer::CHECK_VISITS_THRESHOLD] ?? true,
'visits_threshold' => $this->app[ApplicationConfigCustomizer::VISITS_THRESHOLD] ?? 15,
],
'entity_manager' => [
'connection' => [
'driver' => $dbDriver,
],
],
'translator' => [
'locale' => $this->language[LanguageConfigCustomizer::DEFAULT_LANG] ?? 'en',
],
'cli' => [
'locale' => $this->language[LanguageConfigCustomizer::CLI_LANG] ?? 'en',
],
'url_shortener' => [
'domain' => [
'schema' => $this->urlShortener[UrlShortenerConfigCustomizer::SCHEMA] ?? 'http',
'hostname' => $this->urlShortener[UrlShortenerConfigCustomizer::HOSTNAME] ?? '',
],
'shortcode_chars' => $this->urlShortener[UrlShortenerConfigCustomizer::CHARS] ?? '',
'validate_url' => $this->urlShortener[UrlShortenerConfigCustomizer::VALIDATE_URL] ?? true,
],
];
// Build dynamic database config based on selected driver
if ($dbDriver === 'pdo_sqlite') {
$config['entity_manager']['connection']['path'] = self::SQLITE_DB_PATH;
} else {
$config['entity_manager']['connection']['user'] = $this->database[DatabaseConfigCustomizer::USER] ?? '';
$config['entity_manager']['connection']['password'] =
$this->database[DatabaseConfigCustomizer::PASSWORD] ?? '';
$config['entity_manager']['connection']['dbname'] = $this->database[DatabaseConfigCustomizer::NAME] ?? '';
$config['entity_manager']['connection']['host'] = $this->database[DatabaseConfigCustomizer::HOST] ?? '';
$config['entity_manager']['connection']['port'] = $this->database[DatabaseConfigCustomizer::PORT] ?? '';
if ($dbDriver === 'pdo_mysql') {
$config['entity_manager']['connection']['driverOptions'] = [
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
];
}
}
return $config;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Util;
use Shlinkio\Shlink\Installer\Exception\MissingRequiredOptionException;
use Symfony\Component\Console\Style\SymfonyStyle;
trait AskUtilsTrait
{
/**
* @return mixed
*/
private function askRequired(SymfonyStyle $io, string $optionName, string $question)
{
return $io->ask($question, null, function ($value) use ($optionName) {
if (empty($value)) {
throw MissingRequiredOptionException::fromOption($optionName);
};
return $value;
});
}
}

View File

@@ -1,15 +1,15 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Install;
namespace ShlinkioTest\Shlink\Installer\Command;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerInterface;
use Shlinkio\Shlink\Installer\Command\InstallCommand;
use Shlinkio\Shlink\Installer\Config\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\Installer\Config\Plugin\ConfigCustomizerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Tester\CommandTester;
@@ -131,14 +131,14 @@ class InstallCommandTest extends TestCase
/** @var MethodProphecy $importedConfigExists */
$importedConfigExists = $this->filesystem->exists(
__DIR__ . '/../../../test-resources/' . InstallCommand::GENERATED_CONFIG_PATH
__DIR__ . '/../../test-resources/' . InstallCommand::GENERATED_CONFIG_PATH
)->willReturn(true);
$this->commandTester->setInputs([
'',
'/foo/bar/wrong_previous_shlink',
'',
__DIR__ . '/../../../test-resources',
__DIR__ . '/../../test-resources',
]);
$this->commandTester->execute([]);

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Config\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Installer\Config\Plugin\ApplicationConfigCustomizer;
use Shlinkio\Shlink\Installer\Exception\InvalidConfigOptionException;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_shift;
use function strpos;
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('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasApp());
$this->assertEquals([
'SECRET' => 'asked',
'DISABLE_TRACK_PARAM' => 'asked',
'CHECK_VISITS_THRESHOLD' => false,
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function visitsThresholdIsRequestedIfCheckIsEnabled()
{
$ask = $this->io->ask(Argument::cetera())->will(function (array $args) {
$message = array_shift($args);
return strpos($message, 'What is the amount of visits') === 0 ? 20 : 'asked';
});
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasApp());
$this->assertEquals([
'SECRET' => 'asked',
'DISABLE_TRACK_PARAM' => 'asked',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(3);
$confirm->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function onlyMissingOptionsAreAsked()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('disable_param');
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'disable_param',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('the_new_secret');
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'the_new_secret',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'the_new_secret',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
], $config->getApp());
$ask->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideInvalidValues
* @param mixed $value
*/
public function validateVisitsThresholdThrowsExceptionWhenProvidedValueIsInvalid($value)
{
$this->expectException(InvalidConfigOptionException::class);
$this->plugin->validateVisitsThreshold($value);
}
public function provideInvalidValues(): array
{
return [
'string' => ['foo'],
'empty string' => [''],
'negative number' => [-5],
'negative number as string' => ['-5'],
'zero' => [0],
'zero as string' => ['0'],
];
}
/**
* @test
* @dataProvider provideValidValues
* @param mixed $value
*/
public function validateVisitsThresholdCastsToIntWhenProvidedValueIsValid($value, int $expected)
{
$this->assertEquals($expected, $this->plugin->validateVisitsThreshold($value));
}
public function provideValidValues(): array
{
return [
'positive as string' => ['20', 20],
'positive as integer' => [5, 5],
'one as string' => ['1', 1],
'one as integer' => [1, 1],
];
}
}

View File

@@ -1,13 +1,13 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
namespace ShlinkioTest\Shlink\Installer\Config\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Config\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
@@ -62,64 +62,62 @@ class DatabaseConfigCustomizerTest extends TestCase
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
public function onlyMissingOptionsAreAsked()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$ask = $this->io->ask(Argument::cetera())->willReturn('MySQL');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'NAME' => 'foo',
'PASSWORD' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_mysql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'DRIVER' => 'pdo_pgsql',
'NAME' => 'foo',
'USER' => 'asked',
'PASSWORD' => 'foo',
'HOST' => 'asked',
'PORT' => 'asked',
], $config->getDatabase());
$confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldHaveBeenCalledTimes(1);
$ask->shouldHaveBeenCalledTimes(5);
$choice->shouldNotHaveBeenCalled();
$ask->shouldHaveBeenCalledTimes(3);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'NAME' => 'foo',
'USER' => 'foo',
'PASSWORD' => 'foo',
'HOST' => 'foo',
'PORT' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'NAME' => 'foo',
'USER' => 'foo',
'PASSWORD' => 'foo',
'HOST' => 'foo',
'PORT' => 'foo',
], $config->getDatabase());
$confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldNotHaveBeenCalled();
$ask->shouldNotHaveBeenCalled();
}
/**
@@ -127,7 +125,6 @@ class DatabaseConfigCustomizerTest extends TestCase
*/
public function sqliteDatabaseIsImportedWhenRequested()
{
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$copy = $this->filesystem->copy(Argument::cetera())->willReturn(null);
$config = new CustomizableAppConfig();
@@ -140,7 +137,6 @@ class DatabaseConfigCustomizerTest extends TestCase
$this->assertEquals([
'DRIVER' => 'pdo_sqlite',
], $config->getDatabase());
$confirm->shouldHaveBeenCalledTimes(1);
$copy->shouldHaveBeenCalledTimes(1);
}
}

View File

@@ -1,13 +1,13 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
namespace ShlinkioTest\Shlink\Installer\Config\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Config\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
class LanguageConfigCustomizerTest extends TestCase
@@ -33,7 +33,7 @@ class LanguageConfigCustomizerTest extends TestCase
*/
public function configIsRequestedToTheUser()
{
$ask = $this->io->choice(Argument::cetera())->willReturn('en');
$choice = $this->io->choice(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
@@ -43,38 +43,35 @@ class LanguageConfigCustomizerTest extends TestCase
'DEFAULT' => 'en',
'CLI' => 'en',
], $config->getLanguage());
$ask->shouldHaveBeenCalledTimes(2);
$choice->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
public function onlyMissingOptionsAreAsked()
{
$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($this->io->reveal(), $config);
$this->assertEquals([
'DEFAULT' => 'es',
'DEFAULT' => 'en',
'CLI' => 'es',
], $config->getLanguage());
$choice->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$ask = $this->io->confirm(Argument::cetera())->willReturn(true);
$choice = $this->io->choice(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig();
$config->setLanguage([
@@ -88,6 +85,6 @@ class LanguageConfigCustomizerTest extends TestCase
'DEFAULT' => 'es',
'CLI' => 'es',
], $config->getLanguage());
$ask->shouldHaveBeenCalledTimes(1);
$choice->shouldNotHaveBeenCalled();
}
}

View File

@@ -1,13 +1,13 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
namespace ShlinkioTest\Shlink\Installer\Config\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\UrlShortenerConfigCustomizer;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Config\Plugin\UrlShortenerConfigCustomizer;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
class UrlShortenerConfigCustomizerTest extends TestCase
@@ -33,8 +33,8 @@ class UrlShortenerConfigCustomizerTest extends TestCase
*/
public function configIsRequestedToTheUser()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('something');
$ask = $this->io->ask(Argument::cetera())->willReturn('something');
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
@@ -42,9 +42,9 @@ class UrlShortenerConfigCustomizerTest extends TestCase
$this->assertTrue($config->hasUrlShortener());
$this->assertEquals([
'SCHEMA' => 'something',
'HOSTNAME' => 'something',
'CHARS' => 'something',
'SCHEMA' => 'chosen',
'HOSTNAME' => 'asked',
'CHARS' => 'asked',
'VALIDATE_URL' => true,
], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(2);
@@ -55,16 +55,44 @@ class UrlShortenerConfigCustomizerTest extends TestCase
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
public function onlyMissingOptionsAreAsked()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('foo');
$ask = $this->io->ask(Argument::cetera())->willReturn('foo');
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'bar',
'HOSTNAME' => 'bar',
'CHARS' => 'bar',
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'asked',
'VALIDATE_URL' => false,
], $config->getUrlShortener());
$choice->shouldNotHaveBeenCalled();
$ask->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => true,
]);
@@ -74,36 +102,10 @@ class UrlShortenerConfigCustomizerTest extends TestCase
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => false,
'VALIDATE_URL' => true,
], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(2);
$choice->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
{
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => 'foo',
], $config->getUrlShortener());
$confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldNotHaveBeenCalled();
$ask->shouldNotHaveBeenCalled();
$confirm->shouldNotHaveBeenCalled();
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Installer\ConfigProvider;
class ConfigProviderTest extends TestCase
{
/**
* @var ConfigProvider
*/
protected $configProvider;
public function setUp()
{
$this->configProvider = new ConfigProvider();
}
/**
* @test
*/
public function configIsReturned()
{
$config = $this->configProvider->__invoke();
$this->assertEmpty($config);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Installer\Config\Plugin\ApplicationConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
class CustomizableAppConfigTest extends TestCase
{
/**
* @test
*/
public function exchangeArrayIgnoresAnyNonProvidedKey()
{
$config = new CustomizableAppConfig();
$config->exchangeArray([
'app_options' => [
'disable_track_param' => null,
],
'translator' => [
'locale' => 'es',
],
]);
$this->assertFalse($config->hasDatabase());
$this->assertFalse($config->hasUrlShortener());
$this->assertTrue($config->hasApp());
$this->assertTrue($config->hasLanguage());
$this->assertEquals([
ApplicationConfigCustomizer::DISABLE_TRACK_PARAM => null,
], $config->getApp());
$this->assertEquals([
LanguageConfigCustomizer::DEFAULT_LANG => 'es',
], $config->getLanguage());
}
}

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